Compare commits

...

38 Commits

Author SHA1 Message Date
Huang Xin 0c25b85a8a release: version 0.9.98 (#3006) 2026-01-20 18:54:06 +01:00
Huang Xin c536450ab0 fix(fonts): host fonts in Readest CDN for more stable web fonts distribution (#3005) 2026-01-20 18:44:22 +01:00
Huang Xin c83e380c5a chore(doc): use png resources for sponsors logo (#3003) 2026-01-20 07:47:28 +01:00
Huang Xin 894a7551aa chore(doc): update sponsors info (#3002) 2026-01-20 07:42:06 +01:00
xijibomi-coffee f875ba88ac perf: use native walkdir for recursive imports from directory (#2993)
* fix(perf): replace JS recursion with native Rust walkdir for imports

* fix: implement security scope check in rust recursive scanner

* refactor and format code

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-01-20 06:22:04 +01:00
Huang Xin d9a6cffe78 feat(discord): support show reading status with Discord Rich Presence, closes #1538 (#2998) 2026-01-19 17:42:19 +01:00
Huang Xin 1704736bc8 feat(account): support social login with Discord account (#2995) 2026-01-19 05:22:17 +01:00
Huang Xin 6ce9a263ee fix(a11y): add missing aria labels for some buttons on TTS panel (#2994) 2026-01-19 04:29:02 +01:00
Huang Xin 038eca4267 fix(eink): resolved an issue where the dropdown menu would occasionally fail to expand on some Eink devices (#2991) 2026-01-18 19:57:09 +01:00
Huang Xin f0722ec0fe feat(bookshelf): show one line of book description in list view of the bookshelf, closes #2955 (#2990) 2026-01-18 17:16:54 +01:00
Huang Xin fd299a61a7 feat(notes): export notes with custom template, closes #1734 (#2989) 2026-01-18 16:56:32 +01:00
Huang Xin 36b7386e30 fix(portable): in-app update the portable version app, closes #2983 (#2988) 2026-01-18 11:20:15 +01:00
Huang Xin 2670b0dc0b fix(linux): use new AppImage format on Linux, closes #190 (#2985)
See https://github.com/tauri-apps/tauri/pull/12491
2026-01-18 09:50:51 +01:00
Huang Xin c44705e269 perf(pdf): pre-render next page for PDFs (#2984)
This should close #1911 and #13.
2026-01-18 08:58:00 +01:00
Huang Xin 7e618d456e chore: bump pdf.js to the latest version (#2982) 2026-01-17 13:45:34 +01:00
Huang Xin b28ac99a9e feat(pdf): support panning on PDFs (#2981)
This also closes #2978 and closes #2875.
2026-01-17 09:11:33 +01:00
Huang Xin 2d7d6b08a9 compat(opds): parse attachment filename from download requests, closes #2969 (#2976) 2026-01-16 17:13:30 +01:00
Huang Xin b1419f9c01 chore: bump tauri to latest dev (#2975) 2026-01-16 14:52:22 +01:00
Huang Xin 32ea42a835 fix: remove non-breaking space in book title (#2974) 2026-01-16 12:02:41 +01:00
Huang Xin 6083de3293 fix(import): read permissions of nested directories when importing books, closes #2954 (#2972) 2026-01-16 08:04:07 +01:00
Huang Xin 1c9cfa49b3 fix(flathub): use custom dbus id for single instance on Linux (#2971) 2026-01-16 05:59:25 +01:00
Huang Xin f85d6d4293 fix(eink): avoid scrolling if animation is turned off or in eink mode, closes #2957 (#2967) 2026-01-15 17:37:38 +01:00
Huang Xin 017d0b0b39 fix(tts): fix setting playback rate on Linux, closes #2950 (#2966) 2026-01-15 17:29:26 +01:00
Huang Xin 3146ae48a7 fix(proofread): support replace text with space (#2965) 2026-01-15 14:10:05 +01:00
Huang Xin 7f26e45ae7 feat(sync): cloud deletion in transfer queue (#2964) 2026-01-15 13:50:24 +01:00
Huang Xin 7d97826e4b feat(tts): replace words for TTS, closes #2057 (#2952) 2026-01-14 16:42:11 +01:00
Huang Xin 9c9c79176d feat(bookshelf): add an option to sort books by publish date, closes #2925 (#2949) 2026-01-14 13:40:07 +01:00
Huang Xin ed476a4fce fix(ios): fix wakelock on iOS, closes #2453 (#2948) 2026-01-14 13:06:16 +01:00
Huang Xin 44b4f7995b fix(android): fix annotator on Android, closes #2927 (#2946) 2026-01-14 11:56:38 +01:00
Huang Xin da5e3a0bd3 perf(fonts): cache first for font cache (#2945) 2026-01-14 07:34:00 +01:00
Huang Xin ba4678cc23 fix(fonts): fix CORS access error 403 of deno.dev with Origin: tauri://localhost (#2944) 2026-01-14 05:35:27 +01:00
Huang Xin e5eb3014b4 chore(unittest): test makeSafeFilename (#2943) 2026-01-14 04:08:30 +01:00
Huang Xin ed77b0bc7f fix(android): only dismiss unpinned sidebar and notebook with back key, closes #2920 (#2939) 2026-01-13 15:42:03 +01:00
Huang Xin 434a44e62c feat(proofread): add an option for whole word replacement, closes #2934 (#2938) 2026-01-13 15:35:57 +01:00
Huang Xin aaee04c290 fix(opds): probe filename when downloading PDFs and CBZ files, closes #2921 (#2932) 2026-01-12 17:55:50 +01:00
Huang Xin 48e2bfa82c fix(android): avoid occasional crash on start on Android (#2931) 2026-01-12 16:27:25 +01:00
Huang Xin c04f19ffb4 feat: add support for exporting book files in book details dialog, closes #2919 (#2930) 2026-01-12 16:26:32 +01:00
Huang Xin 4a624e3902 fix(settings): avoid stale viewSettings after saving, closes #2912 (#2916) 2026-01-11 16:47:36 +01:00
134 changed files with 5411 additions and 1358 deletions
+43
View File
@@ -286,10 +286,15 @@ jobs:
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
- name: Override tauri-cli with custom AppImage format (Linux)
if: matrix.config.release == 'linux'
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
- uses: tauri-apps/tauri-action@v0
if: matrix.config.release != 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -348,6 +353,44 @@ jobs:
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
echo "Signing portable binary"
pnpm tauri signer sign $bin_file
echo "Uploading signature to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber
- name: download and update latest.json for Windows portable release
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
version=${{ needs.get-release.outputs.release_version }}
arch=${{ matrix.config.arch }}
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
platform_key="windows-x86_64-portable"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
platform_key="windows-aarch64-portable"
else
echo "Unknown architecture: $arch"
exit 1
fi
portable_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/$bin_file"
portable_sig=$(cat $bin_file.sig)
jq --arg url "$portable_url" \
--arg sig "$portable_sig" \
--arg key "$platform_key" \
'.platforms[$key] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
upload-to-r2:
needs: [get-release, build-tauri]
uses: ./.github/workflows/upload-to-r2.yml
Generated
+576 -557
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -4,6 +4,8 @@ members = [
"packages/tauri/crates/tauri",
"packages/tauri/crates/tauri-utils",
"packages/tauri/crates/tauri-build",
"packages/tauri-plugins/plugins/deep-link",
"packages/tauri-plugins/plugins/single-instance"
]
resolver = "2"
@@ -19,6 +21,7 @@ schemars = "0.8"
serde_json = "1"
thiserror = "2"
glob = "0.3"
zbus = "5.9"
dunce = "1"
url = "2"
@@ -35,3 +38,5 @@ rust-version = "1.77.2"
tauri = { path = "packages/tauri/crates/tauri" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
tauri-plugin-single-instance = { path = "packages/tauri-plugins/plugins/single-instance" }
+2 -2
View File
@@ -267,8 +267,8 @@ If Readest has been useful to you, consider supporting its development. You can
### Sponsors
<p align="center">
<a title="Browser testing via LambdaTest" href="https://www.lambdatest.com/?utm_source=readest&utm_medium=sponsor" target="_blank">
<img src="https://www.lambdatest.com/blue-logo.png" style="vertical-align: middle;" width="250" />
<a title="Browser testing via TestMu AI" href="https://www.testmu.ai?utm_source=readest&utm_medium=sponsor" target="_blank">
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
</a>
</p>
+2
View File
@@ -28,3 +28,5 @@ S3_ACCESS_KEY_ID=PLACE_HOLDER
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
S3_BUCKET_NAME=PLACE_HOLDER
S3_REGION=PLACE_HOLDER
TEMP_STORAGE_PUBLIC_BUCKET_NAME=PLACE_HOLDER
+3 -1
View File
@@ -1 +1,3 @@
NEXT_PUBLIC_APP_PLATFORM=tauri
NEXT_PUBLIC_APP_PLATFORM=tauri
DBUS_ID=com.bilingify.readest
+14 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.97",
"version": "0.9.98",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -14,13 +14,15 @@
"lint": "eslint .",
"test": "dotenv -e .env -e .env.test.local vitest",
"tauri": "tauri",
"clippy": "cargo clippy -p Readest --no-deps -- -D warnings",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc",
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/{openjpeg.wasm,qcms_bg.wasm}\" ./public/vendor/pdfjs",
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
"setup-pdfjs": "pnpm prepare-public-vendor && pnpm copy-pdfjs",
"setup-simplecc": "pnpm prepare-public-vendor && pnpm copy-simplecc",
@@ -53,6 +55,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.735.0",
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@choochmeque/tauri-plugin-sharekit-api": "^0.3.0",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.14.7",
"@serwist/next": "^9.4.2",
@@ -63,18 +66,18 @@
"@supabase/supabase-js": "^2.76.1",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-cli": "^2.4.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-deep-link": "^2.4.6",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-haptics": "^2.3.2",
"@tauri-apps/plugin-http": "^2.5.4",
"@tauri-apps/plugin-log": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-http": "^2.5.6",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "~2.3.3",
"@tauri-apps/plugin-shell": "~2.3.4",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-websocket": "~2.4.1",
"@tauri-apps/plugin-websocket": "~2.4.2",
"@zip.js/zip.js": "^2.7.53",
"abortcontroller-polyfill": "^1.7.8",
"app-store-server-api": "^0.17.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -172,9 +172,6 @@
"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": "دعم المجتمع",
@@ -356,8 +353,8 @@
"{{count}} pages left in chapter_many": "تبقّت {{count}} صفحة في هذا الفصل",
"{{count}} pages left in chapter_other": "تبقّى {{count}} صفحة في هذا الفصل",
"Show Remaining Pages": "عرض الصفحات المتبقية",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "إدارة الاشتراك",
"Coming Soon": "قريبًا",
@@ -876,5 +873,62 @@
"Clear search": "مسح البحث",
"Clear search history": "مسح سجل البحث",
"Quick action disabled": "الإجراء السريع معطل",
"Tap to Toggle Footer": "انقر لتبديل التذييل"
"Tap to Toggle Footer": "انقر لتبديل التذييل",
"Exported successfully": "تم التصدير بنجاح",
"Book exported successfully.": "تم تصدير الكتاب بنجاح.",
"Failed to export the book.": "فشل تصدير الكتاب.",
"Export Book": "تصدير الكتاب",
"Whole word:": "كلمة كاملة:",
"Error": "خطأ",
"Unable to load the article. Try searching directly on {{link}}.": "تعذر تحميل المقال. حاول البحث مباشرة على {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "تعذر تحميل الكلمة. حاول البحث مباشرة على {{link}}.",
"Date Published": "تاريخ النشر",
"Only for TTS:": "فقط لـ TTS:",
"Uploaded": "تم الرفع",
"Downloaded": "تم التنزيل",
"Deleted": "تم الحذف",
"Note:": "ملاحظة:",
"Time:": "الوقت:",
"Format Options": "خيارات التنسيق",
"Export Date": "تاريخ التصدير",
"Chapter Titles": "عناوين الفصول",
"Chapter Separator": "فاصل الفصول",
"Highlights": "التمييزات",
"Note Date": "تاريخ الملاحظة",
"Advanced": "متقدم",
"Hide": "إخفاء",
"Show": "إظهار",
"Use Custom Template": "استخدام قالب مخصص",
"Export Template": "قالب التصدير",
"Template Syntax:": "بناء جملة القالب:",
"Insert value": "إدراج قيمة",
"Format date (locale)": "تنسيق التاريخ (محلي)",
"Format date (custom)": "تنسيق التاريخ (مخصص)",
"Conditional": "شرطي",
"Loop": "حلقة",
"Available Variables:": "المتغيرات المتاحة:",
"Book title": "عنوان الكتاب",
"Book author": "مؤلف الكتاب",
"Export date": "تاريخ التصدير",
"Array of chapters": "مصفوفة الفصول",
"Chapter title": "عنوان الفصل",
"Array of annotations": "مصفوفة التعليقات",
"Highlighted text": "النص المميز",
"Annotation note": "ملاحظة التعليق",
"Update time": "وقت التحديث",
"Date Format Tokens:": "رموز تنسيق التاريخ:",
"Year (4 digits)": "السنة (4 أرقام)",
"Month (01-12)": "الشهر (01-12)",
"Day (01-31)": "اليوم (01-31)",
"Hour (00-23)": "الساعة (00-23)",
"Minute (00-59)": "الدقيقة (00-59)",
"Second (00-59)": "الثانية (00-59)",
"Show Source": "إظهار المصدر",
"No content to preview": "لا يوجد محتوى للمعاينة",
"Export": "تصدير",
"Set Timeout": "تعيين المهلة",
"Select Voice": "اختر الصوت",
"Toggle Sticky Bottom TTS Bar": "تبديل شريط TTS الثابت",
"Display what I'm reading on Discord": "عرض ما أقرأه على Discord",
"Show on Discord": "عرض على Discord"
}
@@ -24,9 +24,6 @@
"Token": "টোকেন",
"Your OTP token": "আপনার ওটিপি টোকেন",
"Verify token": "টোকেন যাচাই করুন",
"Sign in with Google": "গুগল দিয়ে সাইন ইন",
"Sign in with Apple": "অ্যাপল দিয়ে সাইন ইন",
"Sign in with GitHub": "গিটহাব দিয়ে সাইন ইন",
"New Password": "নতুন পাসওয়ার্ড",
"Your new password": "আপনার নতুন পাসওয়ার্ড",
"Update password": "পাসওয়ার্ড আপডেট করুন",
@@ -488,8 +485,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "গুগল বুকস",
"Open Library": "ওপেন লাইব্রেরি",
@@ -836,5 +833,62 @@
"Clear search": "অনুসন্ধান সাফ করুন",
"Clear search history": "অনুসন্ধান ইতিহাস সাফ করুন",
"Quick action disabled": "দ্রুত কার্যক্রম অক্ষম",
"Tap to Toggle Footer": "ফুটার টগল করতে আলতো চাপুন"
"Tap to Toggle Footer": "ফুটার টগল করতে আলতো চাপুন",
"Exported successfully": "সফলভাবে রপ্তানি হয়েছে",
"Book exported successfully.": "বইটি সফলভাবে রপ্তানি হয়েছে।",
"Failed to export the book.": "বই রপ্তানি করতে ব্যর্থ।",
"Export Book": "বই রপ্তানি করুন",
"Whole word:": "সম্পূর্ণ শব্দ:",
"Error": "ত্রুটি",
"Unable to load the article. Try searching directly on {{link}}.": "নিবন্ধ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Unable to load the word. Try searching directly on {{link}}.": "শব্দ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Date Published": "প্রকাশনার তারিখ",
"Only for TTS:": "শুধুমাত্র TTS-এর জন্য:",
"Uploaded": "আপলোড হয়েছে",
"Downloaded": "ডাউনলোড হয়েছে",
"Deleted": "মুছে ফেলা হয়েছে",
"Note:": "নোট:",
"Time:": "সময়:",
"Format Options": "বিন্যাস বিকল্প",
"Export Date": "রপ্তানি তারিখ",
"Chapter Titles": "অধ্যায়ের শিরোনাম",
"Chapter Separator": "অধ্যায় বিভাজক",
"Highlights": "হাইলাইট",
"Note Date": "নোট তারিখ",
"Advanced": "উন্নত",
"Hide": "লুকান",
"Show": "দেখান",
"Use Custom Template": "কাস্টম টেমপ্লেট ব্যবহার করুন",
"Export Template": "রপ্তানি টেমপ্লেট",
"Template Syntax:": "টেমপ্লেট সিনট্যাক্স:",
"Insert value": "মান সন্নিবেশ করুন",
"Format date (locale)": "তারিখ বিন্যাস (স্থানীয়)",
"Format date (custom)": "তারিখ বিন্যাস (কাস্টম)",
"Conditional": "শর্তসাপেক্ষ",
"Loop": "লুপ",
"Available Variables:": "উপলব্ধ ভেরিয়েবল:",
"Book title": "বইয়ের শিরোনাম",
"Book author": "বইয়ের লেখক",
"Export date": "রপ্তানি তারিখ",
"Array of chapters": "অধ্যায়ের তালিকা",
"Chapter title": "অধ্যায়ের শিরোনাম",
"Array of annotations": "টীকাগুলির তালিকা",
"Highlighted text": "হাইলাইট করা পাঠ্য",
"Annotation note": "টীকা নোট",
"Update time": "আপডেট সময়",
"Date Format Tokens:": "তারিখ বিন্যাস টোকেন:",
"Year (4 digits)": "বছর (4 সংখ্যা)",
"Month (01-12)": "মাস (01-12)",
"Day (01-31)": "দিন (01-31)",
"Hour (00-23)": "ঘণ্টা (00-23)",
"Minute (00-59)": "মিনিট (00-59)",
"Second (00-59)": "সেকেন্ড (00-59)",
"Show Source": "উৎস দেখান",
"No content to preview": "প্রিভিউ করার জন্য কোনো কন্টেন্ট নেই",
"Export": "রপ্তানি",
"Set Timeout": "টাইমআউট সেট করুন",
"Select Voice": "ভয়েস নির্বাচন করুন",
"Toggle Sticky Bottom TTS Bar": "স্থির TTS বার টগল করুন",
"Display what I'm reading on Discord": "Discord-এ পড়ছি যা দেখান",
"Show on Discord": "Discord-এ দেখান"
}
@@ -173,9 +173,6 @@
"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": "སྡེ་ཁུལ་གྱི་རྒྱབ་སྐྱོར།",
@@ -336,8 +333,8 @@
"Reset Settings": "སྒྲིག་བཀོད་བསྐྱར་སྒྲིག",
"{{count}} pages left in chapter_other": "ལེའུ་འདིར་ད་དུང་ {{count}} ཤོག་ལྷེ་ལྷག་ཡོད།",
"Show Remaining Pages": "ལྷག་མའི་ཤོག་ལྷེའི་གྲངས་ཀ་མངོན་པ།",
"Source Han Serif CN VF": "སི་ཡོན་སུང་ཐི།",
"Huiwen-mincho": "ཧུའེ་ཝུན་མིང་ཁྲའོ་ཐི།",
"Source Han Serif CN": "སི་ཡོན་སུང་ཐི།",
"Huiwen-MinchoGBK": "ཧུའེ་ཝུན་མིང་ཁྲའོ་ཐི།",
"KingHwa_OldSong": "ཅིང་ཧྭ་ལའོ་སུང་ཐི།",
"Manage Subscription": "མཚམས་སྦྱོར་དོ་དམ།",
"Coming Soon": "ཉེ་བར་སྤྱོད་འགོ་འཛུགས་རྒྱུ།",
@@ -826,5 +823,62 @@
"Clear search": "འཚོལ་བཤེར་གཙང་སེལ།",
"Clear search history": "འཚོལ་བཤེར་ལོ་རྒྱུས་གཙང་སེལ།",
"Quick action disabled": "མྱུར་མགྱོགས་བྱ་འགུལ་བཀག་འགོག",
"Tap to Toggle Footer": "ཞབས་མཇུག་སྒོ་འབྱེད་བྱེད་པར་གནོན།"
"Tap to Toggle Footer": "ཞབས་མཇུག་སྒོ་འབྱེད་བྱེད་པར་གནོན།",
"Exported successfully": "ཕྱིར་འདོན་ལེགས་གྲུབ་བྱུང་།",
"Book exported successfully.": "དཔེ་དེབ་ཕྱིར་འདོན་ལེགས་གྲུབ་བྱུང་།",
"Failed to export the book.": "དཔེ་དེབ་ཕྱིར་འདོན་མི་ཐུབ།",
"Export Book": "དཔེ་དེབ་ཕྱིར་འདོན།",
"Whole word:": "ཚིག་གྲུབ་ཆ་ཚང་:",
"Error": "ནོར་འཁྲུལ།",
"Unable to load the article. Try searching directly on {{link}}.": "རྩོམ་ཡིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Unable to load the word. Try searching directly on {{link}}.": "ཚིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།",
"Only for TTS:": "TTS ལ་ཁོ་ན།:",
"Uploaded": "ཡར་སྐྱེལ་བྱས་ཟིན།",
"Downloaded": "མར་ལེན་བྱས་ཟིན།",
"Deleted": "བསུབས་ཟིན།",
"Note:": "གསལ་བཤད།:",
"Time:": "དུས་ཚོད།:",
"Format Options": "རྣམ་གཞག་གདམ་ག",
"Export Date": "ཕྱིར་འདོན་ཚེས་གྲངས།",
"Chapter Titles": "ལེའུའི་མཚན།",
"Chapter Separator": "ལེའུའི་དབར་མཚམས།",
"Highlights": "གཙོ་གནད།",
"Note Date": "གསལ་བཤད་ཚེས་གྲངས།",
"Advanced": "མཐོ་རིམ།",
"Hide": "སྦས་པ།",
"Show": "མངོན་པ།",
"Use Custom Template": "རང་བཟོའི་དཔེ་གཞི་བེད་སྤྱོད།",
"Export Template": "ཕྱིར་འདོན་དཔེ་གཞི།",
"Template Syntax:": "དཔེ་གཞིའི་སྒྲིག་གཞི།:",
"Insert value": "གྲངས་ཐང་བཙུགས་པ།",
"Format date (locale)": "ཚེས་གྲངས་རྣམ་གཞག (ས་གནས།)",
"Format date (custom)": "ཚེས་གྲངས་རྣམ་གཞག (རང་བཟོ།)",
"Conditional": "དམིགས་བསལ།",
"Loop": "འཁོར་ལོ།",
"Available Variables:": "བེད་སྤྱད་རུང་བའི་འགྱུར་ཅན།:",
"Book title": "དེབ་མཚན།",
"Book author": "དེབ་རྩོམ་པ་པོ།",
"Export date": "ཕྱིར་འདོན་ཚེས་གྲངས།",
"Array of chapters": "ལེའུའི་ལེབ་ངོས།",
"Chapter title": "ལེའུའི་མཚན།",
"Array of annotations": "གསལ་བཤད་ལེབ་ངོས།",
"Highlighted text": "གཙོ་གནད་ཡིག་རྐྱང་།",
"Annotation note": "གསལ་བཤད་ཟིན་བྲིས།",
"Update time": "གསར་བཅོས་དུས་ཚོད།",
"Date Format Tokens:": "ཚེས་གྲངས་རྣམ་གཞག་རྟགས།:",
"Year (4 digits)": "ལོ། (གྲངས་ཐང་4)",
"Month (01-12)": "ཟླ། (01-12)",
"Day (01-31)": "ཚེས། (01-31)",
"Hour (00-23)": "ཆུ་ཚོད། (00-23)",
"Minute (00-59)": "སྐར་མ། (00-59)",
"Second (00-59)": "སྐར་ཆ། (00-59)",
"Show Source": "འབྱུང་ཁུངས་མངོན་པ།",
"No content to preview": "སྔོན་ལྟ་བྱེད་པའི་དོན་རྐྱེན་མེད།",
"Export": "ཕྱིར་འདོན།",
"Set Timeout": "དུས་ཚོད་སྒྲིག་པ།",
"Select Voice": "སྐད་གདངས་འདེམས།",
"Toggle Sticky Bottom TTS Bar": "TTS སྡོམ་ཐིག་བརྗེ་བ།",
"Display what I'm reading on Discord": "Discord ཐོག་ཀློག་བཞིན་པའི་དཔེ་ཆ་སྟོན།",
"Show on Discord": "Discord ཐོག་སྟོན།"
}
@@ -172,9 +172,6 @@
"Token": "Token",
"Your OTP token": "Ihr OTP-Token",
"Verify token": "Token überprüfen",
"Sign in with Google": "Mit Google anmelden",
"Sign in with Apple": "Mit Apple anmelden",
"Sign in with GitHub": "Mit GitHub anmelden",
"Account": "Konto",
"Failed to delete user. Please try again later.": "Benutzer konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut.",
"Community Support": "Community-Support",
@@ -340,8 +337,8 @@
"{{count}} pages left in chapter_one": "{{count}} Seite verbleibend im Kapitel",
"{{count}} pages left in chapter_other": "{{count}} Seiten verbleibend im Kapitel",
"Show Remaining Pages": "Verbleibende Seiten anzeigen",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Abo verwalten",
"Coming Soon": "Demnächst verfügbar",
@@ -836,5 +833,62 @@
"Clear search": "Suche löschen",
"Clear search history": "Suchverlauf löschen",
"Quick action disabled": "Schnellaktion deaktiviert",
"Tap to Toggle Footer": "Tippen, um Fußzeile umzuschalten"
"Tap to Toggle Footer": "Tippen, um Fußzeile umzuschalten",
"Exported successfully": "Erfolgreich exportiert",
"Book exported successfully.": "Buch erfolgreich exportiert.",
"Failed to export the book.": "Buch konnte nicht exportiert werden.",
"Export Book": "Buch exportieren",
"Whole word:": "Ganzes Wort:",
"Error": "Fehler",
"Unable to load the article. Try searching directly on {{link}}.": "Artikel kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Wort kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Date Published": "Veröffentlichungsdatum",
"Only for TTS:": "Nur für TTS:",
"Uploaded": "Hochgeladen",
"Downloaded": "Heruntergeladen",
"Deleted": "Gelöscht",
"Note:": "Notiz:",
"Time:": "Zeit:",
"Format Options": "Formatoptionen",
"Export Date": "Exportdatum",
"Chapter Titles": "Kapiteltitel",
"Chapter Separator": "Kapiteltrennzeichen",
"Highlights": "Markierungen",
"Note Date": "Notizdatum",
"Advanced": "Erweitert",
"Hide": "Ausblenden",
"Show": "Anzeigen",
"Use Custom Template": "Benutzerdefinierte Vorlage verwenden",
"Export Template": "Exportvorlage",
"Template Syntax:": "Vorlagensyntax:",
"Insert value": "Wert einfügen",
"Format date (locale)": "Datum formatieren (Gebietsschema)",
"Format date (custom)": "Datum formatieren (benutzerdefiniert)",
"Conditional": "Bedingung",
"Loop": "Schleife",
"Available Variables:": "Verfügbare Variablen:",
"Book title": "Buchtitel",
"Book author": "Buchautor",
"Export date": "Exportdatum",
"Array of chapters": "Array von Kapiteln",
"Chapter title": "Kapiteltitel",
"Array of annotations": "Array von Anmerkungen",
"Highlighted text": "Markierter Text",
"Annotation note": "Anmerkungsnotiz",
"Update time": "Aktualisierungszeit",
"Date Format Tokens:": "Datumsformat-Token:",
"Year (4 digits)": "Jahr (4 Ziffern)",
"Month (01-12)": "Monat (01-12)",
"Day (01-31)": "Tag (01-31)",
"Hour (00-23)": "Stunde (00-23)",
"Minute (00-59)": "Minute (00-59)",
"Second (00-59)": "Sekunde (00-59)",
"Show Source": "Quelle anzeigen",
"No content to preview": "Kein Inhalt zur Vorschau",
"Export": "Exportieren",
"Set Timeout": "Zeitlimit festlegen",
"Select Voice": "Stimme auswählen",
"Toggle Sticky Bottom TTS Bar": "Fixierte TTS-Leiste umschalten",
"Display what I'm reading on Discord": "Zeige was ich auf Discord lese",
"Show on Discord": "Auf Discord zeigen"
}
@@ -173,9 +173,6 @@
"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": "Υποστήριξη κοινότητας",
@@ -341,8 +338,8 @@
"{{count}} pages left in chapter_one": "Μένει {{count}} σελίδα",
"{{count}} pages left in chapter_other": "Μένουν {{count}} σελίδες",
"Show Remaining Pages": "Εμφάνιση υπολοίπων",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Διαχείριση συνδρομής",
"Coming Soon": "Έρχεται σύντομα",
@@ -836,5 +833,62 @@
"Clear search": "Εκκαθάριση αναζήτησης",
"Clear search history": "Εκκαθάριση ιστορικού αναζήτησης",
"Quick action disabled": "Η γρήγορη ενέργεια απενεργοποιήθηκε",
"Tap to Toggle Footer": "Πατήστε για εναλλαγή υποσέλιδου"
"Tap to Toggle Footer": "Πατήστε για εναλλαγή υποσέλιδου",
"Exported successfully": "Εξαγωγή επιτυχής",
"Book exported successfully.": "Το βιβλίο εξήχθη επιτυχώς.",
"Failed to export the book.": "Αποτυχία εξαγωγής του βιβλίου.",
"Export Book": "Εξαγωγή βιβλίου",
"Whole word:": "Ολόκληρη λέξη:",
"Error": "Σφάλμα",
"Unable to load the article. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης του άρθρου. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης της λέξης. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Date Published": "Ημερομηνία δημοσίευσης",
"Only for TTS:": "Μόνο για TTS:",
"Uploaded": "Μεταφορτώθηκε",
"Downloaded": "Λήφθηκε",
"Deleted": "Διαγράφηκε",
"Note:": "Σημείωση:",
"Time:": "Ώρα:",
"Format Options": "Επιλογές μορφοποίησης",
"Export Date": "Ημερομηνία εξαγωγής",
"Chapter Titles": "Τίτλοι κεφαλαίων",
"Chapter Separator": "Διαχωριστικό κεφαλαίων",
"Highlights": "Επισημάνσεις",
"Note Date": "Ημερομηνία σημείωσης",
"Advanced": "Για προχωρημένους",
"Hide": "Απόκρυψη",
"Show": "Εμφάνιση",
"Use Custom Template": "Χρήση προσαρμοσμένου προτύπου",
"Export Template": "Πρότυπο εξαγωγής",
"Template Syntax:": "Σύνταξη προτύπου:",
"Insert value": "Εισαγωγή τιμής",
"Format date (locale)": "Μορφοποίηση ημερομηνίας (τοπική)",
"Format date (custom)": "Μορφοποίηση ημερομηνίας (προσαρμοσμένη)",
"Conditional": "Υπό όρους",
"Loop": "Βρόχος",
"Available Variables:": "Διαθέσιμες μεταβλητές:",
"Book title": "Τίτλος βιβλίου",
"Book author": "Συγγραφέας βιβλίου",
"Export date": "Ημερομηνία εξαγωγής",
"Array of chapters": "Πίνακας κεφαλαίων",
"Chapter title": "Τίτλος κεφαλαίου",
"Array of annotations": "Πίνακας σχολιασμών",
"Highlighted text": "Επισημασμένο κείμενο",
"Annotation note": "Σημείωση σχολιασμού",
"Update time": "Ώρα ενημέρωσης",
"Date Format Tokens:": "Σύμβολα μορφοποίησης ημερομηνίας:",
"Year (4 digits)": "Έτος (4 ψηφία)",
"Month (01-12)": "Μήνας (01-12)",
"Day (01-31)": "Ημέρα (01-31)",
"Hour (00-23)": "Ώρα (00-23)",
"Minute (00-59)": "Λεπτό (00-59)",
"Second (00-59)": "Δευτερόλεπτο (00-59)",
"Show Source": "Εμφάνιση πηγής",
"No content to preview": "Δεν υπάρχει περιεχόμενο για προεπισκόπηση",
"Export": "Εξαγωγή",
"Set Timeout": "Ορισμός χρονικού ορίου",
"Select Voice": "Επιλογή φωνής",
"Toggle Sticky Bottom TTS Bar": "Εναλλαγή καρφιτσωμένης μπάρας TTS",
"Display what I'm reading on Discord": "Εμφάνιση του βιβλίου που διαβάζω στο Discord",
"Show on Discord": "Εμφάνιση στο Discord"
}
@@ -1,8 +1,8 @@
{
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Are you sure to delete {{count}} selected book(s)?_one": "Are you sure to delete {{count}} selected book?",
"Are you sure to delete {{count}} selected book(s)?_other": "Are you sure to delete {{count}} selected books?",
@@ -200,9 +200,6 @@
"Token": "Código de verificación",
"Your OTP token": "Tu código OTP",
"Verify token": "Verificar código",
"Sign in with Google": "Iniciar sesión con Google",
"Sign in with Apple": "Iniciar sesión con Apple",
"Sign in with GitHub": "Iniciar sesión con GitHub",
"Account": "Cuenta",
"Failed to delete user. Please try again later.": "Error al eliminar usuario. Por favor, inténtelo de nuevo más tarde.",
"Community Support": "Soporte comunitario",
@@ -372,8 +369,8 @@
"{{count}} pages left in chapter_many": "{{count}} páginas restantes en el capítulo",
"{{count}} pages left in chapter_other": "{{count}} páginas restantes en el capítulo",
"Show Remaining Pages": "Mostrar resto",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Gestionar suscripción",
"Coming Soon": "Próximamente",
@@ -846,5 +843,62 @@
"Clear search": "Borrar búsqueda",
"Clear search history": "Borrar historial de búsqueda",
"Quick action disabled": "Acción rápida desactivada",
"Tap to Toggle Footer": "Toca para alternar pie de página"
"Tap to Toggle Footer": "Toca para alternar pie de página",
"Exported successfully": "Exportado con éxito",
"Book exported successfully.": "Libro exportado con éxito.",
"Failed to export the book.": "Error al exportar el libro.",
"Export Book": "Exportar libro",
"Whole word:": "Palabra completa:",
"Error": "Error",
"Unable to load the article. Try searching directly on {{link}}.": "No se puede cargar el artículo. Intenta buscar directamente en {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "No se puede cargar la palabra. Intenta buscar directamente en {{link}}.",
"Date Published": "Fecha de publicación",
"Only for TTS:": "Solo para TTS:",
"Uploaded": "Subido",
"Downloaded": "Descargado",
"Deleted": "Eliminado",
"Note:": "Nota:",
"Time:": "Hora:",
"Format Options": "Opciones de formato",
"Export Date": "Fecha de exportación",
"Chapter Titles": "Títulos de capítulos",
"Chapter Separator": "Separador de capítulos",
"Highlights": "Resaltados",
"Note Date": "Fecha de nota",
"Advanced": "Avanzado",
"Hide": "Ocultar",
"Show": "Mostrar",
"Use Custom Template": "Usar plantilla personalizada",
"Export Template": "Plantilla de exportación",
"Template Syntax:": "Sintaxis de plantilla:",
"Insert value": "Insertar valor",
"Format date (locale)": "Formatear fecha (regional)",
"Format date (custom)": "Formatear fecha (personalizado)",
"Conditional": "Condicional",
"Loop": "Bucle",
"Available Variables:": "Variables disponibles:",
"Book title": "Título del libro",
"Book author": "Autor del libro",
"Export date": "Fecha de exportación",
"Array of chapters": "Lista de capítulos",
"Chapter title": "Título del capítulo",
"Array of annotations": "Lista de anotaciones",
"Highlighted text": "Texto resaltado",
"Annotation note": "Nota de anotación",
"Update time": "Hora de actualización",
"Date Format Tokens:": "Tokens de formato de fecha:",
"Year (4 digits)": "Año (4 dígitos)",
"Month (01-12)": "Mes (01-12)",
"Day (01-31)": "Día (01-31)",
"Hour (00-23)": "Hora (00-23)",
"Minute (00-59)": "Minuto (00-59)",
"Second (00-59)": "Segundo (00-59)",
"Show Source": "Mostrar fuente",
"No content to preview": "Sin contenido para previsualizar",
"Export": "Exportar",
"Set Timeout": "Establecer tiempo límite",
"Select Voice": "Seleccionar voz",
"Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fija",
"Display what I'm reading on Discord": "Mostrar lo que estoy leyendo en Discord",
"Show on Discord": "Mostrar en Discord"
}
@@ -172,9 +172,6 @@
"Token": "توکن",
"Your OTP token": "کد یک‌بارمصرف شما",
"Verify token": "تأیید کد",
"Sign in with Google": "ورود با گوگل",
"Sign in with Apple": "ورود با اپل",
"Sign in with GitHub": "ورود با گیت‌هاب",
"Account": "حساب",
"Failed to delete user. Please try again later.": "حذف کاربر ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
"Community Support": "پشتیبانی جامعه",
@@ -340,8 +337,8 @@
"{{count}} pages left in chapter_one": "{{count}} صفحه تا انتهای فصل",
"{{count}} pages left in chapter_other": "{{count}} صفحه تا انتهای فصل",
"Show Remaining Pages": "نمایش صفحات باقی‌مانده",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Manage Subscription": "مدیریت اشتراک",
"Coming Soon": "به‌زودی",
@@ -836,5 +833,62 @@
"Clear search": "پاک کردن جستجو",
"Clear search history": "پاک کردن تاریخچه جستجو",
"Quick action disabled": "عملکرد سریع غیرفعال است",
"Tap to Toggle Footer": "برای تغییر پاورقی ضربه بزنید"
"Tap to Toggle Footer": "برای تغییر پاورقی ضربه بزنید",
"Exported successfully": "صادرات موفق",
"Book exported successfully.": "کتاب با موفقیت صادر شد.",
"Failed to export the book.": "صادر کردن کتاب ناموفق بود.",
"Export Book": "صادر کردن کتاب",
"Whole word:": "کلمه کامل:",
"Error": "خطا",
"Unable to load the article. Try searching directly on {{link}}.": "بارگذاری مقاله امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Unable to load the word. Try searching directly on {{link}}.": "بارگذاری کلمه امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Date Published": "تاریخ انتشار",
"Only for TTS:": "فقط برای TTS:",
"Uploaded": "بارگذاری شد",
"Downloaded": "دانلود شد",
"Deleted": "حذف شد",
"Note:": "یادداشت:",
"Time:": "زمان:",
"Format Options": "گزینه‌های قالب",
"Export Date": "تاریخ خروجی",
"Chapter Titles": "عناوین فصل",
"Chapter Separator": "جداکننده فصل",
"Highlights": "برجسته‌ها",
"Note Date": "تاریخ یادداشت",
"Advanced": "پیشرفته",
"Hide": "پنهان کردن",
"Show": "نمایش",
"Use Custom Template": "استفاده از الگوی سفارشی",
"Export Template": "الگوی خروجی",
"Template Syntax:": "نحو الگو:",
"Insert value": "درج مقدار",
"Format date (locale)": "قالب‌بندی تاریخ (محلی)",
"Format date (custom)": "قالب‌بندی تاریخ (سفارشی)",
"Conditional": "شرطی",
"Loop": "حلقه",
"Available Variables:": "متغیرهای موجود:",
"Book title": "عنوان کتاب",
"Book author": "نویسنده کتاب",
"Export date": "تاریخ خروجی",
"Array of chapters": "آرایه فصل‌ها",
"Chapter title": "عنوان فصل",
"Array of annotations": "آرایه یادداشت‌ها",
"Highlighted text": "متن برجسته‌شده",
"Annotation note": "یادداشت حاشیه",
"Update time": "زمان به‌روزرسانی",
"Date Format Tokens:": "نشانه‌های قالب تاریخ:",
"Year (4 digits)": "سال (۴ رقم)",
"Month (01-12)": "ماه (۰۱-۱۲)",
"Day (01-31)": "روز (۰۱-۳۱)",
"Hour (00-23)": "ساعت (۰۰-۲۳)",
"Minute (00-59)": "دقیقه (۰۰-۵۹)",
"Second (00-59)": "ثانیه (۰۰-۵۹)",
"Show Source": "نمایش منبع",
"No content to preview": "محتوایی برای پیش‌نمایش وجود ندارد",
"Export": "خروجی",
"Set Timeout": "تنظیم مهلت زمانی",
"Select Voice": "انتخاب صدا",
"Toggle Sticky Bottom TTS Bar": "تغییر نوار TTS ثابت",
"Display what I'm reading on Discord": "نمایش کتاب در حال خواندن در Discord",
"Show on Discord": "نمایش در Discord"
}
@@ -172,9 +172,6 @@
"Token": "Code de vérification",
"Your OTP token": "Votre code OTP",
"Verify token": "Vérifier le code",
"Sign in with Google": "Se connecter avec Google",
"Sign in with Apple": "Se connecter avec Apple",
"Sign in with GitHub": "Se connecter avec GitHub",
"Account": "Compte",
"Failed to delete user. Please try again later.": "Échec de la suppression de l'utilisateur. Veuillez réessayer plus tard.",
"Community Support": "Support communautaire",
@@ -344,8 +341,8 @@
"{{count}} pages left in chapter_many": "{{count}} pages restantes dans le chapitre",
"{{count}} pages left in chapter_other": "{{count}} pages restantes dans le chapitre",
"Show Remaining Pages": "Voir restantes",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Gérer labonnement",
"Coming Soon": "Bientôt disponible",
@@ -846,5 +843,62 @@
"Clear search": "Effacer la recherche",
"Clear search history": "Effacer l'historique de recherche",
"Quick action disabled": "Action rapide désactivée",
"Tap to Toggle Footer": "Appuyez pour afficher/masquer le pied de page"
"Tap to Toggle Footer": "Appuyez pour afficher/masquer le pied de page",
"Exported successfully": "Exporté avec succès",
"Book exported successfully.": "Livre exporté avec succès.",
"Failed to export the book.": "Échec de l'exportation du livre.",
"Export Book": "Exporter le livre",
"Whole word:": "Mot entier :",
"Error": "Erreur",
"Unable to load the article. Try searching directly on {{link}}.": "Impossible de charger l'article. Essayez de rechercher directement sur {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossible de charger le mot. Essayez de rechercher directement sur {{link}}.",
"Date Published": "Date de publication",
"Only for TTS:": "Uniquement pour TTS :",
"Uploaded": "Téléversé",
"Downloaded": "Téléchargé",
"Deleted": "Supprimé",
"Note:": "Note :",
"Time:": "Heure :",
"Format Options": "Options de format",
"Export Date": "Date d'export",
"Chapter Titles": "Titres de chapitres",
"Chapter Separator": "Séparateur de chapitres",
"Highlights": "Surlignages",
"Note Date": "Date de note",
"Advanced": "Avancé",
"Hide": "Masquer",
"Show": "Afficher",
"Use Custom Template": "Utiliser un modèle personnalisé",
"Export Template": "Modèle d'export",
"Template Syntax:": "Syntaxe du modèle :",
"Insert value": "Insérer une valeur",
"Format date (locale)": "Formater la date (locale)",
"Format date (custom)": "Formater la date (personnalisé)",
"Conditional": "Conditionnel",
"Loop": "Boucle",
"Available Variables:": "Variables disponibles :",
"Book title": "Titre du livre",
"Book author": "Auteur du livre",
"Export date": "Date d'export",
"Array of chapters": "Liste de chapitres",
"Chapter title": "Titre du chapitre",
"Array of annotations": "Liste d'annotations",
"Highlighted text": "Texte surligné",
"Annotation note": "Note d'annotation",
"Update time": "Heure de mise à jour",
"Date Format Tokens:": "Jetons de format de date :",
"Year (4 digits)": "Année (4 chiffres)",
"Month (01-12)": "Mois (01-12)",
"Day (01-31)": "Jour (01-31)",
"Hour (00-23)": "Heure (00-23)",
"Minute (00-59)": "Minute (00-59)",
"Second (00-59)": "Seconde (00-59)",
"Show Source": "Afficher la source",
"No content to preview": "Aucun contenu à prévisualiser",
"Export": "Exporter",
"Set Timeout": "Définir le délai",
"Select Voice": "Sélectionner la voix",
"Toggle Sticky Bottom TTS Bar": "Basculer la barre TTS fixée",
"Display what I'm reading on Discord": "Afficher ce que je lis sur Discord",
"Show on Discord": "Afficher sur Discord"
}
@@ -173,9 +173,6 @@
"Token": "टोकन",
"Your OTP token": "आपका ओटीपी टोकन",
"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": "सामुदायिक सहायता",
@@ -341,8 +338,8 @@
"{{count}} pages left in chapter_one": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
"{{count}} pages left in chapter_other": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
"Show Remaining Pages": "शेष पृष्ठ दिखाएँ",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "सदस्यता प्रबंधित करें",
"Coming Soon": "जल्द आ रहा है",
@@ -836,5 +833,62 @@
"Clear search": "खोज साफ़ करें",
"Clear search history": "खोज इतिहास साफ़ करें",
"Quick action disabled": "त्वरित क्रिया अक्षम",
"Tap to Toggle Footer": "फुटर टॉगल करने के लिए टैप करें"
"Tap to Toggle Footer": "फुटर टॉगल करने के लिए टैप करें",
"Exported successfully": "सफलतापूर्वक निर्यात किया गया",
"Book exported successfully.": "पुस्तक सफलतापूर्वक निर्यात की गई।",
"Failed to export the book.": "पुस्तक निर्यात करने में विफल।",
"Export Book": "पुस्तक निर्यात करें",
"Whole word:": "पूरा शब्द:",
"Error": "त्रुटि",
"Unable to load the article. Try searching directly on {{link}}.": "लेख लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Unable to load the word. Try searching directly on {{link}}.": "शब्द लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Date Published": "प्रकाशन तिथि",
"Only for TTS:": "केवल TTS के लिए:",
"Uploaded": "अपलोड किया गया",
"Downloaded": "डाउनलोड किया गया",
"Deleted": "हटाया गया",
"Note:": "नोट:",
"Time:": "समय:",
"Format Options": "प्रारूप विकल्प",
"Export Date": "निर्यात तिथि",
"Chapter Titles": "अध्याय शीर्षक",
"Chapter Separator": "अध्याय विभाजक",
"Highlights": "हाइलाइट्स",
"Note Date": "नोट तिथि",
"Advanced": "उन्नत",
"Hide": "छिपाएं",
"Show": "दिखाएं",
"Use Custom Template": "कस्टम टेम्पलेट उपयोग करें",
"Export Template": "निर्यात टेम्पलेट",
"Template Syntax:": "टेम्पलेट सिंटैक्स:",
"Insert value": "मान डालें",
"Format date (locale)": "तिथि फ़ॉर्मेट करें (स्थानीय)",
"Format date (custom)": "तिथि फ़ॉर्मेट करें (कस्टम)",
"Conditional": "सशर्त",
"Loop": "लूप",
"Available Variables:": "उपलब्ध चर:",
"Book title": "पुस्तक शीर्षक",
"Book author": "पुस्तक लेखक",
"Export date": "निर्यात तिथि",
"Array of chapters": "अध्यायों की सूची",
"Chapter title": "अध्याय शीर्षक",
"Array of annotations": "एनोटेशन की सूची",
"Highlighted text": "हाइलाइट किया गया पाठ",
"Annotation note": "एनोटेशन नोट",
"Update time": "अपडेट समय",
"Date Format Tokens:": "तिथि प्रारूप टोकन:",
"Year (4 digits)": "वर्ष (4 अंक)",
"Month (01-12)": "माह (01-12)",
"Day (01-31)": "दिन (01-31)",
"Hour (00-23)": "घंटा (00-23)",
"Minute (00-59)": "मिनट (00-59)",
"Second (00-59)": "सेकंड (00-59)",
"Show Source": "स्रोत दिखाएं",
"No content to preview": "पूर्वावलोकन के लिए कोई सामग्री नहीं",
"Export": "निर्यात करें",
"Set Timeout": "टाइमआउट सेट करें",
"Select Voice": "आवाज़ चुनें",
"Toggle Sticky Bottom TTS Bar": "स्थिर TTS बार टॉगल करें",
"Display what I'm reading on Discord": "Discord पर पढ़ रही किताब दिखाएं",
"Show on Discord": "Discord पर दिखाएं"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "Token OTP Anda",
"Verify token": "Verifikasi token",
"Sign in with Google": "Masuk dengan Google",
"Sign in with Apple": "Masuk dengan Apple",
"Sign in with GitHub": "Masuk dengan GitHub",
"Account": "Akun",
"Failed to delete user. Please try again later.": "Gagal menghapus pengguna. Silakan coba lagi nanti.",
"Community Support": "Dukungan Komunitas",
@@ -337,8 +334,8 @@
"Reset Settings": "Atur Ulang Pengaturan",
"{{count}} pages left in chapter_other": "{{count}} halaman tersisa di bab ini",
"Show Remaining Pages": "Tampilkan halaman tersisa",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Kelola Langganan",
"Coming Soon": "Segera Hadir",
@@ -826,5 +823,62 @@
"Clear search": "Hapus pencarian",
"Clear search history": "Hapus riwayat pencarian",
"Quick action disabled": "Tindakan cepat dinonaktifkan",
"Tap to Toggle Footer": "Ketuk untuk beralih footer"
"Tap to Toggle Footer": "Ketuk untuk beralih footer",
"Exported successfully": "Berhasil diekspor",
"Book exported successfully.": "Buku berhasil diekspor.",
"Failed to export the book.": "Gagal mengekspor buku.",
"Export Book": "Ekspor Buku",
"Whole word:": "Kata utuh:",
"Error": "Kesalahan",
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuat artikel. Coba cari langsung di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuat kata. Coba cari langsung di {{link}}.",
"Date Published": "Tanggal terbit",
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Diunggah",
"Downloaded": "Diunduh",
"Deleted": "Dihapus",
"Note:": "Catatan:",
"Time:": "Waktu:",
"Format Options": "Opsi Format",
"Export Date": "Tanggal Ekspor",
"Chapter Titles": "Judul Bab",
"Chapter Separator": "Pemisah Bab",
"Highlights": "Sorotan",
"Note Date": "Tanggal Catatan",
"Advanced": "Lanjutan",
"Hide": "Sembunyikan",
"Show": "Tampilkan",
"Use Custom Template": "Gunakan Template Kustom",
"Export Template": "Template Ekspor",
"Template Syntax:": "Sintaks Template:",
"Insert value": "Masukkan nilai",
"Format date (locale)": "Format tanggal (lokal)",
"Format date (custom)": "Format tanggal (kustom)",
"Conditional": "Kondisional",
"Loop": "Loop",
"Available Variables:": "Variabel yang Tersedia:",
"Book title": "Judul buku",
"Book author": "Penulis buku",
"Export date": "Tanggal ekspor",
"Array of chapters": "Daftar bab",
"Chapter title": "Judul bab",
"Array of annotations": "Daftar anotasi",
"Highlighted text": "Teks yang disorot",
"Annotation note": "Catatan anotasi",
"Update time": "Waktu pembaruan",
"Date Format Tokens:": "Token Format Tanggal:",
"Year (4 digits)": "Tahun (4 digit)",
"Month (01-12)": "Bulan (01-12)",
"Day (01-31)": "Hari (01-31)",
"Hour (00-23)": "Jam (00-23)",
"Minute (00-59)": "Menit (00-59)",
"Second (00-59)": "Detik (00-59)",
"Show Source": "Tampilkan Sumber",
"No content to preview": "Tidak ada konten untuk dipratinjau",
"Export": "Ekspor",
"Set Timeout": "Atur batas waktu",
"Select Voice": "Pilih suara",
"Toggle Sticky Bottom TTS Bar": "Alihkan bilah TTS tetap",
"Display what I'm reading on Discord": "Tampilkan buku yang sedang dibaca di Discord",
"Show on Discord": "Tampilkan di Discord"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "Il tuo token OTP",
"Verify token": "Verifica token",
"Sign in with Google": "Accedi con Google",
"Sign in with Apple": "Accedi con Apple",
"Sign in with GitHub": "Accedi con GitHub",
"Account": "Account",
"Failed to delete user. Please try again later.": "Impossibile eliminare l'utente. Riprova più tardi.",
"Community Support": "Supporto della community",
@@ -345,8 +342,8 @@
"{{count}} pages left in chapter_many": "{{count}} pagine rimaste nel capitolo",
"{{count}} pages left in chapter_other": "{{count}} pagine rimaste nel capitolo",
"Show Remaining Pages": "Mostra pagine rimanenti",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Gestisci Abbonamento",
"Coming Soon": "Prossimamente",
@@ -846,5 +843,62 @@
"Clear search": "Cancella ricerca",
"Clear search history": "Cancella cronologia ricerche",
"Quick action disabled": "Azione rapida disattivata",
"Tap to Toggle Footer": "Tocca per mostrare/nascondere il piè di pagina"
"Tap to Toggle Footer": "Tocca per mostrare/nascondere il piè di pagina",
"Exported successfully": "Esportato con successo",
"Book exported successfully.": "Libro esportato con successo.",
"Failed to export the book.": "Impossibile esportare il libro.",
"Export Book": "Esporta libro",
"Whole word:": "Parola intera:",
"Error": "Errore",
"Unable to load the article. Try searching directly on {{link}}.": "Impossibile caricare l'articolo. Prova a cercare direttamente su {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossibile caricare la parola. Prova a cercare direttamente su {{link}}.",
"Date Published": "Data di pubblicazione",
"Only for TTS:": "Solo per TTS:",
"Uploaded": "Caricato",
"Downloaded": "Scaricato",
"Deleted": "Eliminato",
"Note:": "Nota:",
"Time:": "Ora:",
"Format Options": "Opzioni formato",
"Export Date": "Data esportazione",
"Chapter Titles": "Titoli capitoli",
"Chapter Separator": "Separatore capitoli",
"Highlights": "Evidenziazioni",
"Note Date": "Data nota",
"Advanced": "Avanzate",
"Hide": "Nascondi",
"Show": "Mostra",
"Use Custom Template": "Usa modello personalizzato",
"Export Template": "Modello esportazione",
"Template Syntax:": "Sintassi modello:",
"Insert value": "Inserisci valore",
"Format date (locale)": "Formatta data (locale)",
"Format date (custom)": "Formatta data (personalizzato)",
"Conditional": "Condizionale",
"Loop": "Ciclo",
"Available Variables:": "Variabili disponibili:",
"Book title": "Titolo libro",
"Book author": "Autore libro",
"Export date": "Data esportazione",
"Array of chapters": "Elenco capitoli",
"Chapter title": "Titolo capitolo",
"Array of annotations": "Elenco annotazioni",
"Highlighted text": "Testo evidenziato",
"Annotation note": "Nota annotazione",
"Update time": "Ora aggiornamento",
"Date Format Tokens:": "Token formato data:",
"Year (4 digits)": "Anno (4 cifre)",
"Month (01-12)": "Mese (01-12)",
"Day (01-31)": "Giorno (01-31)",
"Hour (00-23)": "Ora (00-23)",
"Minute (00-59)": "Minuto (00-59)",
"Second (00-59)": "Secondo (00-59)",
"Show Source": "Mostra sorgente",
"No content to preview": "Nessun contenuto da visualizzare",
"Export": "Esporta",
"Set Timeout": "Imposta timeout",
"Select Voice": "Seleziona voce",
"Toggle Sticky Bottom TTS Bar": "Attiva/disattiva barra TTS fissa",
"Display what I'm reading on Discord": "Mostra cosa sto leggendo su Discord",
"Show on Discord": "Mostra su Discord"
}
@@ -173,9 +173,6 @@
"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": "コミュニティサポート",
@@ -337,8 +334,8 @@
"Reset Settings": "設定をリセット",
"{{count}} pages left in chapter_other": "{{count}} ページ残り",
"Show Remaining Pages": "残りを表示",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "サブスクリプションを管理",
"Coming Soon": "近日公開",
@@ -826,5 +823,62 @@
"Clear search": "検索をクリア",
"Clear search history": "検索履歴をクリア",
"Quick action disabled": "クイックアクション無効",
"Tap to Toggle Footer": "タップでフッターを切り替え"
"Tap to Toggle Footer": "タップでフッターを切り替え",
"Exported successfully": "エクスポート成功",
"Book exported successfully.": "書籍のエクスポートに成功しました。",
"Failed to export the book.": "書籍のエクスポートに失敗しました。",
"Export Book": "書籍をエクスポート",
"Whole word:": "単語全体:",
"Error": "エラー",
"Unable to load the article. Try searching directly on {{link}}.": "記事を読み込めません。{{link}}で直接検索してみてください。",
"Unable to load the word. Try searching directly on {{link}}.": "単語を読み込めません。{{link}}で直接検索してみてください。",
"Date Published": "出版日",
"Only for TTS:": "TTSのみ:",
"Uploaded": "アップロード済み",
"Downloaded": "ダウンロード済み",
"Deleted": "削除済み",
"Note:": "ノート:",
"Time:": "時刻:",
"Format Options": "形式オプション",
"Export Date": "エクスポート日",
"Chapter Titles": "章タイトル",
"Chapter Separator": "章区切り",
"Highlights": "ハイライト",
"Note Date": "ノート日付",
"Advanced": "詳細設定",
"Hide": "非表示",
"Show": "表示",
"Use Custom Template": "カスタムテンプレート使用",
"Export Template": "エクスポートテンプレート",
"Template Syntax:": "テンプレート構文:",
"Insert value": "値を挿入",
"Format date (locale)": "日付形式(ロケール)",
"Format date (custom)": "日付形式(カスタム)",
"Conditional": "条件分岐",
"Loop": "ループ",
"Available Variables:": "利用可能な変数:",
"Book title": "本のタイトル",
"Book author": "著者",
"Export date": "エクスポート日",
"Array of chapters": "章の配列",
"Chapter title": "章タイトル",
"Array of annotations": "注釈の配列",
"Highlighted text": "ハイライトテキスト",
"Annotation note": "注釈ノート",
"Update time": "更新時刻",
"Date Format Tokens:": "日付形式トークン:",
"Year (4 digits)": "年(4桁)",
"Month (01-12)": "月(01-12)",
"Day (01-31)": "日(01-31)",
"Hour (00-23)": "時(00-23)",
"Minute (00-59)": "分(00-59)",
"Second (00-59)": "秒(00-59)",
"Show Source": "ソース表示",
"No content to preview": "プレビューするコンテンツがありません",
"Export": "エクスポート",
"Set Timeout": "タイムアウト設定",
"Select Voice": "音声選択",
"Toggle Sticky Bottom TTS Bar": "TTSバー固定切替",
"Display what I'm reading on Discord": "読書中の本をDiscordに表示",
"Show on Discord": "Discordに表示"
}
@@ -173,9 +173,6 @@
"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": "커뮤니티 지원",
@@ -337,8 +334,8 @@
"Reset Settings": "설정 재설정",
"{{count}} pages left in chapter_other": "남은 페이지 {{count}}장",
"Show Remaining Pages": "남은 페이지 보기",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "구독 관리",
"Coming Soon": "곧 출시 예정",
@@ -826,5 +823,62 @@
"Clear search": "검색 지우기",
"Clear search history": "검색 기록 지우기",
"Quick action disabled": "빠른 작업 비활성화됨",
"Tap to Toggle Footer": "탭하여 바닥글 전환"
"Tap to Toggle Footer": "탭하여 바닥글 전환",
"Exported successfully": "내보내기 성공",
"Book exported successfully.": "책을 성공적으로 내보냈습니다.",
"Failed to export the book.": "책 내보내기에 실패했습니다.",
"Export Book": "책 내보내기",
"Whole word:": "전체 단어:",
"Error": "오류",
"Unable to load the article. Try searching directly on {{link}}.": "문서를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Unable to load the word. Try searching directly on {{link}}.": "단어를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Date Published": "출판일",
"Only for TTS:": "TTS 전용:",
"Uploaded": "업로드됨",
"Downloaded": "다운로드됨",
"Deleted": "삭제됨",
"Note:": "노트:",
"Time:": "시간:",
"Format Options": "형식 옵션",
"Export Date": "내보내기 날짜",
"Chapter Titles": "챕터 제목",
"Chapter Separator": "챕터 구분 기호",
"Highlights": "하이라이트",
"Note Date": "노트 날짜",
"Advanced": "고급",
"Hide": "숨기기",
"Show": "표시",
"Use Custom Template": "사용자 지정 템플릿 사용",
"Export Template": "내보내기 템플릿",
"Template Syntax:": "템플릿 구문:",
"Insert value": "값 삽입",
"Format date (locale)": "날짜 형식 (로케일)",
"Format date (custom)": "날짜 형식 (사용자 지정)",
"Conditional": "조건부",
"Loop": "반복",
"Available Variables:": "사용 가능한 변수:",
"Book title": "책 제목",
"Book author": "책 저자",
"Export date": "내보내기 날짜",
"Array of chapters": "챕터 배열",
"Chapter title": "챕터 제목",
"Array of annotations": "주석 배열",
"Highlighted text": "하이라이트된 텍스트",
"Annotation note": "주석 노트",
"Update time": "업데이트 시간",
"Date Format Tokens:": "날짜 형식 토큰:",
"Year (4 digits)": "연도 (4자리)",
"Month (01-12)": "월 (01-12)",
"Day (01-31)": "일 (01-31)",
"Hour (00-23)": "시 (00-23)",
"Minute (00-59)": "분 (00-59)",
"Second (00-59)": "초 (00-59)",
"Show Source": "소스 표시",
"No content to preview": "미리 볼 내용 없음",
"Export": "내보내기",
"Set Timeout": "시간 제한 설정",
"Select Voice": "음성 선택",
"Toggle Sticky Bottom TTS Bar": "고정 TTS 바 전환",
"Display what I'm reading on Discord": "Discord에 읽는 책 표시",
"Show on Discord": "Discord에 표시"
}
@@ -25,9 +25,6 @@
"Your OTP token": "Token OTP anda",
"Verify token": "Sahkan token",
"Go Back": "Kembali",
"Sign in with Google": "Log masuk dengan Google",
"Sign in with Apple": "Log masuk dengan Apple",
"Sign in with GitHub": "Log masuk dengan GitHub",
"New Password": "Kata Laluan Baharu",
"Your new password": "Kata laluan baharu anda",
"Update password": "Kemas kini kata laluan",
@@ -649,8 +646,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
@@ -826,5 +823,62 @@
"Clear search": "Kosongkan carian",
"Clear search history": "Kosongkan sejarah carian",
"Quick action disabled": "Tindakan pantas dilumpuhkan",
"Tap to Toggle Footer": "Ketik untuk togol pengaki"
"Tap to Toggle Footer": "Ketik untuk togol pengaki",
"Exported successfully": "Berjaya dieksport",
"Book exported successfully.": "Buku berjaya dieksport.",
"Failed to export the book.": "Gagal mengeksport buku.",
"Export Book": "Eksport Buku",
"Whole word:": "Perkataan penuh:",
"Error": "Ralat",
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuatkan artikel. Cuba cari terus di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuatkan perkataan. Cuba cari terus di {{link}}.",
"Date Published": "Tarikh diterbitkan",
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Dimuat naik",
"Downloaded": "Dimuat turun",
"Deleted": "Dipadam",
"Note:": "Nota:",
"Time:": "Masa:",
"Format Options": "Pilihan Format",
"Export Date": "Tarikh Eksport",
"Chapter Titles": "Tajuk Bab",
"Chapter Separator": "Pemisah Bab",
"Highlights": "Serlahan",
"Note Date": "Tarikh Nota",
"Advanced": "Lanjutan",
"Hide": "Sembunyikan",
"Show": "Tunjukkan",
"Use Custom Template": "Gunakan Templat Tersuai",
"Export Template": "Templat Eksport",
"Template Syntax:": "Sintaks Templat:",
"Insert value": "Masukkan nilai",
"Format date (locale)": "Format tarikh (tempatan)",
"Format date (custom)": "Format tarikh (tersuai)",
"Conditional": "Bersyarat",
"Loop": "Gelung",
"Available Variables:": "Pembolehubah Tersedia:",
"Book title": "Tajuk buku",
"Book author": "Pengarang buku",
"Export date": "Tarikh eksport",
"Array of chapters": "Senarai bab",
"Chapter title": "Tajuk bab",
"Array of annotations": "Senarai anotasi",
"Highlighted text": "Teks yang diserlahkan",
"Annotation note": "Nota anotasi",
"Update time": "Masa kemas kini",
"Date Format Tokens:": "Token Format Tarikh:",
"Year (4 digits)": "Tahun (4 digit)",
"Month (01-12)": "Bulan (01-12)",
"Day (01-31)": "Hari (01-31)",
"Hour (00-23)": "Jam (00-23)",
"Minute (00-59)": "Minit (00-59)",
"Second (00-59)": "Saat (00-59)",
"Show Source": "Tunjukkan Sumber",
"No content to preview": "Tiada kandungan untuk pratonton",
"Export": "Eksport",
"Set Timeout": "Tetapkan masa tamat",
"Select Voice": "Pilih suara",
"Toggle Sticky Bottom TTS Bar": "Togol bar TTS melekit",
"Display what I'm reading on Discord": "Papar buku yang sedang dibaca di Discord",
"Show on Discord": "Papar di Discord"
}
@@ -23,9 +23,6 @@
"Token": "Token",
"Your OTP token": "Uw OTP-token",
"Verify token": "Token verifiëren",
"Sign in with Google": "Inloggen met Google",
"Sign in with Apple": "Inloggen met Apple",
"Sign in with GitHub": "Inloggen met GitHub",
"New Password": "Nieuw wachtwoord",
"Your new password": "Uw nieuwe wachtwoord",
"Update password": "Wachtwoord bijwerken",
@@ -341,8 +338,8 @@
"{{count}} pages left in chapter_one": "Nog {{count}} pagina in hoofdstuk",
"{{count}} pages left in chapter_other": "Nog {{count}} paginas in hoofdstuk",
"Show Remaining Pages": "Toon resterende paginas",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Abonnement beheren",
"Coming Soon": "Binnenkort beschikbaar",
@@ -836,5 +833,62 @@
"Clear search": "Zoekopdracht wissen",
"Clear search history": "Zoekgeschiedenis wissen",
"Quick action disabled": "Snelle actie uitgeschakeld",
"Tap to Toggle Footer": "Tik om voettekst te wisselen"
"Tap to Toggle Footer": "Tik om voettekst te wisselen",
"Exported successfully": "Succesvol geëxporteerd",
"Book exported successfully.": "Boek succesvol geëxporteerd.",
"Failed to export the book.": "Exporteren van het boek mislukt.",
"Export Book": "Boek exporteren",
"Whole word:": "Heel woord:",
"Error": "Fout",
"Unable to load the article. Try searching directly on {{link}}.": "Kan het artikel niet laden. Probeer direct te zoeken op {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan het woord niet laden. Probeer direct te zoeken op {{link}}.",
"Date Published": "Publicatiedatum",
"Only for TTS:": "Alleen voor TTS:",
"Uploaded": "Geüpload",
"Downloaded": "Gedownload",
"Deleted": "Verwijderd",
"Note:": "Notitie:",
"Time:": "Tijd:",
"Format Options": "Formaatopties",
"Export Date": "Exportdatum",
"Chapter Titles": "Hoofdstuktitels",
"Chapter Separator": "Hoofdstukscheiding",
"Highlights": "Markeringen",
"Note Date": "Notitiedatum",
"Advanced": "Geavanceerd",
"Hide": "Verbergen",
"Show": "Tonen",
"Use Custom Template": "Aangepast sjabloon gebruiken",
"Export Template": "Exportsjabloon",
"Template Syntax:": "Sjabloonsyntaxis:",
"Insert value": "Waarde invoegen",
"Format date (locale)": "Datum opmaken (lokaal)",
"Format date (custom)": "Datum opmaken (aangepast)",
"Conditional": "Voorwaardelijk",
"Loop": "Lus",
"Available Variables:": "Beschikbare variabelen:",
"Book title": "Boektitel",
"Book author": "Boekauteur",
"Export date": "Exportdatum",
"Array of chapters": "Reeks hoofdstukken",
"Chapter title": "Hoofdstuktitel",
"Array of annotations": "Reeks annotaties",
"Highlighted text": "Gemarkeerde tekst",
"Annotation note": "Annotatienotitie",
"Update time": "Updatetijd",
"Date Format Tokens:": "Datumformaattokens:",
"Year (4 digits)": "Jaar (4 cijfers)",
"Month (01-12)": "Maand (01-12)",
"Day (01-31)": "Dag (01-31)",
"Hour (00-23)": "Uur (00-23)",
"Minute (00-59)": "Minuut (00-59)",
"Second (00-59)": "Seconde (00-59)",
"Show Source": "Bron tonen",
"No content to preview": "Geen inhoud om te bekijken",
"Export": "Exporteren",
"Set Timeout": "Time-out instellen",
"Select Voice": "Stem selecteren",
"Toggle Sticky Bottom TTS Bar": "Vaste TTS-balk schakelen",
"Display what I'm reading on Discord": "Toon wat ik lees op Discord",
"Show on Discord": "Toon op Discord"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "Twój OTP token",
"Verify token": "Weryfikacja tokena",
"Sign in with Google": "Zaloguj się za pomocą Google",
"Sign in with Apple": "Zaloguj się za pomocą Apple",
"Sign in with GitHub": "Zaloguj się za pomocą GitHub",
"Account": "Konto",
"Failed to delete user. Please try again later.": "Nie udało się usunąć użytkownika. Spróbuj ponownie później.",
"Community Support": "Wsparcie społeczności",
@@ -349,8 +346,8 @@
"{{count}} pages left in chapter_many": "Pozostało {{count}} stron w rozdziale",
"{{count}} pages left in chapter_other": "Pozostało {{count}} stron w rozdziale",
"Show Remaining Pages": "Pokaż pozostałe strony",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Zarządzaj subskrypcją",
"Coming Soon": "Wkrótce dostępne",
@@ -856,5 +853,62 @@
"Clear search": "Wyczyść wyszukiwanie",
"Clear search history": "Wyczyść historię wyszukiwania",
"Quick action disabled": "Szybka akcja wyłączona",
"Tap to Toggle Footer": "Dotknij, aby przełączyć stopkę"
"Tap to Toggle Footer": "Dotknij, aby przełączyć stopkę",
"Exported successfully": "Wyeksportowano pomyślnie",
"Book exported successfully.": "Książka została wyeksportowana pomyślnie.",
"Failed to export the book.": "Nie udało się wyeksportować książki.",
"Export Book": "Eksportuj książkę",
"Whole word:": "Całe słowo:",
"Error": "Błąd",
"Unable to load the article. Try searching directly on {{link}}.": "Nie można załadować artykułu. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Nie można załadować słowa. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Date Published": "Data wydania",
"Only for TTS:": "Tylko dla TTS:",
"Uploaded": "Przesłano",
"Downloaded": "Pobrano",
"Deleted": "Usunięto",
"Note:": "Notatka:",
"Time:": "Czas:",
"Format Options": "Opcje formatu",
"Export Date": "Data eksportu",
"Chapter Titles": "Tytuły rozdziałów",
"Chapter Separator": "Separator rozdziałów",
"Highlights": "Zaznaczenia",
"Note Date": "Data notatki",
"Advanced": "Zaawansowane",
"Hide": "Ukryj",
"Show": "Pokaż",
"Use Custom Template": "Użyj własnego szablonu",
"Export Template": "Szablon eksportu",
"Template Syntax:": "Składnia szablonu:",
"Insert value": "Wstaw wartość",
"Format date (locale)": "Formatuj datę (lokalizacja)",
"Format date (custom)": "Formatuj datę (własny)",
"Conditional": "Warunkowy",
"Loop": "Pętla",
"Available Variables:": "Dostępne zmienne:",
"Book title": "Tytuł książki",
"Book author": "Autor książki",
"Export date": "Data eksportu",
"Array of chapters": "Tablica rozdziałów",
"Chapter title": "Tytuł rozdziału",
"Array of annotations": "Tablica adnotacji",
"Highlighted text": "Zaznaczony tekst",
"Annotation note": "Notatka adnotacji",
"Update time": "Czas aktualizacji",
"Date Format Tokens:": "Tokeny formatu daty:",
"Year (4 digits)": "Rok (4 cyfry)",
"Month (01-12)": "Miesiąc (01-12)",
"Day (01-31)": "Dzień (01-31)",
"Hour (00-23)": "Godzina (00-23)",
"Minute (00-59)": "Minuta (00-59)",
"Second (00-59)": "Sekunda (00-59)",
"Show Source": "Pokaż źródło",
"No content to preview": "Brak treści do podglądu",
"Export": "Eksportuj",
"Set Timeout": "Ustaw limit czasu",
"Select Voice": "Wybierz głos",
"Toggle Sticky Bottom TTS Bar": "Przełącz przypiętą dolną belkę TTS",
"Display what I'm reading on Discord": "Wyświetl to, co czytam na Discord",
"Show on Discord": "Pokaż na Discord"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "Seu token OTP",
"Verify token": "Verificar token",
"Sign in with Google": "Entrar com o Google",
"Sign in with Apple": "Entrar com o Apple",
"Sign in with GitHub": "Entrar com o GitHub",
"Account": "Conta",
"Failed to delete user. Please try again later.": "Falha ao excluir usuário. Por favor, tente novamente mais tarde.",
"Community Support": "Suporte da comunidade",
@@ -345,8 +342,8 @@
"{{count}} pages left in chapter_many": "Faltam {{count}} páginas neste capítulo",
"{{count}} pages left in chapter_other": "Faltam {{count}} páginas neste capítulo",
"Show Remaining Pages": "Mostrar páginas restantes",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Gerenciar assinatura",
"Coming Soon": "Em breve",
@@ -846,5 +843,62 @@
"Clear search": "Limpar pesquisa",
"Clear search history": "Limpar histórico de pesquisa",
"Quick action disabled": "Ação rápida desativada",
"Tap to Toggle Footer": "Toque para alternar rodapé"
"Tap to Toggle Footer": "Toque para alternar rodapé",
"Exported successfully": "Exportado com sucesso",
"Book exported successfully.": "Livro exportado com sucesso.",
"Failed to export the book.": "Falha ao exportar o livro.",
"Export Book": "Exportar livro",
"Whole word:": "Palavra inteira:",
"Error": "Erro",
"Unable to load the article. Try searching directly on {{link}}.": "Não foi possível carregar o artigo. Tente pesquisar diretamente em {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Não foi possível carregar a palavra. Tente pesquisar diretamente em {{link}}.",
"Date Published": "Data de publicação",
"Only for TTS:": "Apenas para TTS:",
"Uploaded": "Enviado",
"Downloaded": "Baixado",
"Deleted": "Excluído",
"Note:": "Nota:",
"Time:": "Hora:",
"Format Options": "Opções de formato",
"Export Date": "Data de exportação",
"Chapter Titles": "Títulos dos capítulos",
"Chapter Separator": "Separador de capítulos",
"Highlights": "Destaques",
"Note Date": "Data da nota",
"Advanced": "Avançado",
"Hide": "Ocultar",
"Show": "Mostrar",
"Use Custom Template": "Usar modelo personalizado",
"Export Template": "Modelo de exportação",
"Template Syntax:": "Sintaxe do modelo:",
"Insert value": "Inserir valor",
"Format date (locale)": "Formatar data (local)",
"Format date (custom)": "Formatar data (personalizado)",
"Conditional": "Condicional",
"Loop": "Loop",
"Available Variables:": "Variáveis disponíveis:",
"Book title": "Título do livro",
"Book author": "Autor do livro",
"Export date": "Data de exportação",
"Array of chapters": "Lista de capítulos",
"Chapter title": "Título do capítulo",
"Array of annotations": "Lista de anotações",
"Highlighted text": "Texto destacado",
"Annotation note": "Nota de anotação",
"Update time": "Hora de atualização",
"Date Format Tokens:": "Tokens de formato de data:",
"Year (4 digits)": "Ano (4 dígitos)",
"Month (01-12)": "Mês (01-12)",
"Day (01-31)": "Dia (01-31)",
"Hour (00-23)": "Hora (00-23)",
"Minute (00-59)": "Minuto (00-59)",
"Second (00-59)": "Segundo (00-59)",
"Show Source": "Mostrar fonte",
"No content to preview": "Sem conteúdo para visualizar",
"Export": "Exportar",
"Set Timeout": "Definir tempo limite",
"Select Voice": "Selecionar voz",
"Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fixada",
"Display what I'm reading on Discord": "Mostrar o que estou lendo no Discord",
"Show on Discord": "Mostrar no Discord"
}
@@ -173,9 +173,6 @@
"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": "Поддержка сообщества",
@@ -349,8 +346,8 @@
"{{count}} pages left in chapter_many": "Осталось {{count}} страниц в главе",
"{{count}} pages left in chapter_other": "Осталось {{count}} страниц в главе",
"Show Remaining Pages": "Показать оставшиеся страницы",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Управление подпиской",
"Coming Soon": "Скоро будет",
@@ -856,5 +853,62 @@
"Clear search": "Очистить поиск",
"Clear search history": "Очистить историю поиска",
"Quick action disabled": "Быстрое действие отключено",
"Tap to Toggle Footer": "Нажмите для переключения нижнего колонтитула"
"Tap to Toggle Footer": "Нажмите для переключения нижнего колонтитула",
"Exported successfully": "Успешно экспортировано",
"Book exported successfully.": "Книга успешно экспортирована.",
"Failed to export the book.": "Не удалось экспортировать книгу.",
"Export Book": "Экспорт книги",
"Whole word:": "Слово целиком:",
"Error": "Ошибка",
"Unable to load the article. Try searching directly on {{link}}.": "Не удалось загрузить статью. Попробуйте искать напрямую на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не удалось загрузить слово. Попробуйте искать напрямую на {{link}}.",
"Date Published": "Дата публикации",
"Only for TTS:": "Только для TTS:",
"Uploaded": "Загружено",
"Downloaded": "Скачано",
"Deleted": "Удалено",
"Note:": "Примечание:",
"Time:": "Время:",
"Format Options": "Параметры формата",
"Export Date": "Дата экспорта",
"Chapter Titles": "Названия глав",
"Chapter Separator": "Разделитель глав",
"Highlights": "Выделения",
"Note Date": "Дата примечания",
"Advanced": "Дополнительно",
"Hide": "Скрыть",
"Show": "Показать",
"Use Custom Template": "Использовать свой шаблон",
"Export Template": "Шаблон экспорта",
"Template Syntax:": "Синтаксис шаблона:",
"Insert value": "Вставить значение",
"Format date (locale)": "Форматировать дату (локаль)",
"Format date (custom)": "Форматировать дату (свой)",
"Conditional": "Условный",
"Loop": "Цикл",
"Available Variables:": "Доступные переменные:",
"Book title": "Название книги",
"Book author": "Автор книги",
"Export date": "Дата экспорта",
"Array of chapters": "Массив глав",
"Chapter title": "Название главы",
"Array of annotations": "Массив примечаний",
"Highlighted text": "Выделенный текст",
"Annotation note": "Примечание аннотации",
"Update time": "Время обновления",
"Date Format Tokens:": "Токены формата даты:",
"Year (4 digits)": "Год (4 цифры)",
"Month (01-12)": "Месяц (01-12)",
"Day (01-31)": "День (01-31)",
"Hour (00-23)": "Час (00-23)",
"Minute (00-59)": "Минута (00-59)",
"Second (00-59)": "Секунда (00-59)",
"Show Source": "Показать источник",
"No content to preview": "Нет содержимого для просмотра",
"Export": "Экспортировать",
"Set Timeout": "Установить тайм-аут",
"Select Voice": "Выбрать голос",
"Toggle Sticky Bottom TTS Bar": "Переключить закреплённую панель TTS",
"Display what I'm reading on Discord": "Показывать книгу в Discord",
"Show on Discord": "Показать в Discord"
}
@@ -24,9 +24,6 @@
"Token": "ටෝකනය",
"Your OTP token": "ඔබේ OTP ටෝකනය",
"Verify token": "ටෝකනය තහවුරු කරන්න",
"Sign in with Google": "Google සමඟ ඇතුල් වන්න",
"Sign in with Apple": "Apple සමඟ ඇතුල් වන්න",
"Sign in with GitHub": "GitHub සමඟ ඇතුල් වන්න",
"New Password": "නව මුරපදය",
"Your new password": "ඔබේ නව මුරපදය",
"Update password": "මුරපදය යාවත්කාලීන කරන්න",
@@ -488,8 +485,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
@@ -836,5 +833,62 @@
"Clear search": "සෙවුම හිස් කරන්න",
"Clear search history": "සෙවුම් ඉතිහාසය හිස් කරන්න",
"Quick action disabled": "ඉක්මන් ක්‍රියාව අක්‍රීයයි",
"Tap to Toggle Footer": "පාදකය ටොගල් කිරීමට තට්ටු කරන්න"
"Tap to Toggle Footer": "පාදකය ටොගල් කිරීමට තට්ටු කරන්න",
"Exported successfully": "සාර්ථකව අපනයනය කරන ලදී",
"Book exported successfully.": "පොත සාර්ථකව අපනයනය කරන ලදී.",
"Failed to export the book.": "පොත අපනයනය කිරීමට අසමත් විය.",
"Export Book": "පොත අපනයනය කරන්න",
"Whole word:": "සම්පූර්ණ වචනය:",
"Error": "දෝෂය",
"Unable to load the article. Try searching directly on {{link}}.": "ලිපිය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Unable to load the word. Try searching directly on {{link}}.": "වචනය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Date Published": "ප්‍රකාශන දිනය",
"Only for TTS:": "TTS සඳහා පමණක්:",
"Uploaded": "උඩුගත කරන ලදී",
"Downloaded": "බාගත කරන ලදී",
"Deleted": "මකා දමන ලදී",
"Note:": "සටහන:",
"Time:": "කාලය:",
"Format Options": "ආකෘති විකල්ප",
"Export Date": "අපනයන දිනය",
"Chapter Titles": "කොටස් මාතෘකා",
"Chapter Separator": "කොටස් වෙන්කරු",
"Highlights": "ඉස්මතු කිරීම්",
"Note Date": "සටහන දිනය",
"Advanced": "උසස්",
"Hide": "සඟවන්න",
"Show": "පෙන්වන්න",
"Use Custom Template": "අභිරුචි සැකිල්ල භාවිතා කරන්න",
"Export Template": "අපනයන සැකිල්ල",
"Template Syntax:": "සැකිල්ලේ ව්‍යවස්ථාව:",
"Insert value": "වටිනාකම ඇතුළත් කරන්න",
"Format date (locale)": "දිනය ආකෘතිය (දේශීය)",
"Format date (custom)": "අභිරුචි දිනය ආකෘතිය",
"Conditional": "ශර්තමය",
"Loop": "ලූප්",
"Available Variables:": "ලබා ගත හැකි විචල්‍ය:",
"Book title": "පොත් මාතෘකාව",
"Book author": "පොත් රචකයා",
"Export date": "අපනයන දිනය",
"Array of chapters": "කොටස් අරය",
"Chapter title": "කොටස් මාතෘකාව",
"Array of annotations": "අනුසටහන් අරය",
"Highlighted text": "ඉස්මතු කළ පෙළ",
"Annotation note": "අනුසටහන් සටහන",
"Update time": "යාවත්කාලීන කාලය",
"Date Format Tokens:": "දිනය ආකෘති ටෝකන:",
"Year (4 digits)": "වසර (අංක 4)",
"Month (01-12)": "මාසය (01-12)",
"Day (01-31)": "දින (01-31)",
"Hour (00-23)": "පැය (00-23)",
"Minute (00-59)": "මිනිත්තු (00-59)",
"Second (00-59)": "තත්පර (00-59)",
"Show Source": "මූලාශ්‍රය පෙන්වන්න",
"No content to preview": "පෙරදසුන සඳහා අන්තර්ගතයක් නැත",
"Export": "අපනයනය කරන්න",
"Set Timeout": "කාල සීමාව සකසන්න",
"Select Voice": "හඬ තෝරන්න",
"Toggle Sticky Bottom TTS Bar": "ඇලවූ TTS තීරුව මාරු කරන්න",
"Display what I'm reading on Discord": "Discord හි කියවන පොත පෙන්වන්න",
"Show on Discord": "Discord හි පෙන්වන්න"
}
@@ -25,9 +25,6 @@
"Your OTP token": "Din OTP-token",
"Verify token": "Verifiera token",
"Go Back": "Tillbaka",
"Sign in with Google": "Logga in med Google",
"Sign in with Apple": "Logga in med Apple",
"Sign in with GitHub": "Logga in med GitHub",
"New Password": "Nytt lösenord",
"Your new password": "Ditt nya lösenord",
"Update password": "Uppdatera lösenord",
@@ -573,8 +570,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Böcker",
"Open Library": "Open Library",
@@ -836,5 +833,62 @@
"Clear search": "Rensa sökning",
"Clear search history": "Rensa sökhistorik",
"Quick action disabled": "Snabbåtgärd inaktiverad",
"Tap to Toggle Footer": "Tryck för att växla sidfot"
"Tap to Toggle Footer": "Tryck för att växla sidfot",
"Exported successfully": "Exporterad",
"Book exported successfully.": "Boken har exporterats.",
"Failed to export the book.": "Det gick inte att exportera boken.",
"Export Book": "Exportera bok",
"Whole word:": "Hela ordet:",
"Error": "Fel",
"Unable to load the article. Try searching directly on {{link}}.": "Kan inte ladda artikeln. Försök söka direkt på {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan inte ladda ordet. Försök söka direkt på {{link}}.",
"Date Published": "Publiceringsdatum",
"Only for TTS:": "Endast för TTS:",
"Uploaded": "Uppladdad",
"Downloaded": "Nedladdad",
"Deleted": "Borttagen",
"Note:": "Anteckning:",
"Time:": "Tid:",
"Format Options": "Formatalternativ",
"Export Date": "Exportdatum",
"Chapter Titles": "Kapiteltitlar",
"Chapter Separator": "Kapitelavgränsare",
"Highlights": "Markeringar",
"Note Date": "Anteckningsdatum",
"Advanced": "Avancerat",
"Hide": "Dölj",
"Show": "Visa",
"Use Custom Template": "Använd anpassad mall",
"Export Template": "Exportmall",
"Template Syntax:": "Mallsyntax:",
"Insert value": "Infoga värde",
"Format date (locale)": "Formatera datum (lokal)",
"Format date (custom)": "Formatera datum (anpassad)",
"Conditional": "Villkorlig",
"Loop": "Loop",
"Available Variables:": "Tillgängliga variabler:",
"Book title": "Boktitel",
"Book author": "Bokförfattare",
"Export date": "Exportdatum",
"Array of chapters": "Lista med kapitel",
"Chapter title": "Kapiteltitel",
"Array of annotations": "Lista med anteckningar",
"Highlighted text": "Markerad text",
"Annotation note": "Anteckningsnotering",
"Update time": "Uppdateringstid",
"Date Format Tokens:": "Datumformattecken:",
"Year (4 digits)": "År (4 siffror)",
"Month (01-12)": "Månad (01-12)",
"Day (01-31)": "Dag (01-31)",
"Hour (00-23)": "Timme (00-23)",
"Minute (00-59)": "Minut (00-59)",
"Second (00-59)": "Sekund (00-59)",
"Show Source": "Visa källa",
"No content to preview": "Inget innehåll att förhandsgranska",
"Export": "Exportera",
"Set Timeout": "Ställ in timeout",
"Select Voice": "Välj röst",
"Toggle Sticky Bottom TTS Bar": "Växla fast TTS-fält",
"Display what I'm reading on Discord": "Visa vad jag läser på Discord",
"Show on Discord": "Visa på Discord"
}
@@ -24,9 +24,6 @@
"Token": "டோக்கன்",
"Your OTP token": "உங்கள் OTP டோக்கன்",
"Verify token": "டோக்கனை சரிபார்க்கவும்",
"Sign in with Google": "Google மூலம் உள்நுழையவும்",
"Sign in with Apple": "Apple மூலம் உள்நுழையவும்",
"Sign in with GitHub": "GitHub மூலம் உள்நுழையவும்",
"New Password": "புதிய கடவுச்சொல்",
"Your new password": "உங்கள் புதிய கடவுச்சொல்",
"Update password": "கடவுச்சொல் புதுப்பிக்கவும்",
@@ -488,8 +485,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
@@ -836,5 +833,62 @@
"Clear search": "தேடலை அழி",
"Clear search history": "தேடல் வரலாற்றை அழி",
"Quick action disabled": "விரைவு செயல் முடக்கப்பட்டது",
"Tap to Toggle Footer": "அடிக்குறிப்பை மாற்ற தட்டவும்"
"Tap to Toggle Footer": "அடிக்குறிப்பை மாற்ற தட்டவும்",
"Exported successfully": "ஏற்றுமதி வெற்றி",
"Book exported successfully.": "புத்தகம் வெற்றிகரமாக ஏற்றுமதி செய்யப்பட்டது.",
"Failed to export the book.": "புத்தகத்தை ஏற்றுமதி செய்ய இயலவில்லை.",
"Export Book": "புத்தகத்தை ஏற்றுமதி செய்",
"Whole word:": "முழு சொல்:",
"Error": "பிழை",
"Unable to load the article. Try searching directly on {{link}}.": "கட்டுரையை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Unable to load the word. Try searching directly on {{link}}.": "சொல்லை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Date Published": "வெளியீட்டு தேதி",
"Only for TTS:": "TTS க்கு மட்டும்:",
"Uploaded": "பதிவேற்றப்பட்டது",
"Downloaded": "பதிவிறக்கப்பட்டது",
"Deleted": "நீக்கப்பட்டது",
"Note:": "குறிப்பு:",
"Time:": "நேரம்:",
"Format Options": "வடிவமைப்பு விருப்பங்கள்",
"Export Date": "ஏற்றுமதி தேதி",
"Chapter Titles": "அத்தியாயத் தலைப்புகள்",
"Chapter Separator": "அத்தியாய பிரிப்பான்",
"Highlights": "சிறப்பம்சங்கள்",
"Note Date": "குறிப்பு தேதி",
"Advanced": "மேம்பட்ட",
"Hide": "மறை",
"Show": "காட்டு",
"Use Custom Template": "தனிப்பயன் வார்ப்புரு பயன்படுத்து",
"Export Template": "ஏற்றுமதி வார்ப்புரு",
"Template Syntax:": "வார்ப்புரு தொடரியல்:",
"Insert value": "மதிப்பு செருகு",
"Format date (locale)": "தேதி வடிவமைப்பு (உள்ளூர்)",
"Format date (custom)": "தேதி வடிவமைப்பு (தனிப்பயன்)",
"Conditional": "நிபந்தனை",
"Loop": "சுழற்சி",
"Available Variables:": "கிடைக்கும் மாறிகள்:",
"Book title": "புத்தகத் தலைப்பு",
"Book author": "புத்தக ஆசிரியர்",
"Export date": "ஏற்றுமதி தேதி",
"Array of chapters": "அத்தியாயங்களின் பட்டியல்",
"Chapter title": "அத்தியாய தலைப்பு",
"Array of annotations": "குறிப்புகளின் பட்டியல்",
"Highlighted text": "முன்னிலைப்படுத்தப்பட்ட உரை",
"Annotation note": "குறிப்பு குறிப்பு",
"Update time": "புதுப்பிப்பு நேரம்",
"Date Format Tokens:": "தேதி வடிவமைப்பு டோக்கன்கள்:",
"Year (4 digits)": "ஆண்டு (4 இலக்கங்கள்)",
"Month (01-12)": "மாதம் (01-12)",
"Day (01-31)": "நாள் (01-31)",
"Hour (00-23)": "மணி (00-23)",
"Minute (00-59)": "நிமிடம் (00-59)",
"Second (00-59)": "விநாடி (00-59)",
"Show Source": "மூலத்தைக் காட்டு",
"No content to preview": "முன்னோட்டத்திற்கு உள்ளடக்கம் இல்லை",
"Export": "ஏற்றுமதி",
"Set Timeout": "நேர வரம்பை அமைக்கவும்",
"Select Voice": "குரலைத் தேர்ந்தெடுக்கவும்",
"Toggle Sticky Bottom TTS Bar": "நிலையான TTS பட்டியை மாற்றவும்",
"Display what I'm reading on Discord": "Discord இல் படிக்கும் புத்தகத்தை காட்டு",
"Show on Discord": "Discord இல் காட்டு"
}
@@ -23,9 +23,6 @@
"Token": "รหัสยืนยัน",
"Your OTP token": "รหัส OTP ของคุณ",
"Verify token": "ยืนยันรหัส",
"Sign in with Google": "เข้าสู่ระบบด้วย Google",
"Sign in with Apple": "เข้าสู่ระบบด้วย Apple",
"Sign in with GitHub": "เข้าสู่ระบบด้วย GitHub",
"New Password": "รหัสผ่านใหม่",
"Your new password": "รหัสผ่านใหม่ของคุณ",
"Update password": "อัปเดตรหัสผ่าน",
@@ -379,8 +376,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Azure Translator": "Azure Translator",
"DeepL": "DeepL",
@@ -826,5 +823,62 @@
"Clear search": "ล้างการค้นหา",
"Clear search history": "ล้างประวัติการค้นหา",
"Quick action disabled": "ปิดใช้งานการดำเนินการด่วน",
"Tap to Toggle Footer": "แตะเพื่อสลับส่วนท้าย"
"Tap to Toggle Footer": "แตะเพื่อสลับส่วนท้าย",
"Exported successfully": "ส่งออกสำเร็จ",
"Book exported successfully.": "ส่งออกหนังสือสำเร็จ",
"Failed to export the book.": "ส่งออกหนังสือล้มเหลว",
"Export Book": "ส่งออกหนังสือ",
"Whole word:": "คำทั้งคำ:",
"Error": "ข้อผิดพลาด",
"Unable to load the article. Try searching directly on {{link}}.": "ไม่สามารถโหลดบทความได้ ลองค้นหาโดยตรงที่ {{link}}",
"Unable to load the word. Try searching directly on {{link}}.": "ไม่สามารถโหลดคำได้ ลองค้นหาโดยตรงที่ {{link}}",
"Date Published": "วันที่เผยแพร่",
"Only for TTS:": "สำหรับ TTS เท่านั้น:",
"Uploaded": "อัปโหลดแล้ว",
"Downloaded": "ดาวน์โหลดแล้ว",
"Deleted": "ลบแล้ว",
"Note:": "หมายเหตุ:",
"Time:": "เวลา:",
"Format Options": "ตัวเลือกรูปแบบ",
"Export Date": "วันที่ส่งออก",
"Chapter Titles": "ชื่อบท",
"Chapter Separator": "ตัวคั่นบท",
"Highlights": "ไฮไลต์",
"Note Date": "วันที่หมายเหตุ",
"Advanced": "ขั้นสูง",
"Hide": "ซ่อน",
"Show": "แสดง",
"Use Custom Template": "ใช้เทมเพลตที่กำหนดเอง",
"Export Template": "เทมเพลตส่งออก",
"Template Syntax:": "ไวยากรณ์เทมเพลต:",
"Insert value": "แทรกค่า",
"Format date (locale)": "จัดรูปแบบวันที่ (ท้องถิ่น)",
"Format date (custom)": "จัดรูปแบบวันที่ (กำหนดเอง)",
"Conditional": "เงื่อนไข",
"Loop": "ลูป",
"Available Variables:": "ตัวแปรที่พร้อมใช้งาน:",
"Book title": "ชื่อหนังสือ",
"Book author": "ผู้แต่งหนังสือ",
"Export date": "วันที่ส่งออก",
"Array of chapters": "อาร์เรย์ของบท",
"Chapter title": "ชื่อบท",
"Array of annotations": "อาร์เรย์ของคำอธิบาย",
"Highlighted text": "ข้อความที่ไฮไลต์",
"Annotation note": "หมายเหตุคำอธิบาย",
"Update time": "เวลาอัปเดต",
"Date Format Tokens:": "โทเค็นรูปแบบวันที่:",
"Year (4 digits)": "ปี (4 หลัก)",
"Month (01-12)": "เดือน (01-12)",
"Day (01-31)": "วัน (01-31)",
"Hour (00-23)": "ชั่วโมง (00-23)",
"Minute (00-59)": "นาที (00-59)",
"Second (00-59)": "วินาที (00-59)",
"Show Source": "แสดงซอร์ส",
"No content to preview": "ไม่มีเนื้อหาให้แสดงตัวอย่าง",
"Export": "ส่งออก",
"Set Timeout": "ตั้งค่าหมดเวลา",
"Select Voice": "เลือกเสียง",
"Toggle Sticky Bottom TTS Bar": "สลับแถบ TTS แบบติด",
"Display what I'm reading on Discord": "แสดงหนังสือที่กำลังอ่านบน Discord",
"Show on Discord": "แสดงบน Discord"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "OTP token'ınız",
"Verify token": "Token'ı doğrula",
"Sign in with Google": "Google ile giriş yap",
"Sign in with Apple": "Apple ile giriş yap",
"Sign in with GitHub": "GitHub ile giriş yap",
"Account": "Hesap",
"Failed to delete user. Please try again later.": "Kullanıcı silinemedi. Lütfen daha sonra tekrar deneyin.",
"Community Support": "Topluluk Desteği",
@@ -341,8 +338,8 @@
"{{count}} pages left in chapter_one": "Bu bölümde {{count}} sayfa kaldı",
"{{count}} pages left in chapter_other": "Bu bölümde {{count}} sayfa kaldı",
"Show Remaining Pages": "Kalan sayfaları göster",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Aboneliği Yönet",
"Coming Soon": "Yakında",
@@ -836,5 +833,62 @@
"Clear search": "Aramayı temizle",
"Clear search history": "Arama geçmişini temizle",
"Quick action disabled": "Hızlı eylem devre dışı",
"Tap to Toggle Footer": "Altbilgiyi değiştirmek için dokunun"
"Tap to Toggle Footer": "Altbilgiyi değiştirmek için dokunun",
"Exported successfully": "Başarıyla dışa aktarıldı",
"Book exported successfully.": "Kitap başarıyla dışa aktarıldı.",
"Failed to export the book.": "Kitap dışa aktarılamadı.",
"Export Book": "Kitabı Dışa Aktar",
"Whole word:": "Tam kelime:",
"Error": "Hata",
"Unable to load the article. Try searching directly on {{link}}.": "Makale yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Unable to load the word. Try searching directly on {{link}}.": "Kelime yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Date Published": "Yayın tarihi",
"Only for TTS:": "Sadece TTS için:",
"Uploaded": "Yüklendi",
"Downloaded": "İndirildi",
"Deleted": "Silindi",
"Note:": "Not:",
"Time:": "Saat:",
"Format Options": "Format Seçenekleri",
"Export Date": "Dışa Aktarma Tarihi",
"Chapter Titles": "Bölüm Başlıkları",
"Chapter Separator": "Bölüm Ayracı",
"Highlights": "Vurgular",
"Note Date": "Not Tarihi",
"Advanced": "Gelişmiş",
"Hide": "Gizle",
"Show": "Göster",
"Use Custom Template": "Özel Şablon Kullan",
"Export Template": "Dışa Aktarma Şablonu",
"Template Syntax:": "Şablon Sözdizimi:",
"Insert value": "Değer ekle",
"Format date (locale)": "Tarihi biçimlendir (yerel)",
"Format date (custom)": "Tarihi biçimlendir (özel)",
"Conditional": "Koşullu",
"Loop": "Döngü",
"Available Variables:": "Kullanılabilir Değişkenler:",
"Book title": "Kitap başlığı",
"Book author": "Kitap yazarı",
"Export date": "Dışa aktarma tarihi",
"Array of chapters": "Bölümler dizisi",
"Chapter title": "Bölüm başlığı",
"Array of annotations": "Notlar dizisi",
"Highlighted text": "Vurgulanan metin",
"Annotation note": "Not açıklaması",
"Update time": "Güncelleme zamanı",
"Date Format Tokens:": "Tarih Biçimi Belirteçleri:",
"Year (4 digits)": "Yıl (4 basamak)",
"Month (01-12)": "Ay (01-12)",
"Day (01-31)": "Gün (01-31)",
"Hour (00-23)": "Saat (00-23)",
"Minute (00-59)": "Dakika (00-59)",
"Second (00-59)": "Saniye (00-59)",
"Show Source": "Kaynağı Göster",
"No content to preview": "Önizlenecek içerik yok",
"Export": "Dışa Aktar",
"Set Timeout": "Zaman aşımını ayarla",
"Select Voice": "Ses seç",
"Toggle Sticky Bottom TTS Bar": "Sabit TTS çubuğunu değiştir",
"Display what I'm reading on Discord": "Discord'da okuduğumu göster",
"Show on Discord": "Discord'da göster"
}
@@ -173,9 +173,6 @@
"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": "Підтримка спільноти",
@@ -349,8 +346,8 @@
"{{count}} pages left in chapter_many": "Залишилось {{count}} сторінок у розділі",
"{{count}} pages left in chapter_other": "Залишилось {{count}} сторінок у розділі",
"Show Remaining Pages": "Показати залишок сторінок",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Керувати підпискою",
"Coming Soon": "Незабаром",
@@ -856,5 +853,62 @@
"Clear search": "Очистити пошук",
"Clear search history": "Очистити історію пошуку",
"Quick action disabled": "Швидку дію вимкнено",
"Tap to Toggle Footer": "Торкніться, щоб перемкнути нижній колонтитул"
"Tap to Toggle Footer": "Торкніться, щоб перемкнути нижній колонтитул",
"Exported successfully": "Успішно експортовано",
"Book exported successfully.": "Книгу успішно експортовано.",
"Failed to export the book.": "Не вдалося експортувати книгу.",
"Export Book": "Експортувати книгу",
"Whole word:": "Ціле слово:",
"Error": "Помилка",
"Unable to load the article. Try searching directly on {{link}}.": "Не вдалося завантажити статтю. Спробуйте шукати безпосередньо на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не вдалося завантажити слово. Спробуйте шукати безпосередньо на {{link}}.",
"Date Published": "Дата публікації",
"Only for TTS:": "Тільки для TTS:",
"Uploaded": "Завантажено",
"Downloaded": "Звантажено",
"Deleted": "Видалено",
"Note:": "Примітка:",
"Time:": "Час:",
"Format Options": "Параметри формату",
"Export Date": "Дата експорту",
"Chapter Titles": "Назви розділів",
"Chapter Separator": "Роздільник розділів",
"Highlights": "Виділення",
"Note Date": "Дата примітки",
"Advanced": "Розширені",
"Hide": "Сховати",
"Show": "Показати",
"Use Custom Template": "Використовувати власний шаблон",
"Export Template": "Шаблон експорту",
"Template Syntax:": "Синтаксис шаблону:",
"Insert value": "Вставити значення",
"Format date (locale)": "Форматувати дату (локаль)",
"Format date (custom)": "Форматувати дату (власний)",
"Conditional": "Умовний",
"Loop": "Цикл",
"Available Variables:": "Доступні змінні:",
"Book title": "Назва книги",
"Book author": "Автор книги",
"Export date": "Дата експорту",
"Array of chapters": "Масив розділів",
"Chapter title": "Назва розділу",
"Array of annotations": "Масив приміток",
"Highlighted text": "Виділений текст",
"Annotation note": "Примітка анотації",
"Update time": "Час оновлення",
"Date Format Tokens:": "Токени формату дати:",
"Year (4 digits)": "Рік (4 цифри)",
"Month (01-12)": "Місяць (01-12)",
"Day (01-31)": "День (01-31)",
"Hour (00-23)": "Година (00-23)",
"Minute (00-59)": "Хвилина (00-59)",
"Second (00-59)": "Секунда (00-59)",
"Show Source": "Показати джерело",
"No content to preview": "Немає вмісту для перегляду",
"Export": "Експортувати",
"Set Timeout": "Встановити тайм-аут",
"Select Voice": "Вибрати голос",
"Toggle Sticky Bottom TTS Bar": "Перемкнути закріплену панель TTS",
"Display what I'm reading on Discord": "Показувати книгу в Discord",
"Show on Discord": "Показати в Discord"
}
@@ -173,9 +173,6 @@
"Token": "Mã token",
"Your OTP token": "Mã OTP của bạn",
"Verify token": "Xác minh mã token",
"Sign in with Google": "Đăng nhập với Google",
"Sign in with Apple": "Đăng nhập với Apple",
"Sign in with GitHub": "Đăng nhập với GitHub",
"Account": "Tài khoản",
"Failed to delete user. Please try again later.": "Không thể xóa người dùng. Vui lòng thử lại sau.",
"Community Support": "Hỗ trợ cộng đồng",
@@ -337,8 +334,8 @@
"Reset Settings": "Đặt lại cài đặt",
"{{count}} pages left in chapter_other": "Còn {{count}} trang trong chương",
"Show Remaining Pages": "Hiển thị trang còn lại",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Quản lý đăng ký",
"Coming Soon": "Sắp ra mắt",
@@ -826,5 +823,62 @@
"Clear search": "Xóa tìm kiếm",
"Clear search history": "Xóa lịch sử tìm kiếm",
"Quick action disabled": "Đã tắt thao tác nhanh",
"Tap to Toggle Footer": "Nhấn để bật/tắt chân trang"
"Tap to Toggle Footer": "Nhấn để bật/tắt chân trang",
"Exported successfully": "Xuất thành công",
"Book exported successfully.": "Sách đã được xuất thành công.",
"Failed to export the book.": "Xuất sách thất bại.",
"Export Book": "Xuất sách",
"Whole word:": "Toàn bộ từ:",
"Error": "Lỗi",
"Unable to load the article. Try searching directly on {{link}}.": "Không thể tải bài viết. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Không thể tải từ. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Date Published": "Ngày xuất bản",
"Only for TTS:": "Chỉ dành cho TTS:",
"Uploaded": "Đã tải lên",
"Downloaded": "Đã tải xuống",
"Deleted": "Đã xóa",
"Note:": "Ghi chú:",
"Time:": "Thời gian:",
"Format Options": "Tùy chọn định dạng",
"Export Date": "Ngày xuất",
"Chapter Titles": "Tiêu đề chương",
"Chapter Separator": "Dấu phân cách chương",
"Highlights": "Đánh dấu",
"Note Date": "Ngày ghi chú",
"Advanced": "Nâng cao",
"Hide": "Ẩn",
"Show": "Hiển thị",
"Use Custom Template": "Sử dụng mẫu tùy chỉnh",
"Export Template": "Mẫu xuất",
"Template Syntax:": "Cú pháp mẫu:",
"Insert value": "Chèn giá trị",
"Format date (locale)": "Định dạng ngày (địa phương)",
"Format date (custom)": "Định dạng ngày (tùy chỉnh)",
"Conditional": "Điều kiện",
"Loop": "Vòng lặp",
"Available Variables:": "Biến có sẵn:",
"Book title": "Tiêu đề sách",
"Book author": "Tác giả sách",
"Export date": "Ngày xuất",
"Array of chapters": "Danh sách chương",
"Chapter title": "Tiêu đề chương",
"Array of annotations": "Danh sách chú thích",
"Highlighted text": "Văn bản được đánh dấu",
"Annotation note": "Ghi chú chú thích",
"Update time": "Thời gian cập nhật",
"Date Format Tokens:": "Token định dạng ngày:",
"Year (4 digits)": "Năm (4 chữ số)",
"Month (01-12)": "Tháng (01-12)",
"Day (01-31)": "Ngày (01-31)",
"Hour (00-23)": "Giờ (00-23)",
"Minute (00-59)": "Phút (00-59)",
"Second (00-59)": "Giây (00-59)",
"Show Source": "Hiển thị nguồn",
"No content to preview": "Không có nội dung để xem trước",
"Export": "Xuất",
"Set Timeout": "Đặt thời gian chờ",
"Select Voice": "Chọn giọng nói",
"Toggle Sticky Bottom TTS Bar": "Bật/tắt thanh TTS cố định",
"Display what I'm reading on Discord": "Hiển thị sách đang đọc trên Discord",
"Show on Discord": "Hiện trên Discord"
}
@@ -173,9 +173,6 @@
"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": "社区支持",
@@ -337,8 +334,8 @@
"Reset Settings": "重置设置",
"{{count}} pages left in chapter_other": "本章剩余 {{count}} 页",
"Show Remaining Pages": "显示剩余页数",
"Source Han Serif CN VF": "思源宋体",
"Huiwen-mincho": "汇文明朝体",
"Source Han Serif CN": "思源宋体",
"Huiwen-MinchoGBK": "汇文明朝体",
"KingHwa_OldSong": "京华老宋体",
"Manage Subscription": "管理订阅",
"Coming Soon": "即将上线",
@@ -826,5 +823,62 @@
"Clear search": "清除搜索",
"Clear search history": "清除搜索历史",
"Quick action disabled": "快捷操作已禁用",
"Tap to Toggle Footer": "点击切换页脚"
"Tap to Toggle Footer": "点击切换页脚",
"Exported successfully": "导出成功",
"Book exported successfully.": "书籍导出成功。",
"Failed to export the book.": "书籍导出失败。",
"Export Book": "导出书籍",
"Whole word:": "全词匹配:",
"Only for TTS:": "仅用于TTS",
"Error": "错误",
"Unable to load the article. Try searching directly on {{link}}.": "无法加载文章。请直接在 {{link}} 上搜索。",
"Unable to load the word. Try searching directly on {{link}}.": "无法加载词汇。请直接在 {{link}} 上搜索。",
"Date Published": "出版日期",
"Uploaded": "已上传",
"Downloaded": "已下载",
"Deleted": "已删除",
"Note:": "笔记:",
"Time:": "时间:",
"Format Options": "格式选项",
"Export Date": "导出日期",
"Chapter Titles": "章节标题",
"Chapter Separator": "章节分隔符",
"Highlights": "高亮",
"Note Date": "笔记日期",
"Advanced": "高级",
"Hide": "隐藏",
"Show": "显示",
"Use Custom Template": "使用自定义模板",
"Export Template": "导出模板",
"Template Syntax:": "模板语法:",
"Insert value": "插入值",
"Format date (locale)": "格式化日期(区域)",
"Format date (custom)": "格式化日期(自定义)",
"Conditional": "条件",
"Loop": "循环",
"Available Variables:": "可用变量:",
"Book title": "书名",
"Book author": "作者",
"Export date": "导出日期",
"Array of chapters": "章节数组",
"Chapter title": "章节标题",
"Array of annotations": "标注数组",
"Highlighted text": "高亮文本",
"Annotation note": "标注笔记",
"Update time": "更新时间",
"Date Format Tokens:": "日期格式标记:",
"Year (4 digits)": "年(4位)",
"Month (01-12)": "月(01-12",
"Day (01-31)": "日(01-31",
"Hour (00-23)": "时(00-23",
"Minute (00-59)": "分(00-59",
"Second (00-59)": "秒(00-59",
"Show Source": "显示源码",
"No content to preview": "无内容可预览",
"Export": "导出",
"Set Timeout": "设置超时",
"Select Voice": "选择语音",
"Toggle Sticky Bottom TTS Bar": "切换固定TTS栏",
"Display what I'm reading on Discord": "在Discord显示阅读状态",
"Show on Discord": "在Discord显示"
}
@@ -173,9 +173,6 @@
"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": "社群支援",
@@ -337,8 +334,8 @@
"Reset Settings": "重置設置",
"{{count}} pages left in chapter_other": "本章剩餘 {{count}} 頁",
"Show Remaining Pages": "顯示剩餘頁數",
"Source Han Serif CN VF": "思源宋體",
"Huiwen-mincho": "匯文明朝體",
"Source Han Serif CN": "思源宋體",
"Huiwen-MinchoGBK": "匯文明朝體",
"KingHwa_OldSong": "京華老宋體",
"Manage Subscription": "管理訂閱",
"Coming Soon": "即將推出",
@@ -826,5 +823,62 @@
"Clear search": "清除搜尋",
"Clear search history": "清除搜尋歷史",
"Quick action disabled": "快捷操作已停用",
"Tap to Toggle Footer": "點擊切換頁尾"
"Tap to Toggle Footer": "點擊切換頁尾",
"Exported successfully": "匯出成功",
"Book exported successfully.": "書籍匯出成功。",
"Failed to export the book.": "書籍匯出失敗。",
"Export Book": "匯出書籍",
"Whole word:": "全詞匹配:",
"Error": "錯誤",
"Unable to load the article. Try searching directly on {{link}}.": "無法載入文章。請直接在 {{link}} 上搜尋。",
"Unable to load the word. Try searching directly on {{link}}.": "無法載入詞彙。請直接在 {{link}} 上搜尋。",
"Date Published": "出版日期",
"Only for TTS:": "僅用於TTS",
"Uploaded": "已上傳",
"Downloaded": "已下載",
"Deleted": "已刪除",
"Note:": "筆記:",
"Time:": "時間:",
"Format Options": "格式選項",
"Export Date": "匯出日期",
"Chapter Titles": "章節標題",
"Chapter Separator": "章節分隔符",
"Highlights": "標記",
"Note Date": "筆記日期",
"Advanced": "進階",
"Hide": "隱藏",
"Show": "顯示",
"Use Custom Template": "使用自訂模板",
"Export Template": "匯出模板",
"Template Syntax:": "模板語法:",
"Insert value": "插入值",
"Format date (locale)": "格式化日期(區域)",
"Format date (custom)": "格式化日期(自訂)",
"Conditional": "條件",
"Loop": "迴圈",
"Available Variables:": "可用變數:",
"Book title": "書名",
"Book author": "作者",
"Export date": "匯出日期",
"Array of chapters": "章節陣列",
"Chapter title": "章節標題",
"Array of annotations": "標註陣列",
"Highlighted text": "標記文字",
"Annotation note": "標註筆記",
"Update time": "更新時間",
"Date Format Tokens:": "日期格式標記:",
"Year (4 digits)": "年(4位)",
"Month (01-12)": "月(01-12",
"Day (01-31)": "日(01-31",
"Hour (00-23)": "時(00-23",
"Minute (00-59)": "分(00-59",
"Second (00-59)": "秒(00-59",
"Show Source": "顯示原始碼",
"No content to preview": "無內容可預覽",
"Export": "匯出",
"Set Timeout": "設定逾時",
"Select Voice": "選擇語音",
"Toggle Sticky Bottom TTS Bar": "切換固定TTS欄",
"Display what I'm reading on Discord": "在Discord顯示閱讀狀態",
"Show on Discord": "在Discord顯示"
}
+19
View File
@@ -1,5 +1,24 @@
{
"releases": {
"0.9.98": {
"date": "2026-01-21",
"notes": [
"Bookshelf: Added the ability to export original book files directly from the book details dialog",
"Bookshelf: Added a new sorting option to organize books by publish date",
"Bookshelf: Improved performance for large library with faster and more stable imports",
"OPDS: Improved download reliability by correctly detecting filenames for PDF and CBZ files",
"Proofreading: Added support for whole-word replacement to make text corrections more precise",
"Notes: Added the ability to export notes using a customizable template",
"PDF: Added support for panning within PDF documents for easier navigation",
"PDF: Improved performance by pre-rendering the next page for smoother page transitions",
"Text-to-Speech: Added support for word replacement to improve pronunciation during playback",
"Text-to-Speech: Fixed an issue where playback speed settings were not applied correctly on Linux",
"E-ink: Improved reading stability by preventing unwanted scrolling when animations are disabled or on e-ink devices",
"Discord: Added Discord Rich Presence to display your current reading status on Desktop platforms",
"Android: Fixed an issue that could cause occasional crashes during app startup",
"Fonts: Improved web font loading for faster and more stable text rendering"
]
},
"0.9.97": {
"date": "2026-01-10",
"notes": [
+4 -1
View File
@@ -26,6 +26,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
thiserror = "2"
walkdir = "2"
tokio = { version = "1", features = ["fs"] }
tokio-util = { version = "0.7", features = ["codec"] }
futures-util = "0.3"
@@ -35,7 +36,7 @@ reqwest = { version = "0.12", default-features = false, features = [
"json",
"stream",
] }
tauri = { version = "2.5.1", features = [ "protocol-asset" ] }
tauri = { version = "2", features = [ "protocol-asset" ] }
tauri-build = "2"
tauri-plugin-log = "2"
tauri-plugin-fs = "2"
@@ -53,6 +54,7 @@ tauri-plugin-persisted-scope = "2"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
tauri-plugin-websocket = "2"
tauri-plugin-sharekit = "0.3"
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
@@ -70,3 +72,4 @@ tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
discord-rich-presence = "1.0.0"
@@ -152,12 +152,23 @@
}
]
},
{
"identifier": "shell:allow-spawn",
"allow": [
{
"name": "start-readest",
"cmd": "cmd",
"args": ["/C", "start", "", { "validator": "^.*Readest(.*)\\.exe$" }]
}
]
},
"haptics:allow-vibrate",
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
"native-bridge:default",
"deep-link:default",
"sharekit:default",
"native-tts:default",
"deep-link:default"
"native-bridge:default"
]
}
@@ -24,7 +24,7 @@ import com.readest.native_bridge.KeyDownInterceptor
import com.readest.native_bridge.NativeBridgePlugin
class MainActivity : TauriActivity(), KeyDownInterceptor {
private lateinit var wv: WebView
private var wv: WebView? = null
private var interceptVolumeKeysEnabled = false
private var interceptBackKeyEnabled = false
@@ -65,7 +65,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
val y = event.getY(pointerIndex)
val pressure = event.getPressure(pointerIndex)
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""
try {
if (window.onNativeTouch) {
@@ -103,7 +103,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
}
if (shouldIntercept) {
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""
try { window.onNativeKeyDown("$keyName", $keyCode); } catch (_) {}
""".trimIndent(),
@@ -119,7 +119,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val keyName = keyEventMap[keyCode]
if (keyName != null) {
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""
try {
window.onNativeKeyDown("$keyName", $keyCode)
@@ -178,7 +178,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
Log.d("MainActivity", "Back invoked callback triggered ${interceptBackKeyEnabled}")
if (interceptBackKeyEnabled) {
Log.d("MainActivity", "Back intercepted (OnBackInvokedCallback)")
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""window.onNativeKeyDown("Back", ${KeyEvent.KEYCODE_BACK});""",
null
)
@@ -194,7 +194,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
override fun handleOnBackPressed() {
if (interceptBackKeyEnabled) {
Log.d("MainActivity", "Back intercepted (OnBackPressedDispatcher)")
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""window.onNativeKeyDown("Back", ${KeyEvent.KEYCODE_BACK});""",
null
)
@@ -581,7 +581,6 @@ class NativeBridgePlugin: Plugin {
let darkMode = args.darkMode
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = !visible
UIApplication.shared.setStatusBarHidden(!visible, with: .none)
let windows = UIApplication.shared.connectedScenes
@@ -0,0 +1,96 @@
use std::path::Path;
use tauri::AppHandle;
use tauri_plugin_fs::FsExt;
use walkdir::WalkDir;
#[derive(serde::Serialize)]
pub struct ScannedFile {
pub path: String,
pub size: u64,
}
#[tauri::command]
pub fn read_dir(
app: AppHandle,
path: String,
recursive: bool,
extensions: Vec<String>,
) -> Result<Vec<ScannedFile>, String> {
let scope = app.fs_scope();
let path_buf = std::path::PathBuf::from(&path);
if !scope.is_allowed(&path_buf) {
return Err("Permission denied: Path not in filesystem scope".to_string());
}
let mut files = Vec::new();
let normalized_extensions: Vec<String> =
extensions.iter().map(|ext| ext.to_lowercase()).collect();
if recursive {
for entry_result in WalkDir::new(&path).into_iter() {
match entry_result {
Ok(entry) => {
if entry.file_type().is_file() {
if let Some(scanned_file) =
process_file_entry(entry.path(), &normalized_extensions)
{
files.push(scanned_file);
}
}
}
Err(e) => {
log::warn!("RUST: Skipping file due to error: {}", e);
}
}
}
} else {
match std::fs::read_dir(&path_buf) {
Ok(entries) => {
for entry_result in entries {
match entry_result {
Ok(entry) => {
let path = entry.path();
if path.is_file() {
if let Some(scanned_file) =
process_file_entry(&path, &normalized_extensions)
{
files.push(scanned_file);
}
}
}
Err(e) => {
log::warn!("RUST: Skipping entry due to error: {}", e);
}
}
}
}
Err(e) => {
return Err(format!("Failed to read directory: {}", e));
}
}
}
Ok(files)
}
fn process_file_entry(path: &Path, extensions: &[String]) -> Option<ScannedFile> {
if extensions.is_empty() || extensions.contains(&"*".to_string()) {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
return Some(ScannedFile {
path: path.to_string_lossy().to_string(),
size,
});
} else if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if extensions.contains(&ext_str) {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
return Some(ScannedFile {
path: path.to_string_lossy().to_string(),
size,
});
}
}
None
}
@@ -0,0 +1,182 @@
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use serde::Deserialize;
use std::sync::{Arc, Mutex};
use tauri::State;
const DISCORD_APP_ID: &str = "1462683110612144348";
const MAX_TITLE_LENGTH: usize = 128;
const MAX_AUTHOR_LENGTH: usize = 128;
#[derive(Debug)]
pub struct DiscordRpcClient {
client: Option<DiscordIpcClient>,
current_book_hash: Option<String>,
}
impl DiscordRpcClient {
pub fn new() -> Self {
DiscordRpcClient {
client: None,
current_book_hash: None,
}
}
fn ensure_connected(&mut self) -> Result<(), String> {
if self.client.is_some() {
return Ok(());
}
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
match client.connect() {
Ok(_) => {
log::info!("Successfully connected to Discord");
self.client = Some(client);
Ok(())
}
Err(e) => {
log::warn!("Failed to connect to Discord: {}", e);
Err(format!("Failed to connect to Discord: {}", e))
}
}
}
fn disconnect(&mut self) {
if let Some(mut client) = self.client.take() {
let _ = client.close();
log::debug!("Disconnected from Discord");
}
self.current_book_hash = None;
}
fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BookPresenceData {
book_hash: String,
title: String,
author: Option<String>,
cover_url: Option<String>,
session_start: i64,
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
#[tauri::command]
pub async fn update_book_presence(
state: State<'_, Arc<Mutex<DiscordRpcClient>>>,
presence: BookPresenceData,
) -> Result<(), String> {
let mut client = state
.lock()
.map_err(|e| format!("Mutex lock error: {}", e))?;
if let Err(e) = client.ensure_connected() {
log::debug!("Discord not available: {}", e);
return Ok(());
}
let BookPresenceData {
book_hash,
title,
author,
cover_url,
session_start,
} = presence;
// Truncate title and author to avoid Discord API limits
let truncated_title = DiscordRpcClient::truncate_string(&title, MAX_TITLE_LENGTH);
let state_text = if let Some(ref author_name) = author {
let truncated_author = DiscordRpcClient::truncate_string(author_name, MAX_AUTHOR_LENGTH);
format!("by {}", truncated_author)
} else {
String::new()
};
let mut activity_builder = activity::Activity::new().details(&truncated_title);
if !state_text.is_empty() {
activity_builder = activity_builder.state(&state_text);
}
activity_builder =
activity_builder.timestamps(activity::Timestamps::new().start(session_start / 1000));
let large_image = cover_url
.as_deref()
.filter(|url| url.starts_with("https://"))
.unwrap_or("book_icon");
let assets_builder = activity::Assets::new()
.large_image(large_image)
.large_text(&truncated_title);
activity_builder = activity_builder.assets(assets_builder);
let button = activity::Button::new("Read on Readest", "https://web.readest.com");
activity_builder = activity_builder.buttons(vec![button]);
if let Some(ref mut discord_client) = client.client {
match discord_client.set_activity(activity_builder) {
Ok(_) => {
log::info!("Successfully updated Discord presence");
client.current_book_hash = Some(book_hash);
Ok(())
}
Err(e) => {
log::error!("Failed to update Discord activity: {}", e);
client.disconnect();
Err(format!("Failed to update Discord activity: {}", e))
}
}
} else {
Err("Discord client not initialized".to_string())
}
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
#[tauri::command]
pub async fn clear_book_presence(
state: State<'_, Arc<Mutex<DiscordRpcClient>>>,
) -> Result<(), String> {
let mut client = state
.lock()
.map_err(|e| format!("Mutex lock error: {}", e))?;
if let Some(ref mut discord_client) = client.client {
match discord_client.clear_activity() {
Ok(_) => {
log::info!("Successfully cleared Discord presence");
client.current_book_hash = None;
Ok(())
}
Err(e) => {
log::error!("Failed to clear Discord activity: {}", e);
client.disconnect();
Ok(())
}
}
} else {
log::debug!("No Discord client to clear");
client.current_book_hash = None;
Ok(())
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
#[tauri::command]
pub async fn update_book_presence(_presence: BookPresenceData) -> Result<(), String> {
Ok(()) // No-op on non-desktop platforms
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
#[tauri::command]
pub async fn clear_book_presence() -> Result<(), String> {
Ok(()) // No-op on non-desktop platforms
}
+29 -16
View File
@@ -22,6 +22,9 @@ use tauri_plugin_fs::FsExt;
#[cfg(desktop)]
use tauri::{Listener, Url};
mod dir_scanner;
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
mod discord_rpc;
#[cfg(target_os = "macos")]
mod macos;
mod transfer_file;
@@ -41,14 +44,14 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
let asset_protocol_scope = app.asset_protocol_scope();
for file in &files {
if let Err(e) = fs_scope.allow_file(file) {
eprintln!("Failed to allow file in fs_scope: {e}");
log::error!("Failed to allow file in fs_scope: {e}");
} else {
println!("Allowed file in fs_scope: {file:?}");
log::debug!("Allowed file in fs_scope: {file:?}");
}
if let Err(e) = asset_protocol_scope.allow_file(file) {
eprintln!("Failed to allow file in asset_protocol_scope: {e}");
log::error!("Failed to allow file in asset_protocol_scope: {e}");
} else {
println!("Allowed file in asset_protocol_scope: {file:?}");
log::debug!("Allowed file in asset_protocol_scope: {file:?}");
}
}
}
@@ -57,14 +60,14 @@ fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) {
let fs_scope = app.fs_scope();
let asset_protocol_scope = app.asset_protocol_scope();
if let Err(e) = fs_scope.allow_directory(dir, true) {
eprintln!("Failed to allow directory in fs_scope: {e}");
log::error!("Failed to allow directory in fs_scope: {e}");
} else {
println!("Allowed directory in fs_scope: {dir:?}");
log::info!("Allowed directory in fs_scope: {dir:?}");
}
if let Err(e) = asset_protocol_scope.allow_directory(dir, true) {
eprintln!("Failed to allow directory in asset_protocol_scope: {e}");
log::error!("Failed to allow directory in asset_protocol_scope: {e}");
} else {
println!("Allowed directory in asset_protocol_scope: {dir:?}");
log::info!("Allowed directory in asset_protocol_scope: {dir:?}");
}
}
@@ -147,6 +150,11 @@ struct SingleInstancePayload {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.build(),
)
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_oauth::init())
@@ -156,12 +164,17 @@ pub fn run() {
upload_file,
get_environment_variable,
get_executable_dir,
dir_scanner::read_dir,
#[cfg(target_os = "macos")]
macos::safari_auth::auth_with_safari,
#[cfg(target_os = "macos")]
macos::apple_auth::start_apple_sign_in,
#[cfg(target_os = "macos")]
macos::traffic_light::set_traffic_lights,
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
discord_rpc::update_book_presence,
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
discord_rpc::clear_book_presence,
])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_persisted_scope::init())
@@ -170,6 +183,7 @@ pub fn run() {
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_sharekit::init())
.plugin(tauri_plugin_native_bridge::init())
.plugin(tauri_plugin_native_tts::init());
@@ -209,6 +223,13 @@ pub fn run() {
builder
.setup(|#[allow(unused_variables)] app| {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
use std::sync::{Arc, Mutex};
let discord_client = Arc::new(Mutex::new(discord_rpc::DiscordRpcClient::new()));
app.manage(discord_client);
}
#[cfg(desktop)]
{
let files = get_files_from_argv(std::env::args().collect());
@@ -265,14 +286,6 @@ pub fn run() {
let _ = app.deep_link().register_all();
}
if let Err(e) = app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
) {
eprintln!("Failed to initialize tauri_plugin_log: {e}");
};
// Check for e-ink device on Android before building the window
#[cfg(target_os = "android")]
let is_eink = android::is_eink_device();
@@ -38,8 +38,8 @@ pub struct AppleIDAuthorizationResponse {
}
thread_local! {
static APPLE_SIGN_IN_DELEGATE: RefCell<Option<Retained<ASAuthorizationControllerDelegateImpl>>> = RefCell::new(None);
static AUTHORIZATION_CONTROLLER: RefCell<Option<Retained<ASAuthorizationController>>> = RefCell::new(None);
static APPLE_SIGN_IN_DELEGATE: RefCell<Option<Retained<ASAuthorizationControllerDelegateImpl>>> = const { RefCell::new(None) };
static AUTHORIZATION_CONTROLLER: RefCell<Option<Retained<ASAuthorizationController>>> = const { RefCell::new(None) };
}
#[derive(Clone)]
@@ -70,7 +70,7 @@ define_class!(
{
let user_identifier = {
let user = credential.user();
if user.len() > 0 {
if !user.is_empty() {
Some(user.to_string())
} else {
None
@@ -183,7 +183,7 @@ pub fn start_apple_sign_in(app: AppHandle, payload: AppleIDAuthorizationRequest)
let auth_request = &request as &ASAuthorizationRequest;
let controller = ASAuthorizationController::initWithAuthorizationRequests(
ASAuthorizationController::alloc(),
&*NSArray::from_slice(&[auth_request]),
&NSArray::from_slice(&[auth_request]),
);
let delegate = ASAuthorizationControllerDelegateImpl::new(app.clone());
@@ -35,16 +35,11 @@ impl ThreadSafeObjcPointer {
}
}
#[derive(Default)]
pub struct AuthSession {
auth_session: Option<ThreadSafeObjcPointer>,
}
impl Default for AuthSession {
fn default() -> Self {
Self { auth_session: None }
}
}
impl Drop for AuthSession {
fn drop(&mut self) {
if let Some(session) = &self.auth_session {
@@ -110,7 +110,7 @@ pub async fn download_file(
single_threaded: Option<bool>,
skip_ssl_verification: Option<bool>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
) -> Result<HashMap<String, String>> {
use futures::stream::{self, StreamExt};
use std::cmp::min;
use tokio::io::AsyncSeekExt;
@@ -130,7 +130,7 @@ pub async fn download_file(
headers: &HashMap<String, String>,
body: &Option<String>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
) -> Result<HashMap<String, String>> {
let mut request = if let Some(body) = body {
client.post(url).body(body.clone())
} else {
@@ -149,6 +149,13 @@ pub async fn download_file(
));
}
let mut resp_headers = HashMap::new();
for (key, value) in response.headers().iter() {
if let Ok(val_str) = value.to_str() {
resp_headers.insert(key.to_string(), val_str.to_string());
}
}
let total = response.content_length().unwrap_or(0);
let mut file = BufWriter::new(File::create(file_path).await?);
let mut stream = response.bytes_stream();
@@ -165,7 +172,7 @@ pub async fn download_file(
}
file.flush().await?;
Ok(())
Ok(resp_headers)
}
if force_single {
@@ -193,6 +200,13 @@ pub async fn download_file(
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let mut resp_headers = HashMap::new();
for (key, value) in range_resp.headers().iter() {
if let Ok(val_str) = value.to_str() {
resp_headers.insert(key.to_string(), val_str.to_string());
}
}
if !accept_ranges || total == 0 {
return single_threaded_download(&client, url, file_path, &headers, &body, on_progress)
.await;
@@ -260,7 +274,7 @@ pub async fn download_file(
})
.await;
Ok(())
Ok(resp_headers)
}
#[command]
+2 -2
View File
@@ -17,8 +17,8 @@
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost http://*:* https://*:* https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.deno.dev https://cdnjs.cloudflare.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.deno.dev https://cdnjs.cloudflare.com",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com",
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
},
@@ -67,9 +67,14 @@ vi.mock('@/services/environment', async (importOriginal) => {
});
import { EnvProvider } from '@/context/EnvContext';
import { AuthProvider } from '@/context/AuthContext';
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
return render(
<EnvProvider>
<AuthProvider>{ui}</AuthProvider>
</EnvProvider>,
);
}
describe('ProofreadRulesManager', () => {
@@ -150,9 +155,9 @@ describe('ProofreadRulesManager', () => {
expect(dialog).toBeTruthy();
// Library (global) rules
expect(screen.getByText('foo')).toBeTruthy();
expect(screen.getByText('bar')).toBeTruthy();
expect(screen.getByText("'bar'")).toBeTruthy();
expect(screen.getByText('hello')).toBeTruthy();
expect(screen.getByText('world')).toBeTruthy();
expect(screen.getByText("'world'")).toBeTruthy();
});
it('renders selection rules separately from book/library rules', async () => {
@@ -231,11 +236,11 @@ describe('ProofreadRulesManager', () => {
// Single Instance Rules section
expect(screen.getByText('Selected Text Rules')).toBeTruthy();
expect(screen.getByText('only-once')).toBeTruthy();
expect(screen.getByText('single-hit')).toBeTruthy();
expect(screen.getByText("'single-hit'")).toBeTruthy();
// Book section should still show book-wide rule
expect(screen.getByText('book-wide')).toBeTruthy();
expect(screen.getByText('book-hit')).toBeTruthy();
expect(screen.getByText("'book-hit'")).toBeTruthy();
});
it('displays correct scope labels for different rule types', async () => {
@@ -402,11 +407,11 @@ describe('ProofreadRulesManager', () => {
const csRuleElement = screen.getByText('case-sensitive').closest('li');
expect(within(csRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(csRuleElement!).getByText(/Yes/)).toBeTruthy();
expect(within(csRuleElement!).getAllByText(/Yes/)).toBeTruthy();
const ciRuleElement = screen.getByText('case-insensitive').closest('li');
expect(within(ciRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(ciRuleElement!).getByText(/No/)).toBeTruthy();
expect(within(ciRuleElement!).getAllByText(/No/)).toBeTruthy();
});
it('opens when BookMenu item is clicked (integration)', async () => {
@@ -0,0 +1,378 @@
import { describe, it, expect } from 'vitest';
import { makeSafeFilename } from '../../utils/misc';
describe('makeSafeFilename', () => {
describe('Basic sanitization', () => {
it('should replace unsafe characters with underscore', () => {
expect(makeSafeFilename('file<name>.txt')).toBe('file_name_.txt');
expect(makeSafeFilename('file>name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file:name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file"name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file/name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file\\name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file|name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file?name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file*name.txt')).toBe('file_name.txt');
});
it('should replace multiple unsafe characters', () => {
expect(makeSafeFilename('file<>:"|?*.txt')).toBe('file_______.txt');
});
it('should use custom replacement character', () => {
expect(makeSafeFilename('file<name>.txt', '-')).toBe('file-name-.txt');
expect(makeSafeFilename('file:name.txt', '')).toBe('filename.txt');
});
it('should handle control characters', () => {
expect(makeSafeFilename('file\x00name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file\x1Fname.txt')).toBe('file_name.txt');
});
it('should trim whitespace from result', () => {
expect(makeSafeFilename(' filename.txt ')).toBe('filename.txt');
expect(makeSafeFilename('filename.txt ')).toBe('filename.txt');
expect(makeSafeFilename(' filename.txt')).toBe('filename.txt');
});
});
describe('Reserved filenames (Windows)', () => {
it('should handle reserved names case-insensitively', () => {
expect(makeSafeFilename('CON')).toBe('CON_');
expect(makeSafeFilename('con')).toBe('con_');
expect(makeSafeFilename('Con')).toBe('Con_');
expect(makeSafeFilename('PRN')).toBe('PRN_');
expect(makeSafeFilename('AUX')).toBe('AUX_');
expect(makeSafeFilename('NUL')).toBe('NUL_');
});
it('should handle reserved names with port numbers', () => {
expect(makeSafeFilename('COM1')).toBe('COM1_');
expect(makeSafeFilename('COM9')).toBe('COM9_');
expect(makeSafeFilename('LPT1')).toBe('LPT1_');
expect(makeSafeFilename('LPT9')).toBe('LPT9_');
});
it('should not affect reserved names with extensions', () => {
// Reserved names only apply to the base name without extension
const result = makeSafeFilename('CON.txt');
expect(result).toBe('CON.txt'); // This might be CON_.txt depending on implementation
});
it('should not affect similar but non-reserved names', () => {
expect(makeSafeFilename('CONFIG')).toBe('CONFIG');
expect(makeSafeFilename('CONSOLE')).toBe('CONSOLE');
expect(makeSafeFilename('PRINTER')).toBe('PRINTER');
});
});
describe('Multi-byte UTF-8 characters', () => {
it('should preserve single multi-byte characters', () => {
expect(makeSafeFilename('文件.txt')).toBe('文件.txt');
expect(makeSafeFilename('ファイル.txt')).toBe('ファイル.txt');
expect(makeSafeFilename('파일.txt')).toBe('파일.txt');
});
it('should preserve emoji characters', () => {
expect(makeSafeFilename('📚 Book.txt')).toBe('📚 Book.txt');
expect(makeSafeFilename('🎉🎊🎈.txt')).toBe('🎉🎊🎈.txt');
expect(makeSafeFilename('Test 😀.txt')).toBe('Test 😀.txt');
});
it('should handle mixed ASCII and multi-byte characters', () => {
expect(makeSafeFilename('Book-书籍-本.txt')).toBe('Book-书籍-本.txt');
expect(makeSafeFilename('Test_测试_テスト.txt')).toBe('Test_测试_テスト.txt');
});
it('should preserve complex emoji (with modifiers and ZWJ)', () => {
expect(makeSafeFilename('👨‍👩‍👧‍👦.txt')).toBe('👨‍👩‍👧‍👦.txt'); // Family emoji with ZWJ
expect(makeSafeFilename('👍🏽.txt')).toBe('👍🏽.txt'); // Thumbs up with skin tone modifier
});
it('should handle combining characters', () => {
expect(makeSafeFilename('café.txt')).toBe('café.txt'); // é is composed
expect(makeSafeFilename('naïve.txt')).toBe('naïve.txt');
});
});
describe('Byte length truncation (250 bytes max)', () => {
it('should not truncate short filenames', () => {
const shortName = 'short.txt';
expect(makeSafeFilename(shortName)).toBe(shortName);
});
it('should truncate long ASCII filenames', () => {
const longName = 'a'.repeat(260) + '.txt';
const result = makeSafeFilename(longName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
});
it('should truncate at exactly 250 bytes', () => {
const longName = 'x'.repeat(255); // More than 250 bytes
const result = makeSafeFilename(longName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBe(250);
});
it('should preserve valid UTF-8 when truncating multi-byte characters', () => {
// Chinese characters: each is 3 bytes in UTF-8
const chineseChars = '书'.repeat(100); // 300 bytes total
const result = makeSafeFilename(chineseChars);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
// Verify no broken UTF-8 by encoding and decoding
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain(''); // No replacement characters
});
it('should handle Japanese characters when truncating', () => {
// Japanese hiragana: each is 3 bytes in UTF-8
const japaneseChars = 'あ'.repeat(100); // 300 bytes
const result = makeSafeFilename(japaneseChars);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle emoji when truncating', () => {
// Most emoji are 4 bytes in UTF-8
const emojiString = '😀'.repeat(70); // 280 bytes
const result = makeSafeFilename(emojiString);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle mixed-width characters when truncating', () => {
// Mix of 1-byte (ASCII), 2-byte (Latin extended), 3-byte (CJK), 4-byte (emoji)
const mixedString = 'Test测试тест😀'.repeat(20); // Over 250 bytes
const result = makeSafeFilename(mixedString);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle Korean characters when truncating', () => {
// Korean characters: each is 3 bytes in UTF-8
const koreanChars = '가'.repeat(100); // 300 bytes
const result = makeSafeFilename(koreanChars);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
});
describe('Edge cases', () => {
it('should handle empty string', () => {
expect(makeSafeFilename('')).toBe('');
});
it('should handle string with only unsafe characters', () => {
expect(makeSafeFilename('<>:"|?*')).toBe('_______');
});
it('should handle string that becomes empty after sanitization and trimming', () => {
const result = makeSafeFilename(' ');
expect(result).toBe('');
});
it('should handle very long extension', () => {
const longExt = '.txt'.repeat(50);
const filename = 'file' + longExt;
const result = makeSafeFilename(filename);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
});
it('should handle filename with only whitespace', () => {
expect(makeSafeFilename(' ')).toBe('');
});
it('should handle complex real-world Chinese book title', () => {
const longTitle =
'这是一个非常长的中文书名用来测试文件名处理功能这个标题包含了很多汉字字符'.repeat(3) +
'.epub';
const result = makeSafeFilename(longTitle);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
// Verify UTF-8 validity
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle complex real-world Chinese book title', () => {
const longTitle =
'榎宮祐 - NO GAME NO LIFE 遊戲人生 02 遊戲玩家兄妹似乎盯上獸耳娘的國家了'.repeat(3);
const result = makeSafeFilename(longTitle);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
// Verify UTF-8 validity
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle complex real-world Chinese book titles with varying zero padding', () => {
const testCases = Array.from({ length: 31 }, (_, i) => {
const padding = '0'.repeat(i) + '2';
return `榎宮祐 - ${'NO GAME NO LIFE'.repeat(12)} 遊戲人生 ${padding} 遊戲玩家兄妹似乎盯上獸耳娘的國家了`;
});
for (const longTitle of testCases) {
const result = makeSafeFilename(longTitle);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
}
});
it('should handle right-to-left text (Arabic)', () => {
const arabicName = 'كتاب'.repeat(50) + '.pdf'; // Over 250 bytes
const result = makeSafeFilename(arabicName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
});
it('should handle Cyrillic characters', () => {
const cyrillicName = 'Книга'.repeat(60) + '.txt'; // Over 250 bytes
const result = makeSafeFilename(cyrillicName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
});
it('should handle Thai characters', () => {
const thaiName = 'หนังสือ'.repeat(50) + '.pdf'; // Over 250 bytes
const result = makeSafeFilename(thaiName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
});
});
describe('Combined sanitization and truncation', () => {
it('should sanitize and truncate in correct order', () => {
const unsafeLongName = '<'.repeat(260) + '.txt';
const result = makeSafeFilename(unsafeLongName);
// Should replace < with _, then truncate
expect(result).not.toContain('<');
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
});
it('should handle reserved name that needs truncation', () => {
// Edge case: reserved name with very long content
const longReservedLike = 'CON' + 'x'.repeat(260);
const result = makeSafeFilename(longReservedLike);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
});
it('should sanitize, handle reserved names, and truncate multi-byte characters', () => {
const complexName = 'CON:文件'.repeat(50) + '😀'.repeat(20);
const result = makeSafeFilename(complexName);
// Should not contain unsafe characters
expect(result).not.toContain(':');
// Should be within byte limit
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
// Should be valid UTF-8
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
});
describe('Real-world scenarios', () => {
it('should handle typical book title with author', () => {
const bookTitle = 'The Great Gatsby - F. Scott Fitzgerald.epub';
expect(makeSafeFilename(bookTitle)).toBe('The Great Gatsby - F. Scott Fitzgerald.epub');
});
it('should handle Chinese book with long title', () => {
const chineseBook = '红楼梦:中国古典文学四大名著之一(全120回完整版).epub';
const result = makeSafeFilename(chineseBook);
expect(result).toBe(chineseBook); // Should fit within 250 bytes
});
it('should handle Japanese light novel title', () => {
const japaneseTitle = 'ソードアート・オンライン:アリシゼーション編.epub';
expect(makeSafeFilename(japaneseTitle)).toBe(japaneseTitle);
});
it('should handle filename with unsafe characters and emoji', () => {
const unsafeEmoji = '📚 Book: "Title" <Part 1>.epub';
const result = makeSafeFilename(unsafeEmoji);
expect(result).toBe('📚 Book_ _Title_ _Part 1_.epub');
});
it('should handle Windows-style path in filename', () => {
const windowsPath = 'C:\\Users\\Documents\\book.pdf';
const result = makeSafeFilename(windowsPath);
expect(result).toBe('C__Users_Documents_book.pdf');
});
it('should handle URL in filename', () => {
const url = 'https://example.com/book.pdf';
const result = makeSafeFilename(url);
expect(result).toBe('https___example.com_book.pdf');
});
});
});
@@ -103,6 +103,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
return new NextResponse(null, {
status: 200,
headers: {
...Object.fromEntries(response.headers.entries()),
'Content-Type': contentType,
'Content-Length': contentLength || '',
'Cache-Control': 'public, max-age=300',
@@ -119,6 +120,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
return new NextResponse(response.body, {
status: 200,
headers: {
...Object.fromEntries(response.headers.entries()),
'Content-Type': contentType,
'X-Content-Length': contentLength || '',
'Cache-Control': 'public, max-age=300',
@@ -136,6 +138,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
return new NextResponse(buf, {
status: 200,
headers: {
...Object.fromEntries(response.headers.entries()),
'Content-Type': contentType,
'Content-Length': length.toString(),
'Cache-Control': 'public, max-age=300',
+12 -7
View File
@@ -6,8 +6,7 @@ import { useRouter } from 'next/navigation';
import { Auth } from '@supabase/auth-ui-react';
import { ThemeSupa } from '@supabase/auth-ui-shared';
import { FcGoogle } from 'react-icons/fc';
import { FaApple } from 'react-icons/fa';
import { FaGithub } from 'react-icons/fa';
import { FaApple, FaGithub, FaDiscord } from 'react-icons/fa';
import { IoArrowBack } from 'react-icons/io5';
import { useAuth } from '@/context/AuthContext';
@@ -29,7 +28,7 @@ import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
import WindowButtons from '@/components/WindowButtons';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github' | 'discord';
interface SingleInstancePayload {
args: string[];
@@ -390,7 +389,7 @@ export default function AuthPage() {
provider='google'
handleSignIn={tauriSignIn}
Icon={FcGoogle}
label={_('Sign in with Google')}
label={_('Sign in with {{provider}}', { provider: 'Google' })}
/>
<ProviderLogin
provider='apple'
@@ -398,13 +397,19 @@ export default function AuthPage() {
appService?.isIOSApp || USE_APPLE_SIGN_IN ? tauriSignInApple : tauriSignIn
}
Icon={FaApple}
label={_('Sign in with Apple')}
label={_('Sign in with {{provider}}', { provider: 'Apple' })}
/>
<ProviderLogin
provider='github'
handleSignIn={tauriSignIn}
Icon={FaGithub}
label={_('Sign in with GitHub')}
label={_('Sign in with {{provider}}', { provider: 'GitHub' })}
/>
<ProviderLogin
provider='discord'
handleSignIn={tauriSignIn}
Icon={FaDiscord}
label={_('Sign in with {{provider}}', { provider: 'Discord' })}
/>
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
<div className='w-full'>
@@ -434,7 +439,7 @@ export default function AuthPage() {
appearance={{ theme: ThemeSupa }}
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={['google', 'apple', 'github']}
providers={['google', 'apple', 'github', 'discord']}
redirectTo={getWebRedirectTo()}
localization={getAuthLocalization()}
/>
@@ -14,7 +14,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { navigateToLogin } from '@/utils/nav';
import { formatAuthors } from '@/utils/book';
import { formatAuthors, formatDescription } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from '@/components/BookCover';
@@ -91,7 +91,7 @@ const BookItem: React.FC<BookItemProps> = ({
className={clsx(
'flex w-full flex-col p-0',
mode === 'grid' && 'pt-2',
mode === 'list' && 'py-2',
mode === 'list' && 'gap-2 py-0',
)}
>
<div className={clsx('min-w-0 flex-1', mode === 'list' && 'flex flex-col gap-2')}>
@@ -110,6 +110,11 @@ const BookItem: React.FC<BookItemProps> = ({
</p>
)}
</div>
{mode === 'list' && (
<h4 className='text-neutral-content line-clamp-1 text-sm'>
{formatDescription(book.metadata?.description)}
</h4>
)}
<div
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
style={{
@@ -10,7 +10,6 @@ import { useThemeStore } from '@/store/themeStore';
import { useAutoFocus } from '@/hooks/useAutoFocus';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTransferStore } from '@/store/transferStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
@@ -24,7 +23,6 @@ import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import SelectModeActions from './SelectModeActions';
import GroupingModal from './GroupingModal';
import TransferQueuePanel from './TransferQueuePanel';
interface BookshelfProps {
libraryBooks: Book[];
@@ -79,6 +77,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const abortDeletionRef = useRef(false);
const isImportingBook = useRef(false);
const iconSize15 = useResponsiveSize(15);
const autofocusRef = useAutoFocus<HTMLDivElement>();
@@ -87,8 +86,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const { getGroupName } = useLibraryStore();
const { isTransferQueueOpen } = useTransferStore();
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
const updateUrlParams = useCallback(
@@ -223,9 +220,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const confirmDelete = async () => {
const books = getBooksToDelete();
const concurrency = 4;
const concurrency = 20;
for (let i = 0; i < books.length; i += concurrency) {
if (abortDeletionRef.current) {
abortDeletionRef.current = false;
break;
}
const batch = books.slice(i, i + concurrency);
await Promise.all(batch.map((book) => handleBookDelete(book, false)));
}
@@ -383,11 +384,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
/>
</ModalPortal>
)}
{isTransferQueueOpen && (
<ModalPortal>
<TransferQueuePanel />
</ModalPortal>
)}
{showDeleteAlert && (
<div
className={clsx('delete-alert fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
@@ -401,6 +397,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
count: getBooksToDelete().length,
})}
onCancel={() => {
abortDeletionRef.current = true;
setShowDeleteAlert(false);
setShowSelectModeActions(true);
}}
@@ -52,7 +52,7 @@ const formatDateTime = (timestamp: number): string => {
const StatusIcon: React.FC<{
status: TransferStatus;
type: 'upload' | 'download';
type: 'upload' | 'download' | 'delete';
size: number;
}> = ({ status, type, size }) => {
switch (status) {
@@ -67,6 +67,8 @@ const StatusIcon: React.FC<{
default:
return type === 'upload' ? (
<MdCloudUpload className='text-primary' size={size} />
) : type === 'delete' ? (
<MdDeleteSweep className='text-primary' size={size} />
) : (
<MdCloudDownload className='text-primary' size={size} />
);
@@ -81,6 +83,12 @@ const TransferItemRow: React.FC<{
}> = ({ transfer, onCancel, onRetry, iconSize }) => {
const _ = useTranslation();
const completedLabel = {
upload: _('Uploaded'),
download: _('Downloaded'),
delete: _('Deleted'),
};
return (
<div className='hover:bg-base-200 flex items-center gap-3 rounded-lg p-3'>
<StatusIcon status={transfer.status} type={transfer.type} size={iconSize} />
@@ -100,7 +108,7 @@ const TransferItemRow: React.FC<{
{transfer.status === 'failed' && (
<span className='text-error'>{transfer.error || _('Failed')}</span>
)}
{transfer.status === 'completed' && _('Completed')}
{transfer.status === 'completed' && (completedLabel[transfer.type] || _('Completed'))}
{transfer.status === 'cancelled' && _('Cancelled')}
{' · '}
{formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
@@ -167,9 +175,7 @@ const TransferQueuePanel: React.FC = () => {
const onClose = () => setIsOpen(false);
const divRef = useKeyDownActions({ onCancel: onClose, onConfirm: onClose });
const booksToUpload = getVisibleLibrary().filter(
(book) => book.downloadedAt && !book.uploadedAt,
);
const booksToUpload = getVisibleLibrary().filter((book) => book.downloadedAt && !book.uploadedAt);
const booksToDownload = getVisibleLibrary().filter(
(book) => book.uploadedAt && !book.downloadedAt,
);
@@ -44,6 +44,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
{ label: _('Format'), value: 'format' },
{ label: _('Date Read'), value: 'updated' },
{ label: _('Date Added'), value: 'created' },
{ label: _('Date Published'), value: 'published' },
];
const sortingOptions = [
@@ -11,7 +11,7 @@ export const useBooksSync = () => {
const { user } = useAuth();
const { appService } = useEnv();
const { library, isSyncing, setLibrary, setIsSyncing, setSyncProgress } = useLibraryStore();
const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const { useSyncInited, syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const isPullingRef = useRef(false);
const getNewBooks = useCallback(() => {
@@ -64,8 +64,7 @@ export const useBooksSync = () => {
return;
}
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [library, handleAutoSync]);
}, [user, library, handleAutoSync]);
const pushLibrary = useCallback(async () => {
if (!user) return;
@@ -76,9 +75,9 @@ export const useBooksSync = () => {
}, [user, syncBooks, getNewBooks]);
useEffect(() => {
if (!user) return;
if (!user || !useSyncInited) return;
pullLibrary();
}, [user, pullLibrary]);
}, [user, useSyncInited, pullLibrary]);
const updateLibrary = async () => {
if (isSyncing) return;
@@ -107,13 +106,16 @@ export const useBooksSync = () => {
return oldBook;
};
const oldBooksBatchSize = 20;
const oldBooksBatchSize = 100;
for (let i = 0; i < oldBooksNeedsDownload.length; i += oldBooksBatchSize) {
const batch = oldBooksNeedsDownload.slice(i, i + oldBooksBatchSize);
await appService?.downloadBookCovers(batch);
}
const updatedLibrary = await Promise.all(library.map(processOldBook));
setLibrary(updatedLibrary);
appService?.saveLibraryBooks(updatedLibrary);
const bookHashesInLibrary = new Set(updatedLibrary.map((book) => book.hash));
const newBooks = syncedBooks.filter(
(newBook) =>
@@ -140,7 +142,8 @@ export const useBooksSync = () => {
setLibrary([...updatedLibrary]);
appService?.saveLibraryBooks(updatedLibrary);
}
setLibrary(updatedLibrary);
} catch (err) {
console.error('Error updating new books:', err);
} finally {
if (newBooks.length > 0) {
setIsSyncing(false);
+46 -18
View File
@@ -36,6 +36,7 @@ import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTransferStore } from '@/store/transferStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
@@ -65,6 +66,8 @@ import Bookshelf from './components/Bookshelf';
import useShortcuts from '@/hooks/useShortcuts';
import DropIndicator from '@/components/DropIndicator';
import SettingsDialog from '@/components/settings/SettingsDialog';
import ModalPortal from '@/components/ModalPortal';
import TransferQueuePanel from './components/TransferQueuePanel';
const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams();
@@ -80,6 +83,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isSyncing,
syncProgress,
updateBook,
updateBooks,
setLibrary,
getGroupId,
getGroupName,
@@ -95,6 +99,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { clearBookData } = useBookDataStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const { isTransferQueueOpen } = useTransferStore();
const [showCatalogManager, setShowCatalogManager] = useState(
searchParams?.get('opds') === 'true',
);
@@ -401,30 +406,29 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
['Unsupported or corrupted book file', _('The book file is corrupted')],
];
const processFile = async (selectedFile: SelectedFile) => {
const processFile = async (selectedFile: SelectedFile): Promise<Book | null> => {
const file = selectedFile.file || selectedFile.path;
if (!file) return;
if (!file) return null;
try {
const book = await appService?.importBook(file, library);
if (!book) return null;
const { path, basePath } = selectedFile;
if (book && groupId) {
if (groupId) {
book.groupId = groupId;
book.groupName = getGroupName(groupId);
await updateBook(envConfig, book);
} else if (book && path && basePath) {
} else if (path && basePath) {
const rootPath = getDirPath(basePath);
const groupName = getDirPath(path).replace(rootPath, '').replace(/^\//, '');
book.groupName = groupName;
book.groupId = getGroupId(groupName);
await updateBook(envConfig, book);
}
if (user && book && !book.uploadedAt && settings.autoUpload) {
if (user && !book.uploadedAt && settings.autoUpload) {
console.log('Queueing upload for book:', book.title);
transferManager.queueUpload(book);
}
if (book) {
successfulImports.push(book.title);
}
successfulImports.push(book.title);
return book;
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getFilename(filename);
@@ -434,14 +438,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
: '';
failedImports.push({ filename: baseFilename, errorMessage });
console.error('Failed to import book:', filename, error);
return null;
}
};
const concurrency = 4;
for (let i = 0; i < files.length; i += concurrency) {
const batch = files.slice(i, i + concurrency);
await Promise.all(batch.map(processFile));
const importedBooks = (await Promise.all(batch.map(processFile))).filter((book) => !!book);
await updateBooks(envConfig, importedBooks);
}
pushLibrary();
if (failedImports.length > 0) {
@@ -466,8 +473,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
}
setLibrary([...library]);
appService?.saveLibraryBooks(library);
setLoading(false);
};
@@ -558,14 +563,32 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
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);
clearBookData(book.hash);
if (syncBooks) pushLibrary();
// Handle local deletion immediately
if (deleteAction === 'local' || deleteAction === 'both') {
await appService?.deleteBook(book, 'local');
if (deleteAction === 'both') {
book.deletedAt = Date.now();
book.downloadedAt = null;
book.coverDownloadedAt = null;
}
await updateBook(envConfig, book);
clearBookData(book.hash);
if (syncBooks) pushLibrary();
}
// Queue cloud deletion
if (deleteAction === 'cloud' || deleteAction === 'both') {
const transferId = transferManager.queueDelete(book, 1, true);
if (!transferId) {
throw new Error('Failed to queue cloud deletion');
}
}
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
timeout: 1000,
message: deletionMessages[deleteAction],
});
return true;
@@ -849,6 +872,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleBookMetadataUpdate={handleUpdateMetadata}
/>
)}
{isTransferQueueOpen && (
<ModalPortal>
<TransferQueuePanel />
</ModalPortal>
)}
<AboutWindow />
<UpdaterWindow />
<MigrateDataWindow />
@@ -47,6 +47,27 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
case 'published':
const aPublished = a.metadata?.published || '0001-01-01';
const bPublished = b.metadata?.published || '0001-01-01';
// Handle cases where published date might not exist
if (!aPublished && !bPublished) return 0;
if (!aPublished) return 1; // Books without published date go to the end
if (!bPublished) return -1;
// Try to parse dates - handle various date formats
const aDate = new Date(aPublished).getTime();
const bDate = new Date(bPublished).getTime();
// If dates are invalid (NaN), fall back to string comparison
if (isNaN(aDate) && isNaN(bDate)) {
return aPublished.localeCompare(bPublished, uiLanguage || navigator.language);
}
if (isNaN(aDate)) return 1;
if (isNaN(bDate)) return -1;
return aDate - bDate;
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
+53 -38
View File
@@ -29,7 +29,13 @@ import {
parseMediaType,
resolveURL,
} from './utils/opdsUtils';
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
import {
getProxiedURL,
fetchWithAuth,
probeAuth,
needsProxy,
probeFilename,
} from './utils/opdsReq';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { FeedView } from './components/FeedView';
import { PublicationView } from './components/PublicationView';
@@ -414,49 +420,58 @@ export default function BrowserPage() {
}
return;
} else {
const username = usernameRef.current || '';
const password = passwordRef.current || '';
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: '*/*',
};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
if (authHeader) {
headers['Authorization'] = authHeader;
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
}
}
const pathname = decodeURIComponent(new URL(url).pathname);
const ext = getFileExtFromMimeType(parsed?.mediaType) || getFileExtFromPath(pathname);
const basename = pathname.replaceAll('/', '_');
const filename = ext ? `${basename}.${ext}` : basename;
const dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
if (dstFilePath) {
const username = usernameRef.current || '';
const password = passwordRef.current || '';
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: '*/*',
};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
if (authHeader) {
headers['Authorization'] = authHeader;
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
}
}
let dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
console.log('Downloading to:', url, dstFilePath);
await downloadFile({
appService,
dst: dstFilePath,
cfp: '',
url: downloadUrl,
headers,
singleThreaded: true,
skipSslVerification: true,
onProgress,
});
const { library, setLibrary } = useLibraryStore.getState();
const book = await appService.importBook(dstFilePath, library);
if (user && book && !book.uploadedAt && settings.autoUpload) {
setTimeout(() => {
transferManager.queueUpload(book);
}, 3000);
}
setLibrary(library);
appService.saveLibraryBooks(library);
return book;
const responseHeaders = await downloadFile({
appService,
dst: dstFilePath,
cfp: '',
url: downloadUrl,
headers,
singleThreaded: true,
skipSslVerification: true,
onProgress,
});
const probedFilename = await probeFilename(responseHeaders);
if (probedFilename) {
const newFilePath = await appService?.resolveFilePath(probedFilename, 'Cache');
await appService?.copyFile(dstFilePath, newFilePath, 'None');
await appService?.deleteFile(dstFilePath, 'None');
console.log('Renamed downloaded file to:', newFilePath);
dstFilePath = newFilePath;
}
const { library, setLibrary } = useLibraryStore.getState();
const book = await appService.importBook(dstFilePath, library);
if (user && book && !book.uploadedAt && settings.autoUpload) {
setTimeout(() => {
transferManager.queueUpload(book);
}, 3000);
}
setLibrary(library);
appService.saveLibraryBooks(library);
return book;
}
} catch (e) {
console.error('Download error:', e);
@@ -245,6 +245,25 @@ export const probeAuth = async (
return createBasicAuth(finalUsername, finalPassword);
};
export const probeFilename = async (headers: Record<string, string>) => {
const contentDisposition = headers['content-disposition'];
if (contentDisposition) {
const extendedMatch = contentDisposition.match(
/filename\*\s*=\s*(?:utf-8|UTF-8)'[^']*'([^;\s]+)/i,
);
if (extendedMatch?.[1]) {
return decodeURIComponent(extendedMatch[1]);
}
const plainMatch = contentDisposition.match(/filename\s*=\s*["']?([^"';\s]+)["']?/i);
if (plainMatch?.[1]) {
return decodeURIComponent(plainMatch[1]);
}
}
return '';
};
/**
* Perform authenticated HTTP request with retry logic for Digest/Basic auth
*/
@@ -53,6 +53,7 @@ import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { useBookCoverAutoSave } from '../hooks/useAutoSaveBookCover';
import { useDiscordPresence } from '@/hooks/useDiscordPresence';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import { removeTabIndex } from '@/utils/a11y';
@@ -85,6 +86,7 @@ const FoliateViewer: React.FC<{
const { getBookData } = useBookDataStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
const bookData = getBookData(bookKey);
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
@@ -98,6 +100,12 @@ const FoliateViewer: React.FC<{
useAutoFocus<HTMLDivElement>({ ref: containerRef });
useDiscordPresence(
bookData?.book || null,
!!viewState?.isPrimary,
settings.discordRichPresenceEnabled,
);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
@@ -188,7 +196,9 @@ const FoliateViewer: React.FC<{
setViewSettings(bookKey, { ...viewSettings });
}
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
if (!bookData?.isFixedLayout) {
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
}
getLoadedFonts().forEach((font) => {
mountCustomFont(detail.doc, font);
@@ -115,15 +115,17 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progressInfoMode]);
const isMobile = appService?.isMobile || window.innerWidth < 640;
return (
<div
role='presentation'
className={clsx(
'progressinfo absolute flex items-center justify-between font-sans',
'pointer-events-auto bottom-0',
'progressinfo absolute bottom-0 flex items-center justify-between font-sans',
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
isMobile ? 'pointer-events-auto' : 'pointer-events-none',
)}
onClick={() => cycleProgressInfoModes()}
aria-label={[
@@ -86,14 +86,14 @@ const RuleItem: React.FC<{
}
return (
<div className='flex items-start justify-between gap-3 p-3'>
<div className='relative flex items-start justify-between gap-3 p-3'>
<div className='flex min-w-0 flex-1 flex-col gap-1.5'>
<div className='break-words text-base font-medium leading-snug'>{rule.pattern}</div>
<div className='break-words pe-20 text-base font-medium leading-snug'>{rule.pattern}</div>
<div className='text-base-content/70 break-words text-sm'>
<span className='text-base-content/80 mr-1.5 text-xs font-medium'>
{_('Replace with:')}
</span>
<span className='text-base-content/90'>{rule.replacement}</span>
<span className='text-base-content/90 text-xs'>{"'" + rule.replacement + "'"}</span>
</div>
<div className='text-base-content/60 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs'>
<span className='inline-flex items-center gap-1'>
@@ -116,9 +116,16 @@ const RuleItem: React.FC<{
{rule.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</span>
<span className='text-base-content/30'></span>
<span className='inline-flex items-center gap-1'>
<span className='text-base-content/50'>{_('Only for TTS:')}</span>
<span className='text-base-content/70 font-medium'>
{rule.onlyForTTS === true ? _('Yes') : _('No')}
</span>
</span>
</div>
</div>
<div className='flex shrink-0 items-center gap-1'>
<div className='absolute right-2 top-2 flex items-center gap-1'>
<button
className='btn btn-ghost btn-sm h-8 w-8 p-0'
onClick={onEdit}
@@ -187,7 +194,8 @@ export const ProofreadRulesManager: React.FC = () => {
pattern: string;
replacement: string;
enabled: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
onlyForTTS: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true, onlyForTTS: false });
const { singleRules, bookRules } = useReplacementRules(sideBarBookKey);
@@ -199,18 +207,26 @@ export const ProofreadRulesManager: React.FC = () => {
el?.removeEventListener('setProofreadRulesVisibility', handleVisibility as EventListener);
}, []);
const startEdit = (rule: ProofreadRule, scope: ProofreadScope) => {
const startEdit = (rule: ProofreadRule) => {
setEditing({
id: rule.id,
scope,
scope: rule.scope,
pattern: rule.pattern,
replacement: rule.replacement,
enabled: !!rule.enabled,
onlyForTTS: !!rule.onlyForTTS,
});
};
const cancelEdit = () => {
setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
setEditing({
id: null,
scope: null,
pattern: '',
replacement: '',
enabled: true,
onlyForTTS: false,
});
};
const saveEdit = async () => {
@@ -221,16 +237,22 @@ export const ProofreadRulesManager: React.FC = () => {
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
onlyForTTS: editing.onlyForTTS,
});
cancelEdit();
recreateViewer(envConfig, sideBarBookKey);
if (!editing.onlyForTTS) {
recreateViewer(envConfig, sideBarBookKey);
}
};
const deleteRule = async (ruleId: string, scope: ProofreadScope) => {
const deleteRule = async (rule: ProofreadRule) => {
if (!sideBarBookKey) return;
await removeRule(envConfig, sideBarBookKey, ruleId, scope);
recreateViewer(envConfig, sideBarBookKey);
await removeRule(envConfig, sideBarBookKey, rule.id, rule.scope);
if (!rule.onlyForTTS) {
recreateViewer(envConfig, sideBarBookKey);
}
};
const renderRuleList = (
@@ -260,10 +282,8 @@ export const ProofreadRulesManager: React.FC = () => {
editing.scope === (scopeType === 'selection' ? 'selection' : rule.scope)
}
editingData={editing}
onEdit={() => startEdit(rule, scopeType === 'selection' ? 'selection' : rule.scope)}
onDelete={() =>
deleteRule(rule.id, scopeType === 'selection' ? 'selection' : rule.scope)
}
onEdit={() => startEdit(rule)}
onDelete={() => deleteRule(rule)}
onSave={saveEdit}
onCancel={cancelEdit}
onEditChange={(_, value) => setEditing({ ...editing, replacement: value })}
@@ -61,8 +61,10 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { showSystemUI, dismissSystemUI } = useThemeStore();
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isSideBarVisible, isSideBarPinned } = useSidebarStore();
const { getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, isNotebookPinned } = useNotebookStore();
const { getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore();
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
@@ -101,9 +103,9 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const handleKeyDown = (event: CustomEvent) => {
if (event.detail.keyName === 'Back') {
if (getIsSideBarVisible()) {
if (getIsSideBarVisible() && !isSideBarPinned) {
setSideBarVisible(false);
} else if (getIsNotebookVisible()) {
} else if (getIsNotebookVisible() && !isNotebookPinned) {
setNotebookVisible(false);
} else {
eventDispatcher.dispatch('close-reader');
@@ -133,7 +135,14 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.isAndroidApp, sideBarBookKey, isSideBarVisible, isNotebookVisible]);
}, [
appService?.isAndroidApp,
sideBarBookKey,
isSideBarPinned,
isSideBarVisible,
isNotebookPinned,
isNotebookVisible,
]);
useEffect(() => {
if (!appService?.isMobileApp) return;
@@ -20,6 +20,7 @@ import { uniqueId } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { eventDispatcher } from '@/utils/event';
import { navigateToLibrary } from '@/utils/nav';
import { clearDiscordPresence } from '@/utils/discord';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
import { BookDetailModal } from '@/components/metadata';
@@ -97,8 +98,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
useEffect(() => {
if (bookKeys && bookKeys.length > 0) {
const settings = useSettingsStore.getState().settings;
settings.lastOpenBooks = bookKeys.map((key) => key.split('-')[0]!);
saveSettings(envConfig, settings);
const lastOpenBooks = bookKeys.map((key) => key.split('-')[0]!);
if (settings.lastOpenBooks?.toString() !== lastOpenBooks.toString()) {
settings.lastOpenBooks = lastOpenBooks;
saveSettings(envConfig, settings);
}
}
let unlistenOnCloseWindow: Promise<UnlistenFn>;
@@ -133,6 +137,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const saveConfigAndCloseBook = async (bookKey: string) => {
console.log('Closing book', bookKey);
const viewState = getViewState(bookKey);
if (viewState?.isPrimary && appService?.isDesktopApp) {
await clearDiscordPresence(appService);
}
try {
getView(bookKey)?.close();
getView(bookKey)?.remove();
@@ -38,7 +38,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
const { user } = useAuth();
const { envConfig, appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { setSettingsDialogOpen } = useSettingsStore();
const { setSettingsDialogOpen, setSettingsDialogBookKey } = useSettingsStore();
const { getView, getViewSettings, getViewState, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
@@ -62,6 +62,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
const openFontLayoutMenu = () => {
setIsDropdownOpen?.(false);
setSettingsDialogBookKey(bookKey);
setSettingsDialogOpen(true);
};
@@ -258,7 +259,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
shortcut='Shift+J'
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
disabled={bookData.bookDoc?.rendition?.layout === 'pre-paginated'}
disabled={bookData.isFixedLayout}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
@@ -222,7 +222,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
}
return (
<div className='pointer-events-none fixed inset-0 z-40'>
<div className='pointer-events-none fixed inset-0 z-50'>
<Handle
position={currentStart}
isVertical={isVertical}
@@ -7,7 +7,7 @@ import { useEnv } from '@/context/EnvContext';
import { BookNote, BooknoteGroup, HighlightColor, HighlightStyle } from '@/types/book';
import { NOTE_PREFIX } from '@/types/view';
import { NativeTouchEventType } from '@/types/system';
import { getLocale, getOSPlatform, uniqueId } from '@/utils/misc';
import { getLocale, getOSPlatform, makeSafeFilename, uniqueId } from '@/utils/misc';
import { useThemeStore } from '@/store/themeStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
@@ -38,6 +38,7 @@ import WikipediaPopup from './WikipediaPopup';
import TranslatorPopup from './TranslatorPopup';
import useShortcuts from '@/hooks/useShortcuts';
import ProofreadPopup from './ProofreadPopup';
import ExportMarkdownDialog from './ExportMarkdownDialog';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -76,6 +77,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [showAnnotationNotes, setShowAnnotationNotes] = useState(false);
const [annotationNotes, setAnnotationNotes] = useState<BookNote[]>([]);
const [editingAnnotation, setEditingAnnotation] = useState<BookNote | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [exportData, setExportData] = useState<{
booknotes: BookNote[];
booknoteGroups: { [href: string]: BooknoteGroup };
} | null>(null);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
settings.globalReadSettings.highlightStyle,
@@ -260,7 +266,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
detail.doc?.addEventListener('pointermove', handlePointerMove.bind(null, doc, index), opts);
detail.doc?.addEventListener('pointercancel', handlePointerCancel.bind(null, doc, index));
detail.doc?.addEventListener('pointerup', handlePointerUp.bind(null, doc, index));
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc));
detail.doc?.addEventListener('selectionchange', handleSelectionchange.bind(null, doc, index));
// For PDF selections, enable right-click context menu to directly open translator popup.
if (bookData.book?.format === 'PDF') {
@@ -414,12 +420,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleDismissPopupAndSelection();
break;
case 'highlight':
// Delay to ensure highlight is applied after selection is set
// so that onShowAnnotation won't be triggered immediately
setTimeout(() => {
handleHighlight();
handleDismissPopupAndSelection();
}, 0);
// highlight is already applied in instant annotating
handleDismissPopupAndSelection();
break;
case 'search':
handleSearch();
@@ -493,7 +495,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection?.cfi, bookKey]);
}, [selection, bookKey]);
useEffect(() => {
if (!progress) return;
@@ -746,7 +748,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
[selection?.text],
);
const handleExportMarkdown = (event: CustomEvent) => {
const handleExportMarkdown = async (event: CustomEvent) => {
const { bookKey: exportBookKey } = event.detail;
if (bookKey !== exportBookKey) return;
@@ -765,6 +767,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
});
return;
}
// Organize booknotes into groups by chapter
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
for (const booknote of booknotes) {
const tocItem = findTocItemBS(bookDoc.toc ?? [], booknote.cfi);
@@ -783,52 +787,34 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
});
});
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => {
return a.id - b.id;
});
setExportData({ booknotes, booknoteGroups });
setShowExportDialog(true);
};
const lines: string[] = [];
lines.push(`# ${book.title}`);
lines.push(`**${_('Author')}**: ${book.author || ''}`);
lines.push('');
lines.push(`**${_('Exported from Readest')}**: ${new Date().toISOString().slice(0, 10)}`);
lines.push('');
lines.push('---');
lines.push('');
lines.push(`## ${_('Highlights & Annotations')}`);
lines.push('');
const handleConfirmExport = async (markdownContent: string) => {
const { book } = bookData;
if (!book) return;
for (const group of sortedGroups) {
const chapterTitle = group.label || _('Untitled');
lines.push(`### ${chapterTitle}`);
for (const note of group.booknotes) {
lines.push(`> "${note.text}"`);
if (note.note) {
lines.push(`**${_('Note')}**:: ${note.note}`);
}
lines.push('');
}
lines.push('---');
lines.push('');
}
setTimeout(() => {
// Delay to ensure it won't be overridden by system clipboard actions
navigator.clipboard?.writeText(markdownContent);
}, 100);
const markdownContent = lines.join('\n');
navigator.clipboard?.writeText(markdownContent);
const filename = `${makeSafeFilename(book.title)}.md`;
const saved = await appService?.saveFile(filename, markdownContent, 'text/markdown');
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to clipboard'),
className: 'whitespace-nowrap',
message: saved ? _('Exported successfully') : _('Copied to clipboard'),
timeout: 2000,
});
if (appService?.isMobile) return;
const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${book.title.replace(/\s+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
setShowExportDialog(false);
setExportData(null);
};
const handleCancelExport = () => {
setShowExportDialog(false);
setExportData(null);
};
const selectionAnnotated = selection?.annotated;
@@ -957,6 +943,18 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onStartEdit={handleStartEditAnnotation}
/>
)}
{showExportDialog && exportData && bookData.book && (
<ExportMarkdownDialog
bookKey={bookKey}
isOpen={showExportDialog}
bookTitle={bookData.book.title}
bookAuthor={bookData.book.author || ''}
booknotes={exportData.booknotes}
booknoteGroups={exportData.booknoteGroups}
onCancel={handleCancelExport}
onExport={handleConfirmExport}
/>
)}
</div>
);
};
@@ -0,0 +1,636 @@
import clsx from 'clsx';
import React, { useState, useMemo, useEffect } from 'react';
import { marked } from 'marked';
import { BookNote, BooknoteGroup, NoteExportConfig } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import Dialog from '@/components/Dialog';
import { useReaderStore } from '@/store/readerStore';
import { DEFAULT_NOTE_EXPORT_CONFIG } from '@/services/constants';
import { saveViewSettings } from '@/helpers/settings';
import { useEnv } from '@/context/EnvContext';
interface ExportMarkdownDialogProps {
bookKey: string;
isOpen: boolean;
bookTitle: string;
bookAuthor: string;
booknotes: BookNote[];
booknoteGroups: { [href: string]: BooknoteGroup };
onCancel: () => void;
onExport: (markdown: string) => void;
}
type TemplateData = {
title: string;
author: string;
exportDate: string;
chapters: {
title: string;
annotations: {
text: string;
note?: string;
timestamp?: number;
}[];
}[];
};
type TemplateVariable = { [key: string]: number | string | TemplateVariable | TemplateVariable[] };
const renderTemplate = (template: string, data: TemplateData): string => {
const applyFilter = (
value: TemplateVariable,
filter: string,
filterArg?: string,
): TemplateVariable | string => {
if (filter === 'date') {
if (typeof value === 'number') {
const date = new Date(value);
if (filterArg) {
const format = filterArg;
return format
.replace('%Y', date.getFullYear().toString())
.replace('%m', String(date.getMonth() + 1).padStart(2, '0'))
.replace('%d', String(date.getDate()).padStart(2, '0'))
.replace('%H', String(date.getHours()).padStart(2, '0'))
.replace('%M', String(date.getMinutes()).padStart(2, '0'))
.replace('%S', String(date.getSeconds()).padStart(2, '0'));
}
return date.toLocaleString();
}
return value;
}
return value;
};
const getValue = (path: string, context: TemplateData) => {
const keys = path.trim().split('.');
let value = context as TemplateVariable;
for (const key of keys) {
value = value?.[key] as TemplateVariable;
}
return value;
};
let result = template;
// Process {% for %} loops - find first occurrence and recurse
const forMatch = result.match(/\{%\s*for\s+(\w+)\s+in\s+(\w+(?:\.\w+)*)\s*%\}/);
if (forMatch) {
const itemName = forMatch[1]!;
const collectionPath = forMatch[2]!;
// Find matching {% endfor %} by counting nesting
let depth = 0;
let endPos = -1;
const startPos = forMatch.index! + forMatch[0].length;
for (let i = startPos; i < result.length; i++) {
const remaining = result.slice(i);
if (remaining.startsWith('{% for ')) {
depth++;
i += 6; // skip past "{% for"
} else if (remaining.startsWith('{% endfor %}')) {
if (depth === 0) {
endPos = i;
break;
}
depth--;
i += 11; // skip past "{% endfor %}"
}
}
if (endPos !== -1) {
const beforeLoop = result.slice(0, forMatch.index);
const loopContent = result.slice(startPos, endPos);
const afterLoop = result.slice(endPos + 13); // 13 = length of "{% endfor %}"
const collection = getValue(collectionPath, data);
if (Array.isArray(collection)) {
const renderedItems = collection
.map((item) => {
const loopData = { ...data, [itemName]: item };
return renderTemplate(loopContent, loopData);
})
.join('');
result = beforeLoop + renderedItems + afterLoop;
return renderTemplate(result, data);
} else {
// Collection not found or not an array, remove the loop
result = beforeLoop + afterLoop;
return renderTemplate(result, data);
}
}
}
// Process {% if condition %}...{% endif %}
result = result.replace(
/\{%\s*if\s+([^%]+?)\s*%\}([\s\S]*?)\{%\s*endif\s*%\}/g,
(_match, condition, content) => {
const value = getValue(condition, data);
return value ? renderTemplate(content, data) : '';
},
);
// Replace {{ variable }}, {{ variable | filter }}, and {{ variable | filter('arg') }}
result = result.replace(
/\{\{\s*([^}|]+?)(?:\s*\|\s*(\w+)(?:\s*\((['"]?)([^)'"]*)\3\))?)?\s*\}\}/g,
(_match, path, filter, _quote, filterArg) => {
const value = getValue(path, data);
if (value === undefined || value === null) return '';
if (filter) {
return applyFilter(value, filter, filterArg) as string;
}
return String(value);
},
);
return result;
};
const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
bookKey,
isOpen,
bookTitle,
bookAuthor,
booknotes,
booknoteGroups,
onCancel,
onExport,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const defaultTemplate = `## {{ title }}
**${_('Author')}**: {{ author }}
**${_('Exported from Readest')}**: {{ exportDate }}
---
### ${_('Highlights & Annotations')}
{% for chapter in chapters %}
#### {{ chapter.title }}
{% for annotation in chapter.annotations %}
> {{ annotation.text }}
{% if annotation.note %}
**${_('Note:')}** {{ annotation.note }}
{% endif %}
*${_('Time:')} {{ annotation.timestamp | date('%Y-%m-%d %H:%M') }}*
{% endfor %}
---
{% endfor %}`;
const [exportConfig, setExportConfig] = useState<NoteExportConfig>(() => {
const noteExportConfig = viewSettings?.noteExportConfig || DEFAULT_NOTE_EXPORT_CONFIG;
if (!noteExportConfig.customTemplate) {
return {
...noteExportConfig,
customTemplate: defaultTemplate,
};
}
return noteExportConfig;
});
const [showSource, setShowSource] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
const customTemplate = exportConfig.customTemplate;
const newExportConfig = {
...exportConfig,
customTemplate: customTemplate === defaultTemplate ? '' : customTemplate,
};
saveViewSettings(envConfig, bookKey, 'noteExportConfig', newExportConfig, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exportConfig, envConfig, bookKey]);
// Generate markdown preview based on current format settings
const markdownPreview = useMemo(() => {
if (exportConfig.useCustomTemplate) {
// Prepare data for template rendering
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => a.id - b.id);
const templateData = {
title: bookTitle,
author: bookAuthor,
exportDate: new Date().toISOString().slice(0, 10),
chapters: sortedGroups.map((group) => ({
title: group.label || _('Untitled'),
annotations: group.booknotes.map((note) => ({
text: note.text || '',
note: note.note || '',
style: note.style,
color: note.color,
timestamp: note.updatedAt,
})),
})),
};
return renderTemplate(exportConfig.customTemplate, templateData);
}
// Default formatting (non-template mode)
const sortedGroups = Object.values(booknoteGroups).sort((a, b) => a.id - b.id);
const lines: string[] = [];
// Add title
if (exportConfig.includeTitle) {
lines.push(`# ${bookTitle}`);
}
// Add author
if (exportConfig.includeAuthor && bookAuthor) {
lines.push(`**${_('Author')}**: ${bookAuthor}`);
lines.push('');
}
// Add export date
if (exportConfig.includeDate) {
lines.push(`**${_('Exported from Readest')}**: ${new Date().toISOString().slice(0, 10)}`);
lines.push('');
}
if (exportConfig.includeTitle || exportConfig.includeAuthor || exportConfig.includeDate) {
lines.push('---');
lines.push('');
}
lines.push(`## ${_('Highlights & Annotations')}`);
lines.push('');
for (const group of sortedGroups) {
// Add chapter title
if (exportConfig.includeChapterTitles) {
const chapterTitle = group.label || _('Untitled');
lines.push(`### ${chapterTitle}`);
}
for (const note of group.booknotes) {
// Add quote
if (exportConfig.includeQuotes && note.text) {
lines.push(`> ${note.text}`);
}
// Add note
if (exportConfig.includeNotes && note.note) {
lines.push('');
lines.push(`**${_('Note')}**:: ${note.note}`);
}
// Add timestamp
if (exportConfig.includeTimestamp && note.updatedAt) {
const timestamp = new Date(note.updatedAt).toLocaleString();
lines.push('');
lines.push(`*${_('Time:')} ${timestamp}*`);
}
lines.push(exportConfig.noteSeparator);
}
if (exportConfig.includeChapterSeparator) {
lines.push('---');
lines.push('');
}
}
return lines.join('\n');
}, [exportConfig, booknoteGroups, bookTitle, bookAuthor, _]);
// Convert markdown to HTML for preview
const htmlPreview = useMemo(() => {
if (!markdownPreview) return '';
return marked.parse(markdownPreview);
}, [markdownPreview]);
const handleToggle = (field: keyof NoteExportConfig) => {
setExportConfig((prev) => ({
...prev,
[field]: !prev[field],
}));
};
const handleExport = () => {
onExport(markdownPreview);
};
return (
<Dialog
isOpen={isOpen}
title={_('Export Annotations')}
onClose={onCancel}
boxClassName='sm:!w-[75%] sm:h-auto sm:!max-h-[90vh] sm:!max-w-5xl'
contentClassName='sm:!px-8 sm:!py-2'
>
<div className='flex flex-col gap-4'>
{/* Format Options */}
<div className='space-y-3'>
<h3 className='font-bold'>{_('Format Options')}</h3>
<div
className={clsx(
'grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3',
exportConfig.useCustomTemplate && 'pointer-events-none opacity-50',
)}
>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeTitle}
onChange={() => handleToggle('includeTitle')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Title')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeAuthor}
onChange={() => handleToggle('includeAuthor')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Author')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeDate}
onChange={() => handleToggle('includeDate')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Export Date')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeChapterTitles}
onChange={() => handleToggle('includeChapterTitles')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Chapter Titles')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeChapterSeparator}
onChange={() => handleToggle('includeChapterSeparator')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Chapter Separator')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeQuotes}
onChange={() => handleToggle('includeQuotes')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Highlights')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeNotes}
onChange={() => handleToggle('includeNotes')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Notes')}</span>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.includeTimestamp}
onChange={() => handleToggle('includeTimestamp')}
className='checkbox checkbox-sm'
disabled={exportConfig.useCustomTemplate}
/>
<span className='text-sm'>{_('Note Date')}</span>
</label>
</div>
</div>
{/* Advanced Options */}
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<h3 className='font-bold'>{_('Advanced')}</h3>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className='text-sm text-blue-500 hover:underline'
>
{showAdvanced ? _('Hide') : _('Show')}
</button>
</div>
{showAdvanced && (
<div className='space-y-3'>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={exportConfig.useCustomTemplate}
onChange={() => handleToggle('useCustomTemplate')}
className='checkbox checkbox-sm'
/>
<span className='text-sm font-medium'>{_('Use Custom Template')}</span>
</label>
{exportConfig.useCustomTemplate && (
<>
<div className='space-y-2'>
<label className='text-sm font-medium'>{_('Export Template')}</label>
<textarea
value={exportConfig.customTemplate}
onChange={(e) =>
setExportConfig({ ...exportConfig, customTemplate: e.target.value })
}
className='textarea textarea-bordered w-full font-mono text-xs'
rows={12}
placeholder={defaultTemplate}
/>
</div>
<div className='bg-base-200 space-y-3 rounded-lg p-3 text-xs'>
<div>
<p className='mb-2 font-bold'>{_('Template Syntax:')}</p>
<ul className='space-y-1 font-mono'>
<li>
<code className='bg-base-300 rounded px-1'>{'{{ variable }}'}</code> -{' '}
{_('Insert value')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>
{'{{ variable | date }}'}
</code>{' '}
- {_('Format date (locale)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>
{"{{ variable | date('%Y-%m-%d') }}"}
</code>{' '}
- {_('Format date (custom)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>
{'{% if variable %}...{% endif %}'}
</code>{' '}
- {_('Conditional')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>
{'{% for item in list %}...{% endfor %}'}
</code>{' '}
- {_('Loop')}
</li>
</ul>
</div>
<div>
<p className='mb-2 font-bold'>{_('Available Variables:')}</p>
<ul className='space-y-1'>
<li>
<code className='bg-base-300 rounded px-1'>title</code> -{' '}
{_('Book title')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>author</code> -{' '}
{_('Book author')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>exportDate</code> -{' '}
{_('Export date')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>chapters</code> -{' '}
{_('Array of chapters')}
</li>
<li className='ml-4'>
<code className='bg-base-300 rounded px-1'>chapter.title</code> -{' '}
{_('Chapter title')}
</li>
<li className='ml-4'>
<code className='bg-base-300 rounded px-1'>chapter.annotations</code> -{' '}
{_('Array of annotations')}
</li>
<li className='ml-8'>
<code className='bg-base-300 rounded px-1'>annotation.text</code> -{' '}
{_('Highlighted text')}
</li>
<li className='ml-8'>
<code className='bg-base-300 rounded px-1'>annotation.note</code> -{' '}
{_('Annotation note')}
</li>
<li className='ml-8'>
<code className='bg-base-300 rounded px-1'>annotation.timestamp</code> -{' '}
{_('Update time')}
</li>
</ul>
</div>
<div>
<p className='mb-2 font-bold'>{_('Date Format Tokens:')}</p>
<ul className='space-y-1 font-mono'>
<li>
<code className='bg-base-300 rounded px-1'>%Y</code> -{' '}
{_('Year (4 digits)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%m</code> -{' '}
{_('Month (01-12)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%d</code> - {_('Day (01-31)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%H</code> - {_('Hour (00-23)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%M</code> -{' '}
{_('Minute (00-59)')}
</li>
<li>
<code className='bg-base-300 rounded px-1'>%S</code> -{' '}
{_('Second (00-59)')}
</li>
</ul>
</div>
</div>
</>
)}
</div>
)}
</div>
{/* Preview */}
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<h3 className='font-bold'>{_('Preview')}</h3>
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={showSource}
onChange={() => setShowSource(!showSource)}
className='checkbox checkbox-sm'
/>
<span className='text-sm'>{_('Show Source')}</span>
</label>
</div>
{showSource ? (
<div
className={clsx(
'bg-base-200 max-h-[40vh] overflow-y-auto rounded-lg p-4 font-mono text-xs',
'select-text whitespace-pre-wrap break-words',
)}
>
{markdownPreview || _('No content to preview')}
</div>
) : (
<div
className={clsx(
'bg-base-200 prose prose-sm max-w-none overflow-y-auto rounded-lg p-4',
'max-h-[40vh] select-text break-words',
)}
dangerouslySetInnerHTML={{
__html:
htmlPreview ||
`<p class="text-base-content/50">${_('No content to preview')}</p>`,
}}
/>
)}
</div>
{/* Footer Actions */}
<div className='mt-4 flex justify-end gap-4'>
<button onClick={onCancel} className='btn btn-ghost btn-sm'>
{_('Cancel')}
</button>
<button
onClick={handleExport}
className='btn btn-primary btn-sm'
disabled={booknotes.length === 0}
>
{_('Export')}
</button>
</div>
</div>
</Dialog>
);
};
export default ExportMarkdownDialog;
@@ -8,7 +8,7 @@ import { CreateProofreadRuleOptions, useProofreadStore } from '@/store/proofread
import { ProofreadScope } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { Position, TextSelection } from '@/utils/sel';
import { isWholeWord } from '@/utils/word';
import { isPunctuationOnly, isWholeWord } from '@/utils/word';
import Select from '@/components/Select';
import Popup from '@/components/Popup';
@@ -41,11 +41,18 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
const [replacementText, setReplacementText] = useState('');
const [caseSensitive, setCaseSensitive] = useState(true);
const [wholeWord, setWholeWord] = useState(!isPunctuationOnly(selection?.text || ''));
const [scope, setScope] = useState<ProofreadScope>('selection');
const [onlyForTTS, setOnlyForTTS] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useAutoFocus<HTMLInputElement>({ ref: inputRef });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setReplacementText(text);
};
const handleScopeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setScope(event.target.value as ProofreadScope);
};
@@ -58,10 +65,10 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
if (range) {
const isValidWholeWord = isWholeWord(range, selection?.text || '');
if (!isValidWholeWord) {
if (wholeWord && !isValidWholeWord) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`,
message: 'Please select a whole word or uncheck the "Whole word" option.',
timeout: 5000,
});
return;
@@ -73,7 +80,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
range.insertNode(textNode);
}
const options = {
const options: CreateProofreadRuleOptions = {
scope,
pattern: selection.text,
replacement: replacementText.trim(),
@@ -82,7 +89,8 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
isRegex: false,
enabled: true,
caseSensitive,
wholeWord: true,
wholeWord: wholeWord,
onlyForTTS: scope !== 'selection' ? onlyForTTS : undefined,
};
onConfirm?.(options);
@@ -90,7 +98,7 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
onDismiss();
if (scope !== 'selection') {
if (scope !== 'selection' && !onlyForTTS) {
if (getView(bookKey)) {
recreateViewer(envConfig, bookKey);
}
@@ -111,14 +119,14 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
width={popupWidth}
minHeight={popupHeight}
position={position}
className='flex flex-col justify-between rounded-lg bg-gray-700 text-gray-400'
className='not-eink:text-gray-400 flex flex-col justify-between rounded-lg bg-gray-700'
triangleClassName='text-gray-700'
onDismiss={onDismiss}
>
<div className='flex flex-col gap-6 p-4'>
<div className='flex gap-1 text-xs text-gray-400'>
<span>{_('Selected text:')}</span>
<span className='line-clamp-1 select-text break-words text-yellow-300'>
<div className='not-eink:text-gray-400 flex gap-1 text-xs'>
<span className='text-nowrap'>{_('Selected text:')}</span>
<span className='not-eink:text-yellow-300 line-clamp-1 select-text break-words font-medium'>
&quot;{selection?.text || ''}&quot;
</span>
</div>
@@ -131,20 +139,23 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
ref={inputRef}
type='text'
value={replacementText}
onChange={(e) => setReplacementText(e.target.value)}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && replacementText.trim()) {
if (e.key === 'Enter' && replacementText) {
handleApply();
}
}}
placeholder={_('Enter text...')}
className='w-full flex-1 rounded-md bg-gray-600 p-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-0'
className={clsx(
'w-full flex-1 rounded-md p-2 text-sm placeholder-gray-400 focus:outline-none focus:ring-0',
'not-eink:bg-gray-600 not-eink:text-white eink:border eink:border-base-content',
)}
/>
<button
onClick={handleApply}
disabled={!replacementText.trim()}
disabled={!replacementText}
className={clsx(
'btn btn-sm btn-ghost text-blue-600 disabled:text-gray-600',
'btn btn-sm btn-ghost btn-primary disabled:text-base-content/75 text-blue-600 disabled:opacity-75',
'bg-transparent hover:bg-transparent disabled:bg-transparent',
)}
>
@@ -153,8 +164,8 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
</div>
</div>
<div className='flex items-center justify-between gap-4 p-4'>
<label className='flex max-w-[30%] cursor-pointer items-center gap-2'>
<div className='flex flex-wrap items-center gap-4 p-4'>
<label className='flex cursor-pointer items-center gap-2'>
<span className='line-clamp-1 text-xs' title={_('Case sensitive:')}>
{_('Case sensitive:')}
</span>
@@ -171,17 +182,51 @@ const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
/>
</label>
<div className='flex max-w-[65%] flex-1 items-center justify-between gap-2'>
<label htmlFor='scope-select' className='line-clamp-1 text-xs' title={_('Scope:')}>
{_('Scope:')}
</label>
<Select
className='max-w-[50%] bg-gray-600 text-white'
value={scope}
onChange={handleScopeChange}
options={scopeOptions}
<label className='flex cursor-pointer items-center gap-2'>
<span className='line-clamp-1 text-xs' title={_('Whole word:')}>
{_('Whole word:')}
</span>
<input
type='checkbox'
className='toggle toggle-sm bg-gray-500 checked:bg-black hover:bg-gray-500 hover:checked:bg-black'
style={
{
'--tglbg': '#4B5563',
} as React.CSSProperties
}
checked={wholeWord}
onChange={(e) => setWholeWord(e.target.checked)}
/>
</div>
</label>
<label className='flex cursor-pointer items-center gap-2'>
<span className='line-clamp-1 text-xs' title={_('Only for TTS:')}>
{_('Only for TTS:')}
</span>
<input
type='checkbox'
disabled={scope === 'selection'}
className='toggle toggle-sm bg-gray-500 checked:bg-black hover:bg-gray-500 hover:checked:bg-black'
style={
{
'--tglbg': '#4B5563',
} as React.CSSProperties
}
checked={onlyForTTS}
onChange={(e) => setOnlyForTTS(e.target.checked)}
/>
</label>
</div>
<div className='flex flex-1 items-center justify-between gap-2 p-4'>
<label htmlFor='scope-select' className='line-clamp-1 text-xs' title={_('Scope:')}>
{_('Scope:')}
</label>
<Select
className='not-eink:bg-gray-600 eink:bg-base-100 not-eink:text-white max-w-[85%]'
value={scope}
onChange={handleScopeChange}
options={scopeOptions}
/>
</div>
</Popup>
</div>
@@ -141,7 +141,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
minHeight={popupHeight}
maxHeight={720}
position={position}
className='grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600 text-white'
className='not-eink:text-white grid h-full select-text grid-rows-[1fr,auto,1fr] bg-gray-600'
triangleClassName='text-gray-600'
onDismiss={onDismiss}
>
@@ -149,7 +149,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
<div className='mb-2 flex items-center justify-between'>
<h1 className='text-sm font-normal'>{_('Original Text')}</h1>
<Select
className='bg-gray-600 text-white/75'
className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100'
value={sourceLang}
onChange={handleSourceLangChange}
options={[
@@ -167,7 +167,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
]}
/>
</div>
<p className='text-base text-white/90'>{text}</p>
<p className='not-eink:text-white/90 text-base'>{text}</p>
</div>
<div className='mx-4 flex-shrink-0 border-t border-gray-500/30'></div>
@@ -176,7 +176,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
<div className='mb-2 flex items-center justify-between'>
<h2 className='text-sm font-normal'>{_('Translated Text')}</h2>
<Select
className='bg-gray-600 text-white/75'
className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100'
value={targetLang}
onChange={handleTargetLangChange}
options={[
@@ -194,7 +194,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
{error ? (
<p className='text-base text-red-600'>{error}</p>
) : (
<p className='text-base text-white/90'>
<p className='not-eink:text-white/90 text-base'>
{translation || _('No translation available.')}
</p>
)}
@@ -211,7 +211,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
})}
</div>
<Select
className='bg-gray-600 text-white/75'
className='not-eink:bg-gray-600 not-eink:text-white eink:bg-base-100'
value={provider}
onChange={handleProviderChange}
options={providers.map(({ name: value, label }) => ({ value, label }))}
@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react';
import Popup from '@/components/Popup';
import { Position } from '@/utils/sel';
import { useTranslation } from '@/hooks/useTranslation';
interface WikipediaPopupProps {
text: string;
@@ -21,6 +22,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
popupHeight,
onDismiss,
}) => {
const _ = useTranslation();
const isLoading = useRef(false);
useEffect(() => {
@@ -85,12 +87,14 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
const errorDiv = document.createElement('div');
const h1 = document.createElement('h1');
h1.innerText = 'Error';
h1.innerText = _('Error');
const errorMsg = document.createElement('p');
errorMsg.innerHTML = `Unable to load the article. Try searching directly on <a href="https://${language}.wikipedia.org/w/index.php?search=${encodeURIComponent(
query,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wikipedia</a>.`;
errorMsg.innerHTML = _('Unable to load the article. Try searching directly on {{link}}.', {
link: `<a href="https://${language}.wikipedia.org/w/index.php?search=${encodeURIComponent(
query,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wikipedia</a>`,
});
errorDiv.append(h1, errorMsg);
main.appendChild(errorDiv);
@@ -101,7 +105,7 @@ const WikipediaPopup: React.FC<WikipediaPopupProps> = ({
const bookLang = typeof lang === 'string' ? lang : lang?.[0];
const langCode = bookLang ? bookLang.split('-')[0]! : 'en';
fetchSummary(text, langCode);
}, [text, lang]);
}, [_, text, lang]);
return (
<div>
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Position } from '@/utils/sel';
import { useTranslation } from '@/hooks/useTranslation';
import Popup from '@/components/Popup';
type Definition = {
@@ -32,6 +33,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
popupHeight,
onDismiss,
}) => {
const _ = useTranslation();
const [lookupWord, setLookupWord] = useState(word);
const isLookingUp = useRef(false);
@@ -141,13 +143,15 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
'flex flex-col items-center justify-center w-full h-full text-center absolute inset-0';
const h1 = document.createElement('h1');
h1.innerText = 'Error';
h1.innerText = _('Error');
h1.className = 'text-lg font-bold';
const p = document.createElement('p');
p.innerHTML = `Unable to load the word. Try searching directly on <a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(
word,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wiktionary</a>.`;
p.innerHTML = _('Unable to load the word. Try searching directly on {{link}}.', {
link: `<a href="https://en.wiktionary.org/w/index.php?search=${encodeURIComponent(
word,
)}" target="_blank" rel="noopener noreferrer" class="text-primary underline">Wiktionary</a>`,
});
div.append(h1, p);
main.append(div);
@@ -156,7 +160,7 @@ const WiktionaryPopup: React.FC<WiktionaryPopupProps> = ({
const langCode = typeof lang === 'string' ? lang : lang?.[0];
fetchDefinitions(lookupWord, langCode);
}, [lookupWord, lang]);
}, [_, lookupWord, lang]);
return (
<div>
@@ -67,7 +67,7 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
return (
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
className='absolute hidden h-8 w-full items-center gap-x-4 px-4 sm:flex'
style={{ bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px' }}
>
<Button
@@ -66,11 +66,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
);
const handleGoPrevPage = useCallback(() => {
viewPagination(view, viewSettings, 'left');
viewPagination(view, viewSettings, 'left', 'page');
}, [view, viewSettings]);
const handleGoNextPage = useCallback(() => {
viewPagination(view, viewSettings, 'right');
viewPagination(view, viewSettings, 'right', 'page');
}, [view, viewSettings]);
const handleGoPrevSection = useCallback(() => {
@@ -211,7 +211,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
needHorizontalScroll && 'sm:!bottom-3 sm:!h-7',
needHorizontalScroll && 'sm:!bottom-3 sm:!h-10 sm:justify-end',
isVisible
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
@@ -245,6 +245,9 @@ const FooterBar: React.FC<FooterBarProps> = ({
<MobileFooterBar {...commonProps} />
<DesktopFooterBar {...commonProps} />
</div>
{isVisible && needHorizontalScroll && (
<div className='bg-base-100 pointer-events-none absolute bottom-0 left-0 hidden h-3 w-full sm:block' />
)}
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
@@ -38,7 +38,9 @@ const BookCard = ({ book }: { book: Book }) => {
/>
</div>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{formatTitle(title)}</h4>
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>
{formatTitle(title).replace(/\u00A0/g, ' ')}
</h4>
<p className='truncate text-xs opacity-75'>{formatAuthors(author)}</p>
</div>
<button
@@ -3,7 +3,9 @@ import React from 'react';
import Image from 'next/image';
import { MdCheck } from 'react-icons/md';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useReaderStore } from '@/store/readerStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useSidebarStore } from '@/store/sidebarStore';
@@ -12,10 +14,13 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { navigateToLogin } from '@/utils/nav';
import { saveSysSettings } from '@/helpers/settings';
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import useBooksManager from '../../hooks/useBooksManager';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
@@ -27,8 +32,11 @@ interface BookMenuProps {
const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
const { user } = useAuth();
const { settings } = useSettingsStore();
const { bookKeys, getViewSettings, setViewSettings } = useReaderStore();
const { bookKeys, recreateViewer, getViewSettings, setViewSettings } = useReaderStore();
const { getVisibleLibrary } = useLibraryStore();
const { openParallelView } = useBooksManager();
const { sideBarBookKey } = useSidebarStore();
@@ -64,8 +72,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
const viewSettings = getViewSettings(sideBarBookKey)!;
viewSettings.sortedTOC = !isSortedTOC;
setViewSettings(sideBarBookKey, viewSettings);
recreateViewer(envConfig, sideBarBookKey);
}
setTimeout(() => window.location.reload(), 100);
};
const handleSetParallel = () => {
setParallel(bookKeys);
@@ -91,6 +99,14 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
eventDispatcher.dispatch('push-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
};
const toggleDiscordPresence = () => {
const discordRichPresenceEnabled = !settings.discordRichPresenceEnabled;
saveSysSettings(envConfig, 'discordRichPresenceEnabled', discordRichPresenceEnabled);
setIsDropdownOpen?.(false);
if (discordRichPresenceEnabled && !user) {
navigateToLogin(router);
}
};
return (
<Menu
@@ -151,6 +167,17 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<MenuItem label={_('Pull Progress')} onClick={handlePullKOSync} />
</>
)}
{appService?.isDesktopApp && (
<>
<hr className='border-base-200 my-1' />
<MenuItem
label={_('Show on Discord')}
tooltip={_("Display what I'm reading on Discord")}
toggled={settings.discordRichPresenceEnabled}
onClick={toggleDiscordPresence}
/>
</>
)}
<hr className='border-base-200 my-1' />
<MenuItem label={_('Proofread')} onClick={showProofreadRulesWindow} />
<hr className='border-base-200 my-1' />
@@ -1,14 +1,17 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useProofreadStore } from '@/store/proofreadStore';
import { TransformContext } from '@/services/transformers/types';
import { proofreadTransformer } from '@/services/transformers/proofread';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
import { TTSController, SILENCE_DATA, TTSMark, TTSHighlightOptions } from '@/services/tts';
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
@@ -38,11 +41,12 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { user } = useAuth();
const { safeAreaInsets } = useThemeStore();
const { safeAreaInsets, isDarkMode } = useThemeStore();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const { getMergedRules } = useProofreadStore();
const viewSettings = getViewSettings(bookKey);
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
@@ -285,6 +289,61 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
}, [getTTSTargetLang]);
const transformCtx: TransformContext = useMemo(
() => ({
bookKey,
viewSettings: getViewSettings(bookKey)!,
userLocale: getLocale(),
content: '',
transformers: [],
reversePunctuationTransform: true,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const preprocessSSMLForTTS = useCallback(
async (ssml: string) => {
const rules = getMergedRules(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const ttsOnlyRules = rules.filter(
(rule) =>
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
);
if (ttsOnlyRules.length === 0) return ssml;
transformCtx['content'] = ssml;
transformCtx['viewSettings'] = viewSettings;
ssml = await proofreadTransformer.transform(transformCtx, {
docType: 'text/xml',
onlyForTTS: true,
});
return ssml;
},
[bookKey, getMergedRules, getViewSettings, transformCtx],
);
const getTTSHighlightOptions = useCallback(
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
return {
...ttsHighlightOptions,
color,
};
},
[isDarkMode],
);
useEffect(() => {
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
if (ttsControllerRef.current && ttsHighlightOptions) {
ttsControllerRef.current.initViewTTS(
getTTSHighlightOptions(ttsHighlightOptions, viewSettings.isEink),
);
}
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
if (bookKey !== ttsBookKey) return;
@@ -338,12 +397,14 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
if (!oneTime) {
setShowIndicator(true);
}
const ttsController = new TTSController(appService, view, !!user?.id);
const ttsController = new TTSController(appService, view, !!user?.id, preprocessSSMLForTTS);
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
await ttsController.init();
await ttsController.initViewTTS(viewSettings.ttsHighlightOptions);
await ttsController.initViewTTS(
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
);
const ssml = view.tts?.from(ttsFromRange);
if (ssml) {
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
@@ -643,13 +704,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
};
}, [hoveredBookKey]);
useEffect(() => {
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
if (ttsControllerRef.current && ttsHighlightOptions) {
ttsControllerRef.current.initViewTTS(ttsHighlightOptions);
}
}, [viewSettings?.ttsHighlightOptions]);
return (
<>
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
@@ -267,6 +267,8 @@ const TTSPanel = ({
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1 transition-transform duration-200 hover:scale-105'
onClick={(e) => e.currentTarget.focus()}
title={_('Set Timeout')}
aria-label={_('Set Timeout')}
>
<MdAlarm size={iconSize32} />
{timeoutCountdown && (
@@ -325,6 +327,8 @@ const TTSPanel = ({
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
)}
title={_('Select Voice')}
aria-label={_('Select Voice')}
>
{voiceGroups.map((voiceGroup, index) => {
return (
@@ -373,7 +377,12 @@ const TTSPanel = ({
</div>
</div>
<div className='flex h-4 items-center justify-center opacity-60 transition-transform duration-200 hover:scale-105 hover:opacity-100'>
<button onClick={onToogleTTSBar} className='p-0'>
<button
onClick={onToogleTTSBar}
className='p-0'
title={_('Toggle Sticky Bottom TTS Bar')}
aria-label={_('Toggle Sticky Bottom TTS Bar')}
>
{viewSettings?.showTTSBar ? (
<TbChevronCompactUp size={iconSize48} style={{ transform: 'scaleY(0.85)' }} />
) : (
@@ -46,12 +46,26 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
const goLeft = () => {
const viewSettings = getViewSettings(sideBarBookKey ?? '');
viewPagination(getView(sideBarBookKey), viewSettings, 'left');
viewPagination(getView(sideBarBookKey), viewSettings, 'left', 'pan', distance);
};
const goRight = () => {
const viewSettings = getViewSettings(sideBarBookKey ?? '');
viewPagination(getView(sideBarBookKey), viewSettings, 'right');
viewPagination(getView(sideBarBookKey), viewSettings, 'right', 'pan', distance);
};
const goUp = (event?: KeyboardEvent | MessageEvent) => {
const view = getView(sideBarBookKey);
const viewSettings = getViewSettings(sideBarBookKey ?? '');
if (view?.renderer.scrolled && event instanceof MessageEvent) return;
viewPagination(view, viewSettings, 'up', 'pan', distance);
};
const goDown = (event?: KeyboardEvent | MessageEvent) => {
const view = getView(sideBarBookKey);
const viewSettings = getViewSettings(sideBarBookKey ?? '');
if (view?.renderer.scrolled && event instanceof MessageEvent) return;
viewPagination(view, viewSettings, 'down', 'pan', distance);
};
const goPrevSection = () => {
@@ -82,32 +96,6 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
getView(sideBarBookKey)?.next(distance);
};
const goPrevArrowUp = (event?: KeyboardEvent | MessageEvent) => {
const view = getView(sideBarBookKey);
if (
view?.renderer.scrolled &&
event instanceof MessageEvent &&
event.data.type === 'iframe-keydown'
) {
// already handled in the iframe for better smoothness
return;
}
view?.prev(distance);
};
const goNextArrowDown = (event?: KeyboardEvent | MessageEvent) => {
const view = getView(sideBarBookKey);
if (
view?.renderer.scrolled &&
event instanceof MessageEvent &&
event.data.type === 'iframe-keydown'
) {
// already handled in the iframe for better smoothness
return;
}
view?.next(distance);
};
const goBack = () => {
getView(sideBarBookKey)?.history.back();
};
@@ -251,10 +239,10 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onQuitApp: quitApp,
onGoLeft: goLeft,
onGoRight: goRight,
onGoUp: goUp,
onGoDown: goDown,
onGoPrev: goPrev,
onGoNext: goNext,
onGoPrevArrowUp: goPrevArrowUp,
onGoNextArrowDown: goNextArrowDown,
onGoHalfPageDown: goHalfPageDown,
onGoHalfPageUp: goHalfPageUp,
onGoPrevSection: goPrevSection,
@@ -12,7 +12,7 @@ import { tauriGetWindowLogicalPosition } from '@/utils/window';
export type ScrollSource = 'touch' | 'mouse';
type PaginationSide = 'left' | 'right' | 'up' | 'down';
type PaginationMode = 'page' | 'section';
type PaginationMode = 'pan' | 'page' | 'section';
const swapLeftRight = (side: PaginationSide) => {
if (side === 'left') return 'right';
@@ -20,11 +20,36 @@ const swapLeftRight = (side: PaginationSide) => {
return side;
};
const isPanningView = (view: FoliateView | null, viewSettings: ViewSettings | null | undefined) => {
if (!view || !viewSettings) return false;
return (
view.book.rendition?.layout === 'pre-paginated' &&
(viewSettings.zoomLevel > 100 || viewSettings.zoomMode !== 'fit-page')
);
};
const hasHorizontalPanning = (
view: FoliateView | null,
viewSettings: ViewSettings | null | undefined,
) => {
if (!view || !viewSettings) return false;
return isPanningView(view, viewSettings) && view.isOverflowX();
};
const hasVerticalPanning = (
view: FoliateView | null,
viewSettings: ViewSettings | null | undefined,
) => {
if (!view || !viewSettings) return false;
return isPanningView(view, viewSettings) && view.isOverflowY();
};
export const viewPagination = (
view: FoliateView | null,
viewSettings: ViewSettings | null | undefined,
side: PaginationSide,
mode: PaginationMode = 'page',
panDistance: number = 50,
) => {
if (!view || !viewSettings) return;
const renderer = view.renderer;
@@ -38,25 +63,37 @@ export const viewPagination = (
const scrollingOverlap = viewSettings.scrollingOverlap;
const distance = size - scrollingOverlap - (showHeader ? 44 : 0) - (showFooter ? 44 : 0);
switch (mode) {
case 'page':
return side === 'left' || side === 'up' ? view.prev(distance) : view.next(distance);
case 'section':
if (side === 'left' || side === 'up') {
return view.renderer.prevSection?.();
} else {
return view.renderer.nextSection?.();
}
case 'pan':
case 'page':
default:
return side === 'left' || side === 'up' ? view.prev(distance) : view.next(distance);
}
} else if (mode === 'pan' && isPanningView(view, viewSettings)) {
if (hasHorizontalPanning(view, viewSettings) && (side === 'left' || side === 'right')) {
return view.pan(side === 'left' ? -panDistance : panDistance, 0);
} else if (hasVerticalPanning(view, viewSettings) && (side === 'up' || side === 'down')) {
return view.pan(0, side === 'up' ? -panDistance : panDistance);
} else {
return side === 'left' || side === 'up' ? view.prev() : view.next();
}
} else {
switch (mode) {
case 'page':
return side === 'left' || side === 'up' ? view.prev() : view.next();
case 'section':
if (side === 'left' || side === 'up') {
return view.renderer.prevSection?.();
} else {
return view.renderer.nextSection?.();
}
case 'pan':
case 'page':
default:
return side === 'left' || side === 'up' ? view.prev() : view.next();
}
}
};
@@ -142,7 +179,7 @@ export const usePagination = (
} else if (
msg.data.type === 'iframe-wheel' &&
!viewSettings.scrolled &&
(!bookData.isFixedLayout || viewSettings.zoomLevel <= 100)
!isPanningView(viewRef.current, viewSettings)
) {
// The wheel event is handled by the iframe itself in scrolled mode.
const { deltaY } = msg.data;
@@ -172,7 +209,7 @@ export const usePagination = (
} else if (
msg.type === 'touch-swipe' &&
bookData.isFixedLayout &&
viewSettings!.zoomLevel <= 100
!isPanningView(viewRef.current, viewSettings)
) {
const { deltaX, deltaY, deltaT } = msg.detail;
const vx = Math.abs(deltaX / deltaT);
@@ -40,7 +40,6 @@ export const useTextSelector = (
const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => {
isTextSelected.current = true;
isUpToPopup.current = true;
const range = sel.getRangeAt(0);
if (rebuildRange) {
sel.removeAllRanges();
@@ -57,7 +56,6 @@ export const useTextSelector = (
// FIXME: extremely hacky way to dismiss system selection tools on iOS
const makeSelectionOnIOS = async (sel: Selection, index: number) => {
isTextSelected.current = true;
isUpToPopup.current = true;
const range = sel.getRangeAt(0);
setTimeout(() => {
sel.removeAllRanges();
@@ -120,6 +118,7 @@ export const useTextSelector = (
stopInstantAnnotating(ev);
const handled = await handleInstantAnnotationPointerUp(doc, index, ev);
if (handled) {
isTextSelected.current = true;
return;
} else {
// If instant annotation was not created, we let the event propagate
@@ -140,11 +139,11 @@ export const useTextSelector = (
if (isValidSelection(sel)) {
const isPointerInside = ev && isPointerInsideSelection(sel, ev);
const isIOS = osPlatform === 'ios' || appService?.isIOSApp;
const isAndroid = appService?.isAndroidApp;
if (isPointerInside && isIOS) {
makeSelectionOnIOS(sel, index);
} else if (isPointerInside || isAndroid) {
} else if (isPointerInside) {
isUpToPopup.current = true;
makeSelection(sel, index, true);
}
}
@@ -160,7 +159,7 @@ export const useTextSelector = (
const handleTouchEnd = () => {
isTouchStarted.current = false;
};
const handleSelectionchange = (doc: Document) => {
const handleSelectionchange = (doc: Document, index: number) => {
// Available on iOS, Android and Desktop, fired when the selection is changed
if (osPlatform !== 'android' || !appService?.isAndroidApp) return;
@@ -169,6 +168,7 @@ export const useTextSelector = (
if (!selectionPosition.current) {
selectionPosition.current = view?.renderer?.start || null;
}
makeSelection(sel, index, false);
} else {
selectionPosition.current = null;
}
+10 -3
View File
@@ -10,6 +10,7 @@ const Alert: React.FC<{
onConfirm: () => void;
}> = ({ title, message, onCancel, onConfirm }) => {
const _ = useTranslation();
const [isProcessing, setIsProcessing] = React.useState(false);
const divRef = useKeyDownActions({ onCancel, onConfirm });
return (
@@ -39,15 +40,21 @@ const Alert: React.FC<{
></path>
</svg>
<div className='flex flex-col gap-y-2'>
<h3 className='text-start text-sm sm:text-center'>{title}</h3>
<div className='text-start text-xs sm:text-center'>{message}</div>
<h3 className='text-start text-sm font-medium sm:text-center'>{title}</h3>
<div className='text-start text-sm sm:text-center'>{message}</div>
</div>
</div>
<div className='buttons flex flex-wrap items-center justify-end gap-2 self-end sm:max-w-[20vw] sm:self-center'>
<button className='btn btn-sm btn-neutral' onClick={onCancel}>
{_('Cancel')}
</button>
<button className='btn btn-sm btn-warning' onClick={onConfirm}>
<button
className={clsx('btn btn-sm btn-warning', { 'btn-disabled': isProcessing })}
onClick={() => {
setIsProcessing(true);
onConfirm();
}}
>
{_('Confirm')}
</button>
</div>
+2 -2
View File
@@ -75,7 +75,7 @@ const Dropdown: React.FC<DropdownProps> = ({
const handleTouchOrClick = () => {
lastInteractionWasTapOrClick.current = true;
setTimeout(() => (lastInteractionWasTapOrClick.current = false), 100);
setTimeout(() => (lastInteractionWasTapOrClick.current = false), 200);
};
const handleFocus = () => {
@@ -139,7 +139,7 @@ const Dropdown: React.FC<DropdownProps> = ({
aria-label={label}
title={label}
className={clsx(
'dropdown-toggle',
'dropdown-toggle touch-target',
isFocused && isOpen && 'bg-base-300/50',
buttonClassName,
)}
@@ -5,8 +5,10 @@ import { useEnv } from '@/context/EnvContext';
import { useEffect, useState } from 'react';
import { type as osType, arch as osArch } from '@tauri-apps/plugin-os';
import { check, Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { relaunch, exit } from '@tauri-apps/plugin-process';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { Command } from '@tauri-apps/plugin-shell';
import { invoke } from '@tauri-apps/api/core';
import { isTauriAppPlatform } from '@/services/environment';
import { useTranslator } from '@/hooks/useTranslator';
import { useTranslation } from '@/hooks/useTranslation';
@@ -14,6 +16,7 @@ import { useSearchParams } from 'next/navigation';
import { getAppVersion } from '@/utils/version';
import { tauriDownload } from '@/utils/transfer';
import { installPackage } from '@/utils/bridge';
import { join } from '@tauri-apps/api/path';
import { getLocale } from '@/utils/misc';
import { setLastShownReleaseNotesVersion } from '@/helpers/updater';
import { READEST_UPDATER_FILE, READEST_CHANGELOG_FILE } from '@/services/constants';
@@ -167,9 +170,76 @@ export const UpdaterContent = ({
} as GenericUpdate);
}
};
const checkWindowsPortableUpdate = async () => {
if (!appService) return;
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
const response = await fetch(READEST_UPDATER_FILE);
const data = await response.json();
if (semver.gt(data.version, currentVersion)) {
const OS_ARCH = osArch();
const platformKey =
OS_ARCH === 'x86_64' ? 'windows-x86_64-portable' : 'windows-aarch64-portable';
const arch = OS_ARCH === 'x86_64' ? 'x64' : 'arm64';
const downloadUrl = data.platforms[platformKey]?.url as string;
const execDir = await invoke<string>('get_executable_dir');
const exeFileName = `Readest_${data.version}_${arch}-portable.exe`;
const exeFilePath = await join(execDir, exeFileName);
setUpdate({
currentVersion,
version: data.version,
date: data.pub_date,
body: data.notes,
downloadAndInstall: async (onEvent) => {
await new Promise<void>(async (resolve, reject) => {
let downloaded = 0;
let total = 0;
await tauriDownload(downloadUrl, exeFilePath, (progress) => {
if (!onEvent) return;
if (!total && progress.total) {
total = progress.total;
onEvent({
event: 'Started',
data: { contentLength: total },
});
} else if (downloaded > 0 && progress.progress === progress.total) {
console.log('Portable EXE downloaded to', exeFilePath);
onEvent?.({ event: 'Finished' });
setTimeout(() => {
resolve();
}, 1000);
}
onEvent({
event: 'Progress',
data: { chunkLength: progress.progress - downloaded },
});
downloaded = progress.progress;
}).catch((error) => {
console.error('Download failed:', error);
reject(error);
});
});
try {
console.log('Launching new executable:', exeFilePath);
const command = Command.create('start-readest', ['/C', 'start', '', exeFilePath]);
await command.spawn();
console.log('New executable launched, exiting current app...');
setTimeout(async () => {
await exit(0);
}, 500);
} catch (error) {
console.error('Failed to launch new executable:', error);
}
},
} as GenericUpdate);
}
};
const checkForUpdates = async () => {
const OS_TYPE = osType();
if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
if (appService?.isPortableApp && OS_TYPE === 'windows') {
checkWindowsPortableUpdate();
} else if (['macos', 'windows', 'linux'].includes(OS_TYPE)) {
checkDesktopUpdate();
} else if (OS_TYPE === 'android') {
checkAndroidUpdate();
@@ -8,11 +8,14 @@ import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useMetadataEdit } from './useMetadataEdit';
import { DeleteAction } from '@/types/system';
import { eventDispatcher } from '@/utils/event';
import { isWebAppPlatform } from '@/services/environment';
import Alert from '@/components/Alert';
import Dialog from '@/components/Dialog';
import BookDetailView from './BookDetailView';
import BookDetailEdit from './BookDetailEdit';
import SourceSelector from './SourceSelector';
import Spinner from '../Spinner';
interface BookDetailModalProps {
book: Book;
@@ -44,9 +47,10 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
handleBookMetadataUpdate,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [bookMeta, setBookMeta] = useState<BookMetadata | null>(null);
const [fileSize, setFileSize] = useState<number | null>(null);
@@ -167,6 +171,20 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
}
};
const handleBookExport = async () => {
setIsLoading(true);
setTimeout(async () => {
const success = await appService?.exportBook(book);
setIsLoading(false);
if (!isWebAppPlatform()) {
eventDispatcher.dispatch('toast', {
type: success ? 'info' : 'error',
message: success ? _('Book exported successfully.') : _('Failed to export the book.'),
});
}
}, 0);
};
const currentDeleteConfig = activeDeleteAction ? deleteConfigs[activeDeleteAction] : null;
return (
@@ -213,6 +231,7 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
onDeleteLocalCopy={handleBookDeleteLocalCopy ? handleDeleteLocalCopy : undefined}
onDownload={handleBookDownload ? handleRedownload : undefined}
onUpload={handleBookUpload ? handleReupload : undefined}
onExport={handleBookExport}
/>
)}
</div>
@@ -228,6 +247,12 @@ const BookDetailModal: React.FC<BookDetailModalProps> = ({
/>
)}
{isLoading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)}
{activeDeleteAction && currentDeleteConfig && (
<div
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
@@ -5,6 +5,7 @@ import {
MdOutlineCloudUpload,
MdOutlineDelete,
MdOutlineEdit,
MdSaveAlt,
} from 'react-icons/md';
import { Book } from '@/types/book';
@@ -32,6 +33,7 @@ interface BookDetailViewProps {
onDeleteLocalCopy?: () => void;
onDownload?: () => void;
onUpload?: () => void;
onExport?: () => void;
}
const BookDetailView: React.FC<BookDetailViewProps> = ({
@@ -44,25 +46,26 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
onDeleteLocalCopy,
onDownload,
onUpload,
onExport,
}) => {
const _ = useTranslation();
return (
<div className='relative w-full rounded-lg'>
<div className='mb-6 me-4 flex h-32 items-start'>
<div className='me-10 aspect-[28/41] h-32 shadow-lg'>
<div className='me-6 aspect-[28/41] h-32 shadow-lg sm:me-10'>
<BookCover mode='list' book={book} />
</div>
<div className='title-author flex h-32 flex-col justify-between'>
<div>
<p className='text-base-content mb-2 line-clamp-2 text-lg font-bold'>
{formatTitle(book.title) || _('Untitled')}
<p className='text-base-content mb-2 line-clamp-2 break-words text-lg font-bold'>
{formatTitle(book.title).replace(/\u00A0/g, ' ') || _('Untitled')}
</p>
<p className='text-neutral-content line-clamp-1'>
{formatAuthors(book.author, book.primaryLanguage) || _('Unknown')}
</p>
</div>
<div className='flex flex-wrap items-center gap-x-4'>
<div className='flex flex-nowrap items-center gap-3 sm:gap-x-4'>
{onEdit && (
<button
onClick={onEdit}
@@ -118,6 +121,11 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<MdOutlineCloudUpload className='fill-base-content' />
</button>
)}
{book.downloadedAt && onExport && (
<button onClick={onExport} title={_('Export Book')}>
<MdSaveAlt className='fill-base-content' />
</button>
)}
</div>
</div>
</div>
@@ -209,6 +209,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
type='checkbox'
className='toggle'
checked={isScrolledMode}
disabled={bookData?.isFixedLayout}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
@@ -218,6 +219,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
type='checkbox'
className='toggle'
checked={isContinuousScroll}
disabled={bookData?.isFixedLayout}
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
@@ -249,15 +249,17 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
useEffect(() => {
if (maxColumnCount === viewSettings.maxColumnCount) return;
saveViewSettings(envConfig, bookKey, 'maxColumnCount', maxColumnCount, false, false);
const newViewSettings = getViewSettings(bookKey)!;
view?.renderer.setAttribute('max-column-count', maxColumnCount);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(newViewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxColumnCount]);
useEffect(() => {
if (maxInlineSize === viewSettings.maxInlineSize) return;
saveViewSettings(envConfig, bookKey, 'maxInlineSize', maxInlineSize, false, false);
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(viewSettings)}px`);
const newViewSettings = getViewSettings(bookKey)!;
view?.renderer.setAttribute('max-inline-size', `${getMaxInlineSize(newViewSettings)}px`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxInlineSize]);
@@ -279,7 +281,8 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
}
saveViewSettings(envConfig, bookKey, 'writingMode', writingMode, true).then(() => {
if (view) {
view.renderer.setStyles?.(getStyles(viewSettings));
const newViewSettings = getViewSettings(bookKey)!;
view.renderer.setStyles?.(getStyles(newViewSettings));
view.book.dir = getBookDirFromWritingMode(writingMode);
}
if (
+1 -1
View File
@@ -39,11 +39,11 @@ export const saveViewSettings = async <K extends keyof ViewSettings>(
if (isSettingsGlobal && !skipGlobal) {
settings.globalViewSettings[key] = value;
setSettings(settings);
await saveSettings(envConfig, settings);
for (const bookKey of bookKeys) {
await applyViewSettings(bookKey);
}
await saveSettings(envConfig, settings);
} else if (bookKey) {
await applyViewSettings(bookKey);
}
+6 -6
View File
@@ -23,12 +23,12 @@ const DEFAULT_SHORTCUTS = {
onToggleFullscreen: ['F11'],
onCloseWindow: ['ctrl+w', 'cmd+w'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h', 'shift+ '],
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
onGoNext: ['j'],
onGoPrev: ['k'],
onGoNextArrowDown: ['ArrowDown'],
onGoPrevArrowUp: ['ArrowUp'],
onGoLeft: ['ArrowLeft', 'h', 'shift+ '],
onGoRight: ['ArrowRight', 'l', ' '],
onGoUp: ['ArrowUp', 'k'],
onGoDown: ['ArrowDown', 'j'],
onGoNext: ['shift+j', 'shift+ArrowRight', 'shift+ArrowDown', 'PageDown'],
onGoPrev: ['shift+k', 'shift+ArrowLeft', 'shift+ArrowUp', 'PageUp'],
onGoLeftSection: ['opt+ArrowLeft', 'alt+ArrowLeft'],
onGoRightSection: ['opt+ArrowRight', 'alt+ArrowRight'],
onGoPrevSection: ['opt+ArrowUp', 'alt+ArrowUp'],
@@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react';
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { updateDiscordPresence, clearDiscordPresence } from '@/utils/discord';
/**
* Hook to manage Discord Rich Presence for a book
* @param book - Current book being read (null if no book)
* @param isPrimary - Whether this is the primary book (for multi-book scenarios)
*/
export const useDiscordPresence = (book: Book | null, isPrimary: boolean, enabled: boolean) => {
const { appService } = useEnv();
const sessionStartRef = useRef<number>(Date.now());
const updateIntervalRef = useRef<NodeJS.Timeout | null>(null);
const currentBookHashRef = useRef<string | null>(null);
const isUpdatingRef = useRef<boolean>(false);
useEffect(() => {
if (!isPrimary || !book) return;
if (!appService?.isDesktopApp) return;
const stopUpdates = () => {
if (updateIntervalRef.current) {
clearInterval(updateIntervalRef.current);
updateIntervalRef.current = null;
}
isUpdatingRef.current = false;
};
if (!enabled) {
stopUpdates();
clearDiscordPresence(appService);
currentBookHashRef.current = null;
return;
}
if (currentBookHashRef.current !== book.hash) {
sessionStartRef.current = Date.now();
currentBookHashRef.current = book.hash;
}
const safeUpdate = async () => {
if (isUpdatingRef.current) return;
isUpdatingRef.current = true;
try {
await updateDiscordPresence(book, sessionStartRef.current, appService);
} catch (err) {
console.error('Discord presence update failed:', err);
} finally {
isUpdatingRef.current = false;
}
};
void safeUpdate();
updateIntervalRef.current = setInterval(() => {
void safeUpdate();
}, 15000);
return () => {
stopUpdates();
clearDiscordPresence(appService);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [book?.hash, isPrimary, enabled, appService]);
};
+2 -1
View File
@@ -181,7 +181,7 @@ export function useSync(bookKey?: string) {
await pushChanges({ books });
}
if (op === 'pull' || op === 'both') {
await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks);
await pullChanges('books', lastSyncedAtBooks + 1, setLastSyncedAtBooks, setSyncingBooks);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -258,6 +258,7 @@ export function useSync(bookKey?: string) {
lastSyncedAtBooks,
lastSyncedAtNotes,
lastSyncedAtConfigs,
useSyncInited: lastSyncedAtInited,
pullChanges,
pushChanges,
syncBooks,
+61 -38
View File
@@ -79,6 +79,8 @@ export const EXTS: Record<BookFormat, string> = {
CBZ: 'cbz',
FB2: 'fb2',
FBZ: 'fbz',
TXT: 'txt',
MD: 'md',
};
export const MIMETYPES: Record<BookFormat, string[]> = {
@@ -90,6 +92,8 @@ export const MIMETYPES: Record<BookFormat, string[]> = {
CBZ: ['application/vnd.comicbook+zip', 'application/zip', 'application/x-cbz'],
FB2: ['application/x-fictionbook+xml', 'text/xml', 'application/xml'],
FBZ: ['application/x-zip-compressed-fb2', 'application/zip'],
TXT: ['text/plain'],
MD: ['text/markdown', 'text/x-markdown'],
};
export class DocumentLoader {
@@ -188,48 +192,56 @@ export class DocumentLoader {
if (!this.file.size) {
throw new Error('File is empty');
}
if (await this.isZip()) {
const loader = await this.makeZipLoader();
const { entries } = loader;
try {
if (await this.isZip()) {
const loader = await this.makeZipLoader();
const { entries } = loader;
if (this.isCBZ()) {
const { makeComicBook } = await import('foliate-js/comic-book.js');
book = await makeComicBook(loader, this.file);
format = 'CBZ';
} else if (this.isFBZ()) {
const entry = entries.find((entry) => entry.filename.endsWith(`.${EXTS.FB2}`));
const blob = await loader.loadBlob((entry ?? entries[0]!).filename);
if (this.isCBZ()) {
const { makeComicBook } = await import('foliate-js/comic-book.js');
book = await makeComicBook(loader, this.file);
format = 'CBZ';
} else if (this.isFBZ()) {
const entry = entries.find((entry) => entry.filename.endsWith(`.${EXTS.FB2}`));
const blob = await loader.loadBlob((entry ?? entries[0]!).filename);
const { makeFB2 } = await import('foliate-js/fb2.js');
book = await makeFB2(blob);
format = 'FBZ';
} else {
const { EPUB } = await import('foliate-js/epub.js');
book = await new EPUB(loader).init();
format = 'EPUB';
}
} else if (await this.isPDF()) {
const { makePDF } = await import('foliate-js/pdf.js');
book = await makePDF(this.file);
format = 'PDF';
} else if (await (await import('foliate-js/mobi.js')).isMOBI(this.file)) {
const fflate = await import('foliate-js/vendor/fflate.js');
const { MOBI } = await import('foliate-js/mobi.js');
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(this.file);
const ext = this.file.name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'azw':
format = 'AZW';
break;
case 'azw3':
format = 'AZW3';
break;
default:
format = 'MOBI';
}
} else if (this.isFB2()) {
const { makeFB2 } = await import('foliate-js/fb2.js');
book = await makeFB2(blob);
format = 'FBZ';
} else {
const { EPUB } = await import('foliate-js/epub.js');
book = await new EPUB(loader).init();
format = 'EPUB';
book = await makeFB2(this.file);
format = 'FB2';
}
} else if (await this.isPDF()) {
const { makePDF } = await import('foliate-js/pdf.js');
book = await makePDF(this.file);
format = 'PDF';
} else if (await (await import('foliate-js/mobi.js')).isMOBI(this.file)) {
const fflate = await import('foliate-js/vendor/fflate.js');
const { MOBI } = await import('foliate-js/mobi.js');
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(this.file);
const ext = this.file.name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'azw':
format = 'AZW';
break;
case 'azw3':
format = 'AZW3';
break;
default:
format = 'MOBI';
} catch (e: unknown) {
console.error('Failed to open document:', e);
if (e instanceof Error && e.message?.includes('not a valid zip')) {
throw new Error('Unsupported or corrupted book file');
}
} else if (this.isFB2()) {
const { makeFB2 } = await import('foliate-js/fb2.js');
book = await makeFB2(this.file);
format = 'FB2';
throw e;
}
return { book, format } as { book: BookDoc; format: BookFormat };
}
@@ -254,3 +266,14 @@ export const getFileExtFromMimeType = (mimeType?: string): string => {
}
return '';
};
export const getMimeTypeFromFileExt = (ext: string): string => {
ext = ext.toLowerCase();
for (const format in EXTS) {
if (EXTS[format as BookFormat] === ext) {
const mimeTypes = MIMETYPES[format as BookFormat];
return mimeTypes[0] || 'application/octet-stream';
}
}
return 'application/octet-stream';
};
+13 -4
View File
@@ -44,6 +44,7 @@ export const uploadFile = async (
fileFullPath: string,
onProgress?: ProgressHandler,
bookHash?: string,
temp = false,
) => {
try {
const response = await fetchWithAuth(API_ENDPOINTS.upload, {
@@ -55,15 +56,18 @@ export const uploadFile = async (
fileName: file.name,
fileSize: file.size,
bookHash,
temp,
}),
});
const { uploadUrl } = await response.json();
const { uploadUrl, downloadUrl }: { uploadUrl: string; downloadUrl?: string } =
await response.json();
if (isWebAppPlatform()) {
await webUpload(file, uploadUrl, onProgress);
} else {
await tauriUpload(uploadUrl, fileFullPath, 'PUT', onProgress);
}
return temp ? downloadUrl : undefined;
} catch (error) {
console.error('File upload failed:', error);
if (error instanceof Error) {
@@ -149,10 +153,15 @@ export const downloadFile = async ({
}
if (isWebAppPlatform()) {
const file = await webDownload(downloadUrl, onProgress, headers);
await appService.writeFile(dst, 'None', await file.arrayBuffer());
const { headers: responseHeaders, blob } = await webDownload(
downloadUrl,
onProgress,
headers,
);
await appService.writeFile(dst, 'None', await blob.arrayBuffer());
return responseHeaders;
} else {
await tauriDownload(
return await tauriDownload(
downloadUrl,
dst,
onProgress,
+2
View File
@@ -18,6 +18,8 @@ export interface SyncResult {
configs: BookConfigRecord[] | null;
}
export type SyncRecord = BookRecord & BookConfigRecord & BookNoteRecord;
export interface SyncData {
books?: Partial<BookRecord>[];
notes?: Partial<BookNoteRecord>[];
@@ -6,7 +6,8 @@ import {
validateUserAndToken,
STORAGE_QUOTA_GRACE_BYTES,
} from '@/utils/access';
import { getUploadSignedUrl } from '@/utils/object';
import { getDownloadSignedUrl, getUploadSignedUrl } from '@/utils/object';
import { READEST_PUBLIC_STORAGE_BASE_URL } from '@/services/constants';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
@@ -15,13 +16,35 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { user, token } = await validateUserAndToken(req.headers['authorization']);
if (!user || !token) {
return res.status(403).json({ error: 'Not authenticated' });
}
const { user, token } = await validateUserAndToken(req.headers['authorization']);
if (!user || !token) {
return res.status(403).json({ error: 'Not authenticated' });
}
const { fileName, fileSize, bookHash } = req.body;
const { fileName, fileSize, bookHash, temp = false } = req.body;
if (temp) {
try {
const datetime = new Date();
const timeStr = datetime.toISOString().replace(/[-:]/g, '').replace('T', '').slice(0, 10);
const userStr = user.id.slice(0, 8);
const fileKey = `temp/img/${timeStr}/${userStr}/${fileName}`;
const bucketName = process.env['TEMP_STORAGE_PUBLIC_BUCKET_NAME'] || '';
const uploadUrl = await getUploadSignedUrl(fileKey, fileSize, 1800, bucketName);
const downloadUrl = await getDownloadSignedUrl(fileKey, 3 * 86400, bucketName);
const pathname = new URL(downloadUrl).pathname;
const publicBaseUrl = READEST_PUBLIC_STORAGE_BASE_URL;
const publicDownloadUrl = `${publicBaseUrl}${pathname.replace(`/${bucketName}`, '')}`;
return res.status(200).json({
uploadUrl,
downloadUrl: publicDownloadUrl,
});
} catch (error) {
console.error('Error creating presigned post for temp file:', error);
return res.status(500).json({ error: 'Could not create presigned post' });
}
}
try {
if (!fileName || !fileSize) {
return res.status(400).json({ error: 'Missing file info' });
}
+39 -16
View File
@@ -7,7 +7,7 @@ import { transformBookConfigToDB } from '@/utils/transform';
import { transformBookNoteToDB } from '@/utils/transform';
import { transformBookToDB } from '@/utils/transform';
import { runMiddleware, corsAllMethods } from '@/utils/cors';
import { SyncData, SyncResult, SyncType } from '@/libs/sync';
import { SyncData, SyncRecord, SyncResult, SyncType } from '@/libs/sync';
import { validateUserAndToken } from '@/utils/access';
import { DBBook, DBBookConfig } from '@/types/records';
@@ -60,21 +60,44 @@ export async function GET(req: NextRequest) {
};
const queryTables = async (table: TableName, dedupeKeys?: (keyof BookDataRecord)[]) => {
let query = supabase.from(table).select('*').eq('user_id', user.id);
if (bookParam && metaHashParam) {
query.or(`book_hash.eq.${bookParam},meta_hash.eq.${metaHashParam}`);
} else if (bookParam) {
query.eq('book_hash', bookParam);
} else if (metaHashParam) {
query.eq('meta_hash', metaHashParam);
const PAGE_SIZE = 1000;
let allRecords: SyncRecord[] = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
let query = supabase
.from(table)
.select('*')
.eq('user_id', user.id)
.range(offset, offset + PAGE_SIZE - 1);
if (bookParam && metaHashParam) {
query = query.or(`book_hash.eq.${bookParam},meta_hash.eq.${metaHashParam}`);
} else if (bookParam) {
query = query.eq('book_hash', bookParam);
} else if (metaHashParam) {
query = query.eq('meta_hash', metaHashParam);
}
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
query = query.order('updated_at', { ascending: false });
console.log('Querying table:', table, 'since:', sinceIso, 'offset:', offset);
const { data, error } = await query;
if (error) throw { table, error } as DBError;
if (data && data.length > 0) {
allRecords = allRecords.concat(data);
offset += PAGE_SIZE;
hasMore = data.length === PAGE_SIZE;
} else {
hasMore = false;
}
}
query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`);
query = query.order('updated_at', { ascending: false });
console.log('Querying table:', table, 'since:', sinceIso);
const { data, error } = await query;
if (error) throw { table, error } as DBError;
let records = data;
let records = allRecords;
if (dedupeKeys && dedupeKeys.length > 0) {
const seen = new Set<string>();
records = records.filter((rec) => {
@@ -167,8 +190,8 @@ export async function POST(req: NextRequest) {
// Fetch existing records for this batch
const orConditions = matchConditions
.map((cond) => {
const parts = Object.entries(cond).map(([key, val]) => `and(${key}.eq.${val})`);
return parts.join(',');
const parts = Object.entries(cond).map(([key, val]) => `${key}.eq.${val}`);
return `and(${parts.join(',')})`;
})
.join(',');
+37 -7
View File
@@ -58,7 +58,14 @@ import {
DEFAULT_EINK_VIEW_SETTINGS,
} from './constants';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { getOSPlatform, getTargetLang, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc';
import {
getOSPlatform,
getTargetLang,
isCJKEnv,
isContentURI,
isValidURL,
makeSafeFilename,
} from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import {
downloadFile,
@@ -114,6 +121,12 @@ export abstract class BaseAppService implements AppService {
abstract setCustomRootDir(customRootDir: string): Promise<void>;
abstract selectDirectory(mode: SelectDirectoryMode): Promise<string>;
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
abstract saveFile(
filename: string,
content: string | ArrayBuffer,
filepath: string,
mimeType?: string,
): Promise<boolean>;
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
if (lastMigrationVersion < 20251124) {
@@ -501,15 +514,23 @@ export abstract class BaseAppService implements AppService {
}
}
async uploadFileToCloud(lfp: string, cfp: string, handleProgress: ProgressHandler, hash: string) {
async uploadFileToCloud(
lfp: string,
cfp: string,
base: BaseDir,
handleProgress: ProgressHandler,
hash: string,
temp: boolean = false,
) {
console.log('Uploading file:', lfp, 'to', cfp);
const file = await this.fs.openFile(lfp, 'Books', cfp);
const localFullpath = `${this.localBooksDir}/${lfp}`;
await uploadFile(file, localFullpath, handleProgress, hash);
const file = await this.fs.openFile(lfp, base, cfp);
const localFullpath = await this.resolveFilePath(lfp, base);
const downloadUrl = await uploadFile(file, localFullpath, handleProgress, hash, temp);
const f = file as ClosableFile;
if (f && f.close) {
await f.close();
}
return downloadUrl;
}
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
@@ -536,7 +557,7 @@ export abstract class BaseAppService implements AppService {
if (coverExist) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getCoverFilename(book)}`;
await this.uploadFileToCloud(lfp, cfp, handleProgress, book.hash);
await this.uploadFileToCloud(lfp, cfp, 'Books', handleProgress, book.hash);
uploaded = true;
completedFiles.count++;
}
@@ -544,7 +565,7 @@ export abstract class BaseAppService implements AppService {
if (bookFileExist) {
const lfp = getLocalBookFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`;
await this.uploadFileToCloud(lfp, cfp, handleProgress, book.hash);
await this.uploadFileToCloud(lfp, cfp, 'Books', handleProgress, book.hash);
uploaded = true;
completedFiles.count++;
}
@@ -661,6 +682,15 @@ export abstract class BaseAppService implements AppService {
}
}
async exportBook(book: Book): Promise<boolean> {
const { file } = await this.loadBookContent(book);
const content = await file.arrayBuffer();
const filename = `${makeSafeFilename(book.title)}.${book.format.toLowerCase()}`;
const filepath = await this.resolveFilePath(getLocalBookFilename(book), 'Books');
const fileType = file.type || 'application/octet-stream';
return await this.saveFile(filename, content, filepath, fileType);
}
async isBookAvailable(book: Book): Promise<boolean> {
const fp = getLocalBookFilename(book);
if (await this.fs.exists(fp, 'Books')) {

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