Compare commits

...

63 Commits

Author SHA1 Message Date
Huang Xin f53cee9616 release: version 0.9.97 (#2909) 2026-01-10 15:51:20 +01:00
Huang Xin 30385ee5ec fix(settings): correctly setting configs for selected book in parallel read, closes #2825 (#2908) 2026-01-10 15:01:04 +01:00
江夏尧 b30dfb3e23 fix(fonts): update font CDN links to use deno.dev for improved reliability (#2906) 2026-01-10 13:47:19 +01:00
Huang Xin c0d6102857 fix(css): primary btn style for e-ink mode (#2905) 2026-01-10 13:13:32 +01:00
Huang Xin 4537c55e84 fix(css): respect css for page break and minimum font size, closes #2895 (#2903) 2026-01-10 10:30:21 +01:00
Huang Xin aff94c0ab8 fix(windows): update shortcut to point to current installation, closes #2878 (#2902) 2026-01-10 10:26:32 +01:00
Huang Xin fd70836308 fix(windows): update shortcut to point to current installation, closes #2878 (#2901) 2026-01-10 09:53:40 +01:00
Huang Xin 1b0c94b9a5 fix(opds): temporary workaround for self-signed cert for OPDS server, closes #2871 (#2900)
Related to https://github.com/seanmonstar/reqwest/issues/1554
2026-01-10 08:47:12 +01:00
Huang Xin 7cb523eefc fix(metadata): no need to download book to display book details, closes #2857 (#2897) 2026-01-09 15:58:01 +01:00
Huang Xin 941be80cc4 fix(css): table and header layout, closes #2874 (#2896) 2026-01-09 15:06:37 +01:00
Huang Xin 5eecc735aa fix(proofread): support text replacement for text with no word boundaries, closes #2889 (#2894) 2026-01-09 10:27:38 +01:00
Huang Xin cfe51d01ee fix(sanitizer): normalize non-breaking spaces for WebView compatibility (#2893)
Convert   to   before sanitization and restore after XMLSerializer
serialization. This handles the platform difference where XMLSerializer
produces different representations of non-breaking spaces ( ,  ,
or \u00A0) across different WebView implementations.
2026-01-09 09:30:04 +01:00
Huang Xin 20ae09c52d perf(sync): persist partial library sync to prevent full retry on failure (#2892) 2026-01-09 09:23:02 +01:00
Huang Xin b868146129 feat(config): add an option to toggle footbar by tapping on the footbar (#2891) 2026-01-09 03:22:34 +01:00
Hermotimus a312080f7c fix(tts): footnotes anchors and superscript filtering (#2890) 2026-01-09 02:16:07 +01:00
Huang Xin 462ca46fee feat(eink): optimize color and layout for e-ink mode (#2887) 2026-01-08 17:29:33 +01:00
Huang Xin 93228c4b2a fix: avoid hydration mismatch for tauri app (#2884) 2026-01-08 04:05:10 +01:00
Huang Xin 2ff10f781f feat(annotator): support vertical layout for annotation editor (#2882) 2026-01-07 16:18:57 +01:00
Huang Xin 9614c62360 feat(annotator): instant highlighting with mouse, touch or pen (#2881) 2026-01-07 16:13:37 +01:00
lcd1232 5ffaac5e67 fix(login): fix login message field (#2879) 2026-01-07 12:55:11 +01:00
Huang Xin 71af91608f feat(annotator): support editing text range of highlights (#2870) 2026-01-05 16:25:58 +01:00
Huang Xin 9603b61776 refactor(annotatot): more cleaner text selector on Android (#2867) 2026-01-05 08:53:38 +01:00
Huang Xin eed84a059a fix(search): should be able to terminate search when sidebar is invisible (#2863) 2026-01-04 17:10:49 +01:00
Huang Xin 604ef65719 perf(search): use cache for search results (#2861) 2026-01-04 12:56:40 +01:00
Huang Xin 483d536ca4 feat(search): support search terms history, closes #2836 (#2859) 2026-01-04 07:47:43 +01:00
Huang Xin 69a51c5880 compat(footnote): support alt footnote inside placeholder anchor (#2858) 2026-01-04 06:06:24 +01:00
Huang Xin ca8d25341e compat(opds): support Komga OPDS v1.2 and v2, closes #2839 (#2851) 2026-01-03 18:04:39 +01:00
Huang Xin 8a263235ed feat(search): add annotations navigation bar, closes #2060 (#2849) 2026-01-03 14:57:10 +01:00
Huang Xin fb41ff5d0c fix(tts): reduce the pause duration between sentences in Edge TTS, closes #2837 (#2844) 2026-01-03 08:44:15 +01:00
Huang Xin a5e09e8454 feat(search): add search results navigation bar, closes #1183 (#2842)
Add a floating navigation component that appears when search results exist.
The component includes:
   - Top bar: displays search term and current section with TOC and close buttons
   - Bottom bar: search results, previous, and next navigation buttons
   - Page-based navigation using isCfiInLocation to skip between pages with results
2026-01-03 08:10:23 +01:00
Huang Xin 730ee21651 fix(android): don't navigate back with the back button for more predictable navigation (#2835)
This will revert the link navigation with back button in #2454.
This will close #2833 and more user reports that found back link navigation confusing.
2026-01-02 15:44:47 +01:00
Huang Xin dea43445c3 fix(layout): set image width for vertical inline images (#2832) 2026-01-02 14:54:02 +01:00
Huang Xin 4f0ae78d43 fix(ios): fixed error when importing file with urlencoded names (#2831) 2026-01-02 14:35:11 +01:00
Huang Xin d9116d619a feat(css): support overriding link color (#2830) 2026-01-02 14:15:02 +01:00
Huang Xin c080e6fdd3 fix(metadata): fixed sometimes svg book cover is rendered blank (#2829) 2026-01-02 14:13:24 +01:00
Huang Xin d3752dadc6 fix(library): upload files also for open with import and opds import, closes #2826 (#2827) 2026-01-02 13:32:52 +01:00
André Angelantoni e21ef53a3d fix(settings): ensure global settings sync across all open panes (#2818)
* fix(settings): ensure global settings sync across all open panes

Previously, changing a global setting (like font size) only updated the currently active book pane, causing split-view panes to desynchronize.

This commit updates the settings logic to propagate global changes to all open book instances immediately. It also ensures that settings UI panels correctly re-render when preferences are updated from outside the component.

* refactor to reuse some code

* fix: pointer in doc check

---------

Co-authored-by: André Angelantoni <andre.angelantoni@performantlabs.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-01-01 07:12:07 +01:00
Huang Xin 71e97998b6 feat(transfer): add a background transfer queue system for book uploads/downloads (#2821) 2025-12-31 08:40:28 +01:00
Huang Xin 22f9c45232 fix(annotator): delay highlight action to prevent immediate onShowAnnotation trigger (#2820) 2025-12-31 08:35:26 +01:00
Huang Xin a42897ec5c feat(annotator): support quick actions for text selection, closes #2505 (#2813) 2025-12-30 10:32:01 +01:00
Huang Xin 58a5c1625c fix(css): only override background color of hr when it has background image (#2808) 2025-12-30 10:24:37 +01:00
Huang Xin d53fc09e1e chore: bump opennext, wrangler and serwist to the latest versions (#2807) 2025-12-29 05:12:32 +01:00
Huang Xin 11fecb5dc0 chore: bump tauri to latest dev (#2805) 2025-12-29 04:07:58 +01:00
Huang Xin f4587663b5 chore(ios): drop support for iOS below 15.0 (#2804) 2025-12-29 03:41:12 +01:00
Huang Xin b76a2adf61 compat(epub): detect missing writing direction for some EPUBs (#2803) 2025-12-28 19:43:49 +01:00
Huang Xin 988fbc8c85 fix(settings): override book text align of left (#2802) 2025-12-28 18:02:56 +01:00
Huang Xin f944ad9b9f feat(annotator): support rendering vertical annotation bubbles (#2801) 2025-12-28 13:52:12 +01:00
Huang Xin 335d91b9c9 fix(layout): dismiss annotation popup on mobile when navigating (#2799) 2025-12-28 07:18:03 +01:00
Huang Xin 173404eaad feat(annotator): show notes popup when annotation bubble icon is clicked, closes #1845 (#2798) 2025-12-27 17:31:47 +01:00
Huang Xin 674fed5230 fix(annotator): adjust underline position to the center of two lines, closes #2772 (#2793) 2025-12-26 09:51:02 +01:00
Huang Xin 12d284cd22 fix(layout): fixed line clamp for config items on small screens (#2792) 2025-12-26 05:05:37 +01:00
Huang Xin 77037c8adb fix(sw): use NetworkFirst for navigation to prevent blank page on updates (#2784) 2025-12-24 15:19:14 +01:00
Huang Xin 6984393ed1 refactor(proofread): refactor UI and i18n for proofread tool (#2783) 2025-12-24 14:46:33 +01:00
Huang Xin 3abef6ea75 fix(opds): correctly parse file extension for CBZ files from OPDS servers, closes #2765 (#2771) 2025-12-23 08:53:50 +01:00
Huang Xin 4ae1ab7dd0 fix(layout): workaround for hardcoded image container (#2769) 2025-12-23 06:31:09 +01:00
Huang Xin 69fb22119b fix(layout): tweak insets for vertical layout (#2767) 2025-12-23 06:01:06 +01:00
Huang Xin 3a6c18c6d5 feat(font): support OpenType feature of vrt2 and vert for vertical replacement of some punctuations (#2764) 2025-12-22 18:39:09 +01:00
Huang Xin 7db1bc460d chore(pwa): migrate from next-pwa to serwist (#2762) 2025-12-22 16:10:09 +01:00
Huang Xin a460e609fa refactor(file): avoid eviction race of chunks cache (#2761) 2025-12-22 05:59:40 +01:00
Huang Xin 4b4ebdbdaa fix(pdf): avoid frequent eviction of chunk cache from worker thread, closes #2757 (#2759) 2025-12-21 17:47:41 +01:00
Huang Xin 9358a06839 compat(layout): grid layout with fit covers on older browsers, closes #2745 (#2756) 2025-12-21 08:52:57 +01:00
Huang Xin a7baf6cc9f fix(tts): prompt users to log in to use Edge TTS on the web version (#2749) 2025-12-19 17:54:17 +01:00
Huang Xin 15dc669f35 chore: update app metainfo (#2744) 2025-12-19 04:54:24 +01:00
183 changed files with 10413 additions and 6384 deletions
+8 -83
View File
@@ -1,4 +1,4 @@
import withPWAInit from '@ducanh2912/next-pwa';
import withSerwistInit from '@serwist/next';
import withBundleAnalyzer from '@next/bundle-analyzer';
const isDev = process.env['NODE_ENV'] === 'development';
@@ -33,7 +33,6 @@ const nextConfig = {
'i18next-browser-languagedetector',
'react-i18next',
'i18next',
'@ducanh2912/next-pwa',
'@tauri-apps',
'highlight.js',
'foliate-js',
@@ -68,88 +67,14 @@ const pwaDisabled = isDev || appPlatform !== 'web';
const withPWA = pwaDisabled
? (config) => config
: withPWAInit({
dest: 'public',
cacheStartUrl: false,
dynamicStartUrl: false,
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
: withSerwistInit({
swSrc: 'src/sw.ts',
swDest: 'public/sw.js',
cacheOnNavigation: true,
reloadOnOnline: true,
swcMinify: true,
fallbacks: {
document: '/offline',
},
workboxOptions: {
disableDevLogs: true,
runtimeCaching: [
{
urlPattern: ({ url, request }) => {
const clientRoutes = ['/library', '/reader'];
const isClientRoute = clientRoutes.some((route) => url.pathname.startsWith(route));
return isClientRoute && request.mode === 'navigate';
},
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'pages-cache',
expiration: {
maxAgeSeconds: 365 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
const basePath = url.pathname.split('/')[1];
const cacheKey = `${url.origin}/${basePath}`;
return cacheKey;
},
},
],
},
},
{
urlPattern: ({ url }) => {
if (url.pathname.startsWith('/api/')) {
return false;
}
return /^https?.*/.test(url.href);
},
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'offlineCache',
expiration: {
maxEntries: 512,
maxAgeSeconds: 365 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
manifestTransforms: [
(manifestEntries) => {
const manifest = manifestEntries.filter((entry) => {
const url = entry.url;
return (
!url.includes('dynamic-css-manifest.json') &&
!url.includes('middleware-manifest.json') &&
!url.includes('react-loadable-manifest.json') &&
!url.includes('build-manifest.json') &&
!url.includes('_buildManifest.js') &&
!url.includes('_ssgManifest.js') &&
!url.includes('_headers')
);
});
return { manifest };
},
],
},
disable: false,
register: true,
scope: '/',
});
const withAnalyzer = withBundleAnalyzer({
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.96",
"version": "0.9.97",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -53,9 +53,9 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.735.0",
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.13.1",
"@opennextjs/cloudflare": "^1.14.7",
"@serwist/next": "^9.4.2",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@supabase/auth-ui-react": "^0.4.7",
@@ -154,10 +154,11 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"serwist": "^9.3.0",
"tailwindcss": "^3.4.18",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.15",
"wrangler": "^4.50.0"
"wrangler": "^4.56.0"
}
}
@@ -805,5 +805,76 @@
"Count": "العدد",
"Start Page": "الصفحة الأولى",
"Search in OPDS Catalog...": "البحث في كتالوج OPDS...",
"Please log in to use advanced TTS features.": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة."
"Please log in to use advanced TTS features": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة.",
"Word limit of 30 words exceeded.": "تم تجاوز حد الكلمات البالغ 30 كلمة.",
"Proofread": "التدقيق اللغوي",
"Current selection": "النص المحدد الحالي",
"All occurrences in this book": "جميع الحالات في هذا الكتاب",
"All occurrences in your library": "جميع الحالات في مكتبتك",
"Selected text:": "النص المحدد:",
"Replace with:": "استبدال بـ:",
"Enter text...": "أدخل النص...",
"Case sensitive:": "حساس لحالة الأحرف:",
"Scope:": "النطاق:",
"Selection": "التحديد",
"Library": "المكتبة",
"Yes": "نعم",
"No": "لا",
"Proofread Replacement Rules": "قواعد استبدال التدقيق اللغوي",
"Selected Text Rules": "قواعد النص المحدد",
"No selected text replacement rules": "لا توجد قواعد استبدال نص محدد",
"Book Specific Rules": "قواعد خاصة بالكتاب",
"No book-level replacement rules": "لا توجد قواعد استبدال على مستوى الكتاب",
"Disable Quick Action": "تعطيل الإجراء السريع",
"Enable Quick Action on Selection": "تمكين الإجراء السريع عند التحديد",
"None": "لا شيء",
"Annotation Tools": "أدوات التعليق",
"Enable Quick Actions": "تمكين الإجراءات السريعة",
"Quick Action": "الإجراء السريع",
"Copy to Notebook": "نسخ إلى الدفتر",
"Copy text after selection": "نسخ النص بعد التحديد",
"Highlight text after selection": "تمييز النص بعد التحديد",
"Annotate text after selection": "تعليق على النص بعد التحديد",
"Search text after selection": "البحث في النص بعد التحديد",
"Look up text in dictionary after selection": "البحث عن النص في القاموس بعد التحديد",
"Look up text in Wikipedia after selection": "البحث عن النص في ويكيبيديا بعد التحديد",
"Translate text after selection": "ترجمة النص بعد التحديد",
"Read text aloud after selection": "قراءة النص بصوت عالٍ بعد التحديد",
"Proofread text after selection": "تدقيق النص بعد التحديد",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} نشط، {{pendingCount}} قيد الانتظار",
"{{failedCount}} failed": "{{failedCount}} فشل",
"Waiting...": "جارٍ الانتظار...",
"Failed": "فشل",
"Completed": "مكتمل",
"Cancelled": "ملغى",
"Retry": "إعادة المحاولة",
"Active": "نشط",
"Transfer Queue": "قائمة انتظار النقل",
"Upload All": "رفع الكل",
"Download All": "تنزيل الكل",
"Resume Transfers": "استئناف النقل",
"Pause Transfers": "إيقاف النقل مؤقتًا",
"Pending": "قيد الانتظار",
"No transfers": "لا توجد عمليات نقل",
"Retry All": "إعادة محاولة الكل",
"Clear Completed": "مسح المكتمل",
"Clear Failed": "مسح الفاشل",
"Upload queued: {{title}}": "الرفع في قائمة الانتظار: {{title}}",
"Download queued: {{title}}": "التنزيل في قائمة الانتظار: {{title}}",
"Book not found in library": "الكتاب غير موجود في المكتبة",
"Unknown error": "خطأ غير معروف",
"Please log in to continue": "يرجى تسجيل الدخول للمتابعة",
"Cloud File Transfers": "نقل الملفات السحابية",
"Show Search Results": "عرض نتائج البحث",
"Search results for '{{term}}'": "نتائج البحث عن «{{term}}»",
"Close Search": "إغلاق البحث",
"Previous Result": "النتيجة السابقة",
"Next Result": "النتيجة التالية",
"Bookmarks": "الإشارات المرجعية",
"Annotations": "التعليقات التوضيحية",
"Show Results": "عرض النتائج",
"Clear search": "مسح البحث",
"Clear search history": "مسح سجل البحث",
"Quick action disabled": "الإجراء السريع معطل",
"Tap to Toggle Footer": "انقر لتبديل التذييل"
}
@@ -765,5 +765,76 @@
"Count": "গণনা",
"Start Page": "শুরু পৃষ্ঠা",
"Search in OPDS Catalog...": "OPDS ক্যাটালগে অনুসন্ধান করুন...",
"Please log in to use advanced TTS features.": "উন্নত TTS বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।"
"Please log in to use advanced TTS features": "উন্নত TTS বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।",
"Word limit of 30 words exceeded.": "৩০ শব্দের শব্দসীমা অতিক্রম হয়েছে।",
"Proofread": "প্রুফরিড",
"Current selection": "বর্তমান নির্বাচন",
"All occurrences in this book": "এই বইয়ে সমস্ত ঘটনা",
"All occurrences in your library": "আপনার লাইব্রেরির সমস্ত ঘটনা",
"Selected text:": "নির্বাচিত টেক্সট:",
"Replace with:": "এর সাথে প্রতিস্থাপন করুন:",
"Enter text...": "টেক্সট লিখুন...",
"Case sensitive:": "কেস সংবেদনশীল:",
"Scope:": "পরিসর:",
"Selection": "নির্বাচন",
"Library": "লাইব্রেরি",
"Yes": "হ্যাঁ",
"No": "না",
"Proofread Replacement Rules": "প্রুফরিড বিকল্প নিয়ম",
"Selected Text Rules": "নির্বাচিত টেক্সট নিয়ম",
"No selected text replacement rules": "কোনও নির্বাচিত টেক্সট বিকল্প নিয়ম নেই",
"Book Specific Rules": "বই নির্দিষ্ট নিয়ম",
"No book-level replacement rules": "কোনও বই-স্তরের বিকল্প নিয়ম নেই",
"Disable Quick Action": "দ্রুত ক্রিয়া নিষ্ক্রিয় করুন",
"Enable Quick Action on Selection": "নির্বাচনের উপর দ্রুত ক্রিয়া সক্ষম করুন",
"None": "কোনোটিই নয়",
"Annotation Tools": "অ্যনোটেশন সরঞ্জামসমূহ",
"Enable Quick Actions": "দ্রুত ক্রিয়া সক্ষম করুন",
"Quick Action": "দ্রুত ক্রিয়া",
"Copy to Notebook": "নোটবুকে কপি করুন",
"Copy text after selection": "নির্বাচনের পরে টেক্সট কপি করুন",
"Highlight text after selection": "নির্বাচনের পরে টেক্সট হাইলাইট করুন",
"Annotate text after selection": "নির্বাচনের পরে টেক্সট মন্তব্য করুন",
"Search text after selection": "নির্বাচনের পরে টেক্সট অনুসন্ধান করুন",
"Look up text in dictionary after selection": "নির্বাচনের পরে টেক্সট অভিধানে দেখুন",
"Look up text in Wikipedia after selection": "নির্বাচনের পরে টেক্সট উইকিপিডিয়ায় দেখুন",
"Translate text after selection": "নির্বাচনের পরে টেক্সট অনুবাদ করুন",
"Read text aloud after selection": "নির্বাচনের পরে টেক্সট উচ্চারণ করুন",
"Proofread text after selection": "নির্বাচনের পরে টেক্সট প্রুফরিড করুন",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} সক্রিয়, {{pendingCount}} অপেক্ষমাণ",
"{{failedCount}} failed": "{{failedCount}} ব্যর্থ",
"Waiting...": "অপেক্ষা করছে...",
"Failed": "ব্যর্থ",
"Completed": "সম্পন্ন",
"Cancelled": "বাতিল",
"Retry": "পুনরায় চেষ্টা করুন",
"Active": "সক্রিয়",
"Transfer Queue": "স্থানান্তর সারি",
"Upload All": "সব আপলোড করুন",
"Download All": "সব ডাউনলোড করুন",
"Resume Transfers": "স্থানান্তর পুনরায় শুরু করুন",
"Pause Transfers": "স্থানান্তর বিরতি দিন",
"Pending": "অপেক্ষমাণ",
"No transfers": "কোনো স্থানান্তর নেই",
"Retry All": "সব পুনরায় চেষ্টা করুন",
"Clear Completed": "সম্পন্ন মুছুন",
"Clear Failed": "ব্যর্থ মুছুন",
"Upload queued: {{title}}": "আপলোড সারিতে: {{title}}",
"Download queued: {{title}}": "ডাউনলোড সারিতে: {{title}}",
"Book not found in library": "লাইব্রেরিতে বই পাওয়া যায়নি",
"Unknown error": "অজানা ত্রুটি",
"Please log in to continue": "চালিয়ে যেতে লগইন করুন",
"Cloud File Transfers": "ক্লাউড ফাইল স্থানান্তরসমূহ",
"Show Search Results": "অনুসন্ধান ফলাফল দেখান",
"Search results for '{{term}}'": "'{{term}}' এর ফলাফল",
"Close Search": "অনুসন্ধান বন্ধ করুন",
"Previous Result": "আগের ফলাফল",
"Next Result": "পরবর্তী ফলাফল",
"Bookmarks": "বুকমার্ক",
"Annotations": "টীকা",
"Show Results": "ফলাফল দেখুন",
"Clear search": "অনুসন্ধান সাফ করুন",
"Clear search history": "অনুসন্ধান ইতিহাস সাফ করুন",
"Quick action disabled": "দ্রুত কার্যক্রম অক্ষম",
"Tap to Toggle Footer": "ফুটার টগল করতে আলতো চাপুন"
}
@@ -755,5 +755,76 @@
"Count": "ཨང་",
"Start Page": "ཤོག་ངོས་དང་པོ",
"Search in OPDS Catalog...": "OPDS དཀར་ཆག་ནང་འཚོལ།...",
"Please log in to use advanced TTS features.": "དབང་བསྐྱོད་ཀྱི TTS རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།"
"Please log in to use advanced TTS features": "དབང་བསྐྱོད་ཀྱི TTS རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།",
"Word limit of 30 words exceeded.": "ཚིག་གཅིག 30 ལྟར་འདོད་མེད།",
"Proofread": "མིག་སྔའི་བཤད་རྒྱུན།",
"Current selection": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"All occurrences in this book": "དེབ་ནང་གི་འདི་ཚུགས་ཀྱི་འཚོལ་ཞིབ།",
"All occurrences in your library": "ཁྱོད་ཀྱི་དེབ་མཛོད་ནང་གི་འདི་ཚུགས་ཀྱི་འཚོལ་ཞིབ།",
"Selected text:": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Replace with:": "བརྗེ་བ།:",
"Enter text...": "ཡིག་ཆ་ལ་འགྲོ།...",
"Case sensitive:": "གནས་སྟངས་འདི་ལ་འབད་དགོས།",
"Scope:": "གནས་ཚུལ།:",
"Selection": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Library": "དེབ་མཛོད།",
"Yes": "ཨིན་",
"No": "མེད་",
"Proofread Replacement Rules": "མིག་སྔའི་བཤད་རྒྱུན་བརྗེ་བའི་རྣམ་པ།",
"Selected Text Rules": "བདམས་པའི་ཡིག་ཆ་བརྗེ་སྒྱུར་སྲིད་སྒྲིག",
"No selected text replacement rules": "བདམས་པའི་ཡིག་ཆ་བརྗེ་སྒྱུར་སྲིད་སྒྲིག་མེད་པ",
"Book Specific Rules": "དེབ་སྒོས་ཀྱི་བརྗེ་སྒྱུར་སྲིད་སྒྲིག",
"No book-level replacement rules": "དེབ་རིམ་གྱི་བརྗེ་སྒྱུར་སྲིད་སྒྲིག་མེད་པ",
"Disable Quick Action": "མགྱོགས་མྱུར་བྱ་བ་སྤང་",
"Enable Quick Action on Selection": "འདེམས་པའི་སྐབས་མགྱོགས་མྱུར་བྱ་བ་སྤྱོད་",
"None": "མེད་",
"Annotation Tools": "མཆན་འགྲེལ་ལག་ཆ་",
"Enable Quick Actions": "མགྱོགས་མྱུར་བྱ་བ་སྤྱོད་",
"Quick Action": "མགྱོགས་མྱུར་བྱ་བ་",
"Copy to Notebook": "ཟིན་དེབ་ནང་འདྲ་བཤུས་",
"Copy text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་འདྲ་བཤུས་",
"Highlight text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་མངོན་གསལ་བྱེད་",
"Annotate text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་མཆན་འགྲེལ་བྱེད་",
"Search text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་འཚོལ་ཞིབ་བྱེད་",
"Look up text in dictionary after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཚིག་མཛོད་ནང་འཚོལ་",
"Look up text in Wikipedia after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཝེ་ཀི་པི་ཌི་ཡི་ནང་འཚོལ་",
"Translate text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་སྐད་བསྒྱུར་བྱེད་",
"Read text aloud after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་སྐད་ཀྱིས་ཀློག་",
"Proofread text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཞིབ་བཤེར་བྱེད་",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} བྱེད་བཞིན་པ་, {{pendingCount}} སྒུག་བཞིན་པ་",
"{{failedCount}} failed": "{{failedCount}} ཕམ་པ་",
"Waiting...": "སྒུག་བཞིན་པ་...",
"Failed": "ཕམ་པ་",
"Completed": "གྲུབ་སོང་",
"Cancelled": "དོར་སོང་",
"Retry": "བསྐྱར་ཚོད་",
"Active": "བྱེད་བཞིན་པ་",
"Transfer Queue": "སྐྱེལ་འདྲེན་སྒྲིག་གཅོད་",
"Upload All": "ཚང་མ་འཇོག་",
"Download All": "ཚང་མ་ལེན་",
"Resume Transfers": "སྐྱེལ་འདྲེན་མུ་མཐུད་",
"Pause Transfers": "སྐྱེལ་འདྲེན་མཚམས་འཇོག་",
"Pending": "སྒུག་བཞིན་པ་",
"No transfers": "སྐྱེལ་འདྲེན་མེད་",
"Retry All": "ཚང་མ་བསྐྱར་ཚོད་",
"Clear Completed": "གྲུབ་པ་གསལ་བ་",
"Clear Failed": "ཕམ་པ་གསལ་བ་",
"Upload queued: {{title}}": "འཇོག་པའི་སྒྲིག་གཅོད་ནང་བཅུག་: {{title}}",
"Download queued: {{title}}": "ལེན་པའི་སྒྲིག་གཅོད་ནང་བཅུག་: {{title}}",
"Book not found in library": "དཔེ་མཛོད་ནང་དཔེ་ཆ་རྙེད་མ་སོང་",
"Unknown error": "མ་ཤེས་པའི་ནོར་འཁྲུལ་",
"Please log in to continue": "མུ་མཐུད་པར་ནང་འཛུལ་བྱོས་",
"Cloud File Transfers": "སྤྲིན་གནས་ཡིག་ཆ་སྐྱེལ་འདྲེན།",
"Show Search Results": "འཚོལ་བཤེར་འབྲས་བུ་སྟོན།",
"Search results for '{{term}}'": "'{{term}}'ཡི་འབྲས་བུ།",
"Close Search": "འཚོལ་བཤེར་སྒོ་རྒྱག",
"Previous Result": "སྔོན་མའི་འབྲས་བུ།",
"Next Result": "རྗེས་མའི་འབྲས་བུ།",
"Bookmarks": "དཔེ་རྟགས།",
"Annotations": "མཆན།",
"Show Results": "འབྲས་བུ་སྟོན།",
"Clear search": "འཚོལ་བཤེར་གཙང་སེལ།",
"Clear search history": "འཚོལ་བཤེར་ལོ་རྒྱུས་གཙང་སེལ།",
"Quick action disabled": "མྱུར་མགྱོགས་བྱ་འགུལ་བཀག་འགོག",
"Tap to Toggle Footer": "ཞབས་མཇུག་སྒོ་འབྱེད་བྱེད་པར་གནོན།"
}
@@ -765,5 +765,76 @@
"Count": "Anzahl",
"Start Page": "Startseite",
"Search in OPDS Catalog...": "Im OPDS-Katalog suchen...",
"Please log in to use advanced TTS features.": "Bitte melde dich an, um erweiterte TTS-Funktionen zu nutzen."
"Please log in to use advanced TTS features": "Bitte melde dich an, um erweiterte TTS-Funktionen zu nutzen",
"Word limit of 30 words exceeded.": "Das Wortlimit von 30 Wörtern wurde überschritten.",
"Proofread": "Korrekturlesen",
"Current selection": "Aktuelle Auswahl",
"All occurrences in this book": "Alle Vorkommen in diesem Buch",
"All occurrences in your library": "Alle Vorkommen in Ihrer Bibliothek",
"Selected text:": "Ausgewählter Text:",
"Replace with:": "Ersetzen durch:",
"Enter text...": "Text eingeben …",
"Case sensitive:": "Groß-/Kleinschreibung:",
"Scope:": "Geltungsbereich:",
"Selection": "Auswahl",
"Library": "Bibliothek",
"Yes": "Ja",
"No": "Nein",
"Proofread Replacement Rules": "Korrektur-Ersetzungsregeln",
"Selected Text Rules": "Regeln für ausgewählten Text",
"No selected text replacement rules": "Keine Ersetzungsregeln für ausgewählten Text",
"Book Specific Rules": "Buchspezifische Regeln",
"No book-level replacement rules": "Keine Ersetzungsregeln auf Buchebene",
"Disable Quick Action": "Schnellaktion deaktivieren",
"Enable Quick Action on Selection": "Schnellaktion bei Auswahl aktivieren",
"None": "Keine",
"Annotation Tools": "Anmerkungswerkzeuge",
"Enable Quick Actions": "Schnellaktionen aktivieren",
"Quick Action": "Schnellaktion",
"Copy to Notebook": "In Notizbuch kopieren",
"Copy text after selection": "Text nach Auswahl kopieren",
"Highlight text after selection": "Text nach Auswahl hervorheben",
"Annotate text after selection": "Text nach Auswahl annotieren",
"Search text after selection": "Text nach Auswahl suchen",
"Look up text in dictionary after selection": "Text nach Auswahl im Wörterbuch nachschlagen",
"Look up text in Wikipedia after selection": "Text nach Auswahl in Wikipedia nachschlagen",
"Translate text after selection": "Text nach Auswahl übersetzen",
"Read text aloud after selection": "Text nach Auswahl vorlesen",
"Proofread text after selection": "Text nach Auswahl Korrektur lesen",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktiv, {{pendingCount}} ausstehend",
"{{failedCount}} failed": "{{failedCount}} fehlgeschlagen",
"Waiting...": "Warten...",
"Failed": "Fehlgeschlagen",
"Completed": "Abgeschlossen",
"Cancelled": "Abgebrochen",
"Retry": "Erneut versuchen",
"Active": "Aktiv",
"Transfer Queue": "Übertragungswarteschlange",
"Upload All": "Alle hochladen",
"Download All": "Alle herunterladen",
"Resume Transfers": "Übertragungen fortsetzen",
"Pause Transfers": "Übertragungen pausieren",
"Pending": "Ausstehend",
"No transfers": "Keine Übertragungen",
"Retry All": "Alle erneut versuchen",
"Clear Completed": "Abgeschlossene löschen",
"Clear Failed": "Fehlgeschlagene löschen",
"Upload queued: {{title}}": "Upload in Warteschlange: {{title}}",
"Download queued: {{title}}": "Download in Warteschlange: {{title}}",
"Book not found in library": "Buch nicht in der Bibliothek gefunden",
"Unknown error": "Unbekannter Fehler",
"Please log in to continue": "Bitte melden Sie sich an, um fortzufahren",
"Cloud File Transfers": "Cloud-Dateiübertragungen",
"Show Search Results": "Suchergebnisse anzeigen",
"Search results for '{{term}}'": "Ergebnisse für '{{term}}'",
"Close Search": "Suche schließen",
"Previous Result": "Vorheriges Ergebnis",
"Next Result": "Nächstes Ergebnis",
"Bookmarks": "Lesezeichen",
"Annotations": "Anmerkungen",
"Show Results": "Ergebnisse anzeigen",
"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"
}
@@ -765,5 +765,76 @@
"Count": "Πλήθος",
"Start Page": "Αρχική Σελίδα",
"Search in OPDS Catalog...": "Αναζήτηση στον κατάλογο OPDS...",
"Please log in to use advanced TTS features.": "Παρακαλώ συνδεθείτε για να χρησιμοποιήσετε προηγμένες λειτουργίες TTS."
"Please log in to use advanced TTS features": "Παρακαλώ συνδεθείτε για να χρησιμοποιήσετε προηγμένες λειτουργίες TTS",
"Word limit of 30 words exceeded.": "Υπέρβαση ορίου 30 λέξεων.",
"Proofread": "Διόρθωση",
"Current selection": "Τρέχουσα επιλογή",
"All occurrences in this book": "Όλες οι εμφανίσεις σε αυτό το βιβλίο",
"All occurrences in your library": "Όλες οι εμφανίσεις στη βιβλιοθήκη σας",
"Selected text:": "Επιλεγμένο κείμενο:",
"Replace with:": "Αντικατάσταση με:",
"Enter text...": "Εισαγωγή κειμένου…",
"Case sensitive:": "Διάκριση πεζών-κεφαλαίων:",
"Scope:": "Εύρος εφαρμογής:",
"Selection": "Επιλογή",
"Library": "Βιβλιοθήκη",
"Yes": "Ναι",
"No": "Όχι",
"Proofread Replacement Rules": "Κανόνες αντικατάστασης διόρθωσης",
"Selected Text Rules": "Κανόνες επιλεγμένου κειμένου",
"No selected text replacement rules": "Δεν υπάρχουν κανόνες αντικατάστασης για το επιλεγμένο κείμενο",
"Book Specific Rules": "Κανόνες για συγκεκριμένο βιβλίο",
"No book-level replacement rules": "Δεν υπάρχουν κανόνες αντικατάστασης σε επίπεδο βιβλίου",
"Disable Quick Action": "Απενεργοποίηση γρήγορης ενέργειας",
"Enable Quick Action on Selection": "Ενεργοποίηση γρήγορης ενέργειας κατά την επιλογή",
"None": "Καμία",
"Annotation Tools": "Εργαλεία σχολιασμού",
"Enable Quick Actions": "Ενεργοποίηση γρήγορων ενεργειών",
"Quick Action": "Γρήγορη ενέργεια",
"Copy to Notebook": "Αντιγραφή στο σημειωματάριο",
"Copy text after selection": "Αντιγραφή κειμένου μετά την επιλογή",
"Highlight text after selection": "Επισήμανση κειμένου μετά την επιλογή",
"Annotate text after selection": "Σχολιασμός κειμένου μετά την επιλογή",
"Search text after selection": "Αναζήτηση κειμένου μετά την επιλογή",
"Look up text in dictionary after selection": "Αναζήτηση κειμένου στο λεξικό μετά την επιλογή",
"Look up text in Wikipedia after selection": "Αναζήτηση κειμένου στη Wikipedia μετά την επιλογή",
"Translate text after selection": "Μετάφραση κειμένου μετά την επιλογή",
"Read text aloud after selection": "Ανάγνωση κειμένου μετά την επιλογή",
"Proofread text after selection": "Διόρθωση κειμένου μετά την επιλογή",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} ενεργές, {{pendingCount}} σε αναμονή",
"{{failedCount}} failed": "{{failedCount}} απέτυχαν",
"Waiting...": "Αναμονή...",
"Failed": "Απέτυχε",
"Completed": "Ολοκληρώθηκε",
"Cancelled": "Ακυρώθηκε",
"Retry": "Επανάληψη",
"Active": "Ενεργές",
"Transfer Queue": "Ουρά μεταφορών",
"Upload All": "Ανέβασμα όλων",
"Download All": "Λήψη όλων",
"Resume Transfers": "Συνέχιση μεταφορών",
"Pause Transfers": "Παύση μεταφορών",
"Pending": "Σε αναμονή",
"No transfers": "Χωρίς μεταφορές",
"Retry All": "Επανάληψη όλων",
"Clear Completed": "Εκκαθάριση ολοκληρωμένων",
"Clear Failed": "Εκκαθάριση αποτυχημένων",
"Upload queued: {{title}}": "Ανέβασμα στην ουρά: {{title}}",
"Download queued: {{title}}": "Λήψη στην ουρά: {{title}}",
"Book not found in library": "Το βιβλίο δεν βρέθηκε στη βιβλιοθήκη",
"Unknown error": "Άγνωστο σφάλμα",
"Please log in to continue": "Παρακαλώ συνδεθείτε για να συνεχίσετε",
"Cloud File Transfers": "Μεταφορές αρχείων στο cloud",
"Show Search Results": "Εμφάνιση αποτελεσμάτων",
"Search results for '{{term}}'": "Αποτελέσματα για '{{term}}'",
"Close Search": "Κλείσιμο αναζήτησης",
"Previous Result": "Προηγούμενο αποτέλεσμα",
"Next Result": "Επόμενο αποτέλεσμα",
"Bookmarks": "Σελιδοδείκτες",
"Annotations": "Σχολιασμοί",
"Show Results": "Εμφάνιση αποτελεσμάτων",
"Clear search": "Εκκαθάριση αναζήτησης",
"Clear search history": "Εκκαθάριση ιστορικού αναζήτησης",
"Quick action disabled": "Η γρήγορη ενέργεια απενεργοποιήθηκε",
"Tap to Toggle Footer": "Πατήστε για εναλλαγή υποσέλιδου"
}
@@ -775,5 +775,76 @@
"Count": "Cuenta",
"Start Page": "Página de inicio",
"Search in OPDS Catalog...": "Buscar en el catálogo OPDS...",
"Please log in to use advanced TTS features.": "Por favor, inicie sesión para usar funciones avanzadas de TTS."
"Please log in to use advanced TTS features": "Por favor, inicie sesión para usar funciones avanzadas de TTS",
"Word limit of 30 words exceeded.": "Se superó el límite de 30 palabras.",
"Proofread": "Corrección",
"Current selection": "Selección actual",
"All occurrences in this book": "Todas las apariciones en este libro",
"All occurrences in your library": "Todas las apariciones en tu biblioteca",
"Selected text:": "Texto seleccionado:",
"Replace with:": "Reemplazar por:",
"Enter text...": "Introduce texto…",
"Case sensitive:": "Distinguir mayúsculas y minúsculas:",
"Scope:": "Ámbito:",
"Selection": "Selección",
"Library": "Biblioteca",
"Yes": "Sí",
"No": "No",
"Proofread Replacement Rules": "Reglas de reemplazo de corrección",
"Selected Text Rules": "Reglas de texto seleccionado",
"No selected text replacement rules": "No hay reglas de reemplazo para el texto seleccionado",
"Book Specific Rules": "Reglas específicas del libro",
"No book-level replacement rules": "No hay reglas de reemplazo a nivel de libro",
"Disable Quick Action": "Desactivar acción rápida",
"Enable Quick Action on Selection": "Activar acción rápida al seleccionar",
"None": "Ninguna",
"Annotation Tools": "Herramientas de anotación",
"Enable Quick Actions": "Activar acciones rápidas",
"Quick Action": "Acción rápida",
"Copy to Notebook": "Copiar al cuaderno",
"Copy text after selection": "Copiar texto después de la selección",
"Highlight text after selection": "Resaltar texto después de la selección",
"Annotate text after selection": "Anotar texto después de la selección",
"Search text after selection": "Buscar texto después de la selección",
"Look up text in dictionary after selection": "Buscar texto en el diccionario después de la selección",
"Look up text in Wikipedia after selection": "Buscar texto en Wikipedia después de la selección",
"Translate text after selection": "Traducir texto después de la selección",
"Read text aloud after selection": "Leer texto en voz alta después de la selección",
"Proofread text after selection": "Corregir texto después de la selección",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} activas, {{pendingCount}} pendientes",
"{{failedCount}} failed": "{{failedCount}} fallidas",
"Waiting...": "Esperando...",
"Failed": "Fallido",
"Completed": "Completado",
"Cancelled": "Cancelado",
"Retry": "Reintentar",
"Active": "Activas",
"Transfer Queue": "Cola de transferencias",
"Upload All": "Subir todos",
"Download All": "Descargar todos",
"Resume Transfers": "Reanudar transferencias",
"Pause Transfers": "Pausar transferencias",
"Pending": "Pendientes",
"No transfers": "Sin transferencias",
"Retry All": "Reintentar todos",
"Clear Completed": "Limpiar completados",
"Clear Failed": "Limpiar fallidos",
"Upload queued: {{title}}": "Subida en cola: {{title}}",
"Download queued: {{title}}": "Descarga en cola: {{title}}",
"Book not found in library": "Libro no encontrado en la biblioteca",
"Unknown error": "Error desconocido",
"Please log in to continue": "Inicia sesión para continuar",
"Cloud File Transfers": "Transferencias en la nube",
"Show Search Results": "Mostrar resultados",
"Search results for '{{term}}'": "Resultados para '{{term}}'",
"Close Search": "Cerrar búsqueda",
"Previous Result": "Resultado anterior",
"Next Result": "Resultado siguiente",
"Bookmarks": "Marcadores",
"Annotations": "Anotaciones",
"Show Results": "Mostrar resultados",
"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"
}
@@ -765,5 +765,76 @@
"Count": "تعداد",
"Start Page": "صفحه شروع",
"Search in OPDS Catalog...": "جستجو در فهرست OPDS...",
"Please log in to use advanced TTS features.": "لطفاً برای استفاده از ویژگی‌های پیشرفته TTS وارد شوید."
"Please log in to use advanced TTS features": "لطفاً برای استفاده از ویژگی‌های پیشرفته TTS وارد شوید.",
"Word limit of 30 words exceeded.": "محدودیت ۳۰ واژه‌ای تجاوز شد.",
"Proofread": "بازبینی",
"Current selection": "انتخاب فعلی",
"All occurrences in this book": "همه موارد در این کتاب",
"All occurrences in your library": "همه موارد در کتابخانه شما",
"Selected text:": "متن انتخاب‌شده:",
"Replace with:": "جایگزین با:",
"Enter text...": "متن را وارد کنید…",
"Case sensitive:": "حساس به حروف بزرگ و کوچک:",
"Scope:": "دامنه:",
"Selection": "انتخاب",
"Library": "کتابخانه",
"Yes": "بله",
"No": "خیر",
"Proofread Replacement Rules": "قوانین جایگزینی بازبینی",
"Selected Text Rules": "قوانین متن انتخاب‌شده",
"No selected text replacement rules": "هیچ قانون جایگزینی برای متن انتخاب‌شده وجود ندارد",
"Book Specific Rules": "قوانین مخصوص کتاب",
"No book-level replacement rules": "هیچ قانون جایگزینی در سطح کتاب وجود ندارد",
"Disable Quick Action": "غیرفعال‌سازی اقدام سریع",
"Enable Quick Action on Selection": "فعال‌سازی اقدام سریع هنگام انتخاب",
"None": "هیچ‌کدام",
"Annotation Tools": "ابزارهای یادداشت‌گذاری",
"Enable Quick Actions": "فعال‌سازی اقدامات سریع",
"Quick Action": "اقدام سریع",
"Copy to Notebook": "کپی به دفترچه",
"Copy text after selection": "کپی متن پس از انتخاب",
"Highlight text after selection": "برجسته‌سازی متن پس از انتخاب",
"Annotate text after selection": "یادداشت‌گذاری متن پس از انتخاب",
"Search text after selection": "جستجوی متن پس از انتخاب",
"Look up text in dictionary after selection": "جستجوی متن در فرهنگ لغت پس از انتخاب",
"Look up text in Wikipedia after selection": "جستجوی متن در ویکی‌پدیا پس از انتخاب",
"Translate text after selection": "ترجمه متن پس از انتخاب",
"Read text aloud after selection": "خواندن متن با صدای بلند پس از انتخاب",
"Proofread text after selection": "بازبینی متن پس از انتخاب",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} فعال، {{pendingCount}} در انتظار",
"{{failedCount}} failed": "{{failedCount}} ناموفق",
"Waiting...": "در انتظار...",
"Failed": "ناموفق",
"Completed": "تکمیل شده",
"Cancelled": "لغو شده",
"Retry": "تلاش مجدد",
"Active": "فعال",
"Transfer Queue": "صف انتقال",
"Upload All": "بارگذاری همه",
"Download All": "دانلود همه",
"Resume Transfers": "ادامه انتقال‌ها",
"Pause Transfers": "توقف انتقال‌ها",
"Pending": "در انتظار",
"No transfers": "بدون انتقال",
"Retry All": "تلاش مجدد همه",
"Clear Completed": "پاک کردن تکمیل شده‌ها",
"Clear Failed": "پاک کردن ناموفق‌ها",
"Upload queued: {{title}}": "بارگذاری در صف: {{title}}",
"Download queued: {{title}}": "دانلود در صف: {{title}}",
"Book not found in library": "کتاب در کتابخانه یافت نشد",
"Unknown error": "خطای ناشناخته",
"Please log in to continue": "لطفاً برای ادامه وارد شوید",
"Cloud File Transfers": "انتقال فایل‌های ابری",
"Show Search Results": "نمایش نتایج جستجو",
"Search results for '{{term}}'": "نتایج برای «{{term}}»",
"Close Search": "بستن جستجو",
"Previous Result": "نتیجه قبلی",
"Next Result": "نتیجه بعدی",
"Bookmarks": "نشانک‌ها",
"Annotations": "یادداشت‌ها",
"Show Results": "نمایش نتایج",
"Clear search": "پاک کردن جستجو",
"Clear search history": "پاک کردن تاریخچه جستجو",
"Quick action disabled": "عملکرد سریع غیرفعال است",
"Tap to Toggle Footer": "برای تغییر پاورقی ضربه بزنید"
}
@@ -775,5 +775,76 @@
"Count": "Nombre",
"Start Page": "Page de départ",
"Search in OPDS Catalog...": "Rechercher dans le catalogue OPDS...",
"Please log in to use advanced TTS features.": "Veuillez vous connecter pour utiliser les fonctionnalités avancées de TTS."
"Please log in to use advanced TTS features": "Veuillez vous connecter pour utiliser les fonctionnalités avancées de TTS",
"Word limit of 30 words exceeded.": "La limite de 30 mots a été dépassée.",
"Proofread": "Correction",
"Current selection": "Sélection actuelle",
"All occurrences in this book": "Toutes les occurrences dans ce livre",
"All occurrences in your library": "Toutes les occurrences dans votre bibliothèque",
"Selected text:": "Texte sélectionné :",
"Replace with:": "Remplacer par :",
"Enter text...": "Saisir du texte…",
"Case sensitive:": "Respecter la casse :",
"Scope:": "Portée :",
"Selection": "Sélection",
"Library": "Bibliothèque",
"Yes": "Oui",
"No": "Non",
"Proofread Replacement Rules": "Règles de remplacement de correction",
"Selected Text Rules": "Règles du texte sélectionné",
"No selected text replacement rules": "Aucune règle de remplacement pour le texte sélectionné",
"Book Specific Rules": "Règles spécifiques au livre",
"No book-level replacement rules": "Aucune règle de remplacement au niveau du livre",
"Disable Quick Action": "Désactiver laction rapide",
"Enable Quick Action on Selection": "Activer laction rapide lors de la sélection",
"None": "Aucune",
"Annotation Tools": "Outils dannotation",
"Enable Quick Actions": "Activer les actions rapides",
"Quick Action": "Action rapide",
"Copy to Notebook": "Copier dans le carnet",
"Copy text after selection": "Copier le texte après la sélection",
"Highlight text after selection": "Mettre en surbrillance le texte après la sélection",
"Annotate text after selection": "Annoter le texte après la sélection",
"Search text after selection": "Rechercher le texte après la sélection",
"Look up text in dictionary after selection": "Chercher le texte dans le dictionnaire après la sélection",
"Look up text in Wikipedia after selection": "Chercher le texte dans Wikipédia après la sélection",
"Translate text after selection": "Traduire le texte après la sélection",
"Read text aloud after selection": "Lire le texte à haute voix après la sélection",
"Proofread text after selection": "Relire le texte après la sélection",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} actifs, {{pendingCount}} en attente",
"{{failedCount}} failed": "{{failedCount}} échoués",
"Waiting...": "En attente...",
"Failed": "Échoué",
"Completed": "Terminé",
"Cancelled": "Annulé",
"Retry": "Réessayer",
"Active": "Actifs",
"Transfer Queue": "File de transfert",
"Upload All": "Tout téléverser",
"Download All": "Tout télécharger",
"Resume Transfers": "Reprendre les transferts",
"Pause Transfers": "Suspendre les transferts",
"Pending": "En attente",
"No transfers": "Aucun transfert",
"Retry All": "Tout réessayer",
"Clear Completed": "Effacer les terminés",
"Clear Failed": "Effacer les échoués",
"Upload queued: {{title}}": "Téléversement en file: {{title}}",
"Download queued: {{title}}": "Téléchargement en file: {{title}}",
"Book not found in library": "Livre non trouvé dans la bibliothèque",
"Unknown error": "Erreur inconnue",
"Please log in to continue": "Veuillez vous connecter pour continuer",
"Cloud File Transfers": "Transferts de fichiers cloud",
"Show Search Results": "Afficher les résultats",
"Search results for '{{term}}'": "Résultats pour « {{term}} »",
"Close Search": "Fermer la recherche",
"Previous Result": "Résultat précédent",
"Next Result": "Résultat suivant",
"Bookmarks": "Signets",
"Annotations": "Annotations",
"Show Results": "Afficher les résultats",
"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"
}
@@ -765,5 +765,76 @@
"Count": "गणना",
"Start Page": "प्रारंभ पृष्ठ",
"Search in OPDS Catalog...": "OPDS कैटलॉग में खोजें...",
"Please log in to use advanced TTS features.": "उन्नत TTS सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।"
"Please log in to use advanced TTS features": "उन्नत TTS सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।",
"Word limit of 30 words exceeded.": "30 शब्दों की सीमा पार हो गई है।",
"Proofread": "प्रूफ़रीड",
"Current selection": "वर्तमान चयन",
"All occurrences in this book": "इस पुस्तक में सभी स्थान",
"All occurrences in your library": "आपकी लाइब्रेरी में सभी स्थान",
"Selected text:": "चयनित पाठ:",
"Replace with:": "से बदलें:",
"Enter text...": "पाठ दर्ज करें…",
"Case sensitive:": "अक्षर संवेदनशील:",
"Scope:": "क्षेत्र:",
"Selection": "चयन",
"Library": "लाइब्रेरी",
"Yes": "हाँ",
"No": "नहीं",
"Proofread Replacement Rules": "प्रूफ़रीड प्रतिस्थापन नियम",
"Selected Text Rules": "चयनित पाठ के नियम",
"No selected text replacement rules": "चयनित पाठ के लिए कोई प्रतिस्थापन नियम नहीं हैं",
"Book Specific Rules": "पुस्तक-विशिष्ट नियम",
"No book-level replacement rules": "पुस्तक स्तर पर कोई प्रतिस्थापन नियम नहीं हैं",
"Disable Quick Action": "त्वरित क्रिया अक्षम करें",
"Enable Quick Action on Selection": "चयन पर त्वरित क्रिया सक्षम करें",
"None": "कोई नहीं",
"Annotation Tools": "टिप्पणी उपकरण",
"Enable Quick Actions": "त्वरित क्रियाएँ सक्षम करें",
"Quick Action": "त्वरित क्रिया",
"Copy to Notebook": "नोटबुक में कॉपी करें",
"Copy text after selection": "पाठ कॉपी करें चयन के बाद",
"Highlight text after selection": "चयन के बाद पाठ हाइलाइट करें",
"Annotate text after selection": "चयन के बाद पाठ पर टिप्पणी करें",
"Search text after selection": "चयन के बाद पाठ खोजें",
"Look up text in dictionary after selection": "चयन के बाद शब्दकोश में पाठ देखें",
"Look up text in Wikipedia after selection": "चयन के बाद विकिपीडिया में पाठ देखें",
"Translate text after selection": "चयन के बाद पाठ का अनुवाद करें",
"Read text aloud after selection": "चयन के बाद पाठ को जोर से पढ़ें",
"Proofread text after selection": "चयन के बाद पाठ को प्रूफरीड करें",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} सक्रिय, {{pendingCount}} लंबित",
"{{failedCount}} failed": "{{failedCount}} विफल",
"Waiting...": "प्रतीक्षा कर रहे हैं...",
"Failed": "विफल",
"Completed": "पूर्ण",
"Cancelled": "रद्द",
"Retry": "पुनः प्रयास करें",
"Active": "सक्रिय",
"Transfer Queue": "स्थानांतरण कतार",
"Upload All": "सभी अपलोड करें",
"Download All": "सभी डाउनलोड करें",
"Resume Transfers": "स्थानांतरण जारी रखें",
"Pause Transfers": "स्थानांतरण रोकें",
"Pending": "लंबित",
"No transfers": "कोई स्थानांतरण नहीं",
"Retry All": "सभी पुनः प्रयास करें",
"Clear Completed": "पूर्ण साफ़ करें",
"Clear Failed": "विफल साफ़ करें",
"Upload queued: {{title}}": "अपलोड कतार में: {{title}}",
"Download queued: {{title}}": "डाउनलोड कतार में: {{title}}",
"Book not found in library": "पुस्तकालय में पुस्तक नहीं मिली",
"Unknown error": "अज्ञात त्रुटि",
"Please log in to continue": "जारी रखने के लिए लॉगिन करें",
"Cloud File Transfers": "क्लाउड फ़ाइल स्थानांतरण",
"Show Search Results": "खोज परिणाम दिखाएं",
"Search results for '{{term}}'": "'{{term}}' के परिणाम",
"Close Search": "खोज बंद करें",
"Previous Result": "पिछला परिणाम",
"Next Result": "अगला परिणाम",
"Bookmarks": "बुकमार्क",
"Annotations": "टिप्पणियाँ",
"Show Results": "परिणाम दिखाएं",
"Clear search": "खोज साफ़ करें",
"Clear search history": "खोज इतिहास साफ़ करें",
"Quick action disabled": "त्वरित क्रिया अक्षम",
"Tap to Toggle Footer": "फुटर टॉगल करने के लिए टैप करें"
}
@@ -755,5 +755,76 @@
"Count": "Jumlah",
"Start Page": "Halaman Awal",
"Search in OPDS Catalog...": "Cari di Katalog OPDS...",
"Please log in to use advanced TTS features.": "Silakan masuk untuk menggunakan fitur TTS lanjutan."
"Please log in to use advanced TTS features": "Silakan masuk untuk menggunakan fitur TTS lanjutan",
"Word limit of 30 words exceeded.": "Batas 30 kata telah terlampaui.",
"Proofread": "Koreksi",
"Current selection": "Pilihan saat ini",
"All occurrences in this book": "Semua kemunculan di buku ini",
"All occurrences in your library": "Semua kemunculan di perpustakaan Anda",
"Selected text:": "Teks terpilih:",
"Replace with:": "Ganti dengan:",
"Enter text...": "Masukkan teks…",
"Case sensitive:": "Peka huruf besar dan kecil:",
"Scope:": "Cakupan:",
"Selection": "Pilihan",
"Library": "Perpustakaan",
"Yes": "Ya",
"No": "Tidak",
"Proofread Replacement Rules": "Aturan penggantian koreksi",
"Selected Text Rules": "Aturan teks terpilih",
"No selected text replacement rules": "Tidak ada aturan penggantian untuk teks terpilih",
"Book Specific Rules": "Aturan khusus buku",
"No book-level replacement rules": "Tidak ada aturan penggantian tingkat buku",
"Disable Quick Action": "Nonaktifkan Aksi Cepat",
"Enable Quick Action on Selection": "Aktifkan Aksi Cepat saat memilih",
"None": "Tidak ada",
"Annotation Tools": "Alat Anotasi",
"Enable Quick Actions": "Aktifkan Aksi Cepat",
"Quick Action": "Aksi Cepat",
"Copy to Notebook": "Salin ke Buku Catatan",
"Copy text after selection": "Salin teks setelah pemilihan",
"Highlight text after selection": "Sorot teks setelah pemilihan",
"Annotate text after selection": "Anotasi teks setelah pemilihan",
"Search text after selection": "Cari teks setelah pemilihan",
"Look up text in dictionary after selection": "Cari teks di kamus setelah pemilihan",
"Look up text in Wikipedia after selection": "Cari teks di Wikipedia setelah pemilihan",
"Translate text after selection": "Terjemahkan teks setelah pemilihan",
"Read text aloud after selection": "Bacakan teks setelah pemilihan",
"Proofread text after selection": "Periksa teks setelah pemilihan",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} menunggu",
"{{failedCount}} failed": "{{failedCount}} gagal",
"Waiting...": "Menunggu...",
"Failed": "Gagal",
"Completed": "Selesai",
"Cancelled": "Dibatalkan",
"Retry": "Coba lagi",
"Active": "Aktif",
"Transfer Queue": "Antrian Transfer",
"Upload All": "Unggah Semua",
"Download All": "Unduh Semua",
"Resume Transfers": "Lanjutkan Transfer",
"Pause Transfers": "Jeda Transfer",
"Pending": "Menunggu",
"No transfers": "Tidak ada transfer",
"Retry All": "Coba Semua Lagi",
"Clear Completed": "Hapus Selesai",
"Clear Failed": "Hapus Gagal",
"Upload queued: {{title}}": "Unggahan dalam antrian: {{title}}",
"Download queued: {{title}}": "Unduhan dalam antrian: {{title}}",
"Book not found in library": "Buku tidak ditemukan di perpustakaan",
"Unknown error": "Kesalahan tidak diketahui",
"Please log in to continue": "Silakan masuk untuk melanjutkan",
"Cloud File Transfers": "Transfer File Cloud",
"Show Search Results": "Tampilkan hasil pencarian",
"Search results for '{{term}}'": "Hasil untuk '{{term}}'",
"Close Search": "Tutup pencarian",
"Previous Result": "Hasil sebelumnya",
"Next Result": "Hasil berikutnya",
"Bookmarks": "Penanda",
"Annotations": "Anotasi",
"Show Results": "Tampilkan Hasil",
"Clear search": "Hapus pencarian",
"Clear search history": "Hapus riwayat pencarian",
"Quick action disabled": "Tindakan cepat dinonaktifkan",
"Tap to Toggle Footer": "Ketuk untuk beralih footer"
}
@@ -775,5 +775,76 @@
"Count": "Conteggio",
"Start Page": "Pagina iniziale",
"Search in OPDS Catalog...": "Cerca nel catalogo OPDS...",
"Please log in to use advanced TTS features.": "Effettua il login per utilizzare le funzionalità TTS avanzate."
"Please log in to use advanced TTS features": "Effettua il login per utilizzare le funzionalità TTS avanzate",
"Word limit of 30 words exceeded.": "È stato superato il limite di 30 parole.",
"Proofread": "Revisione",
"Current selection": "Selezione corrente",
"All occurrences in this book": "Tutte le occorrenze in questo libro",
"All occurrences in your library": "Tutte le occorrenze nella tua libreria",
"Selected text:": "Testo selezionato:",
"Replace with:": "Sostituisci con:",
"Enter text...": "Inserisci testo…",
"Case sensitive:": "Maiuscole/minuscole:",
"Scope:": "Ambito:",
"Selection": "Selezione",
"Library": "Libreria",
"Yes": "Sì",
"No": "No",
"Proofread Replacement Rules": "Regole di sostituzione per la revisione",
"Selected Text Rules": "Regole per il testo selezionato",
"No selected text replacement rules": "Nessuna regola di sostituzione per il testo selezionato",
"Book Specific Rules": "Regole specifiche del libro",
"No book-level replacement rules": "Nessuna regola di sostituzione a livello di libro",
"Disable Quick Action": "Disattiva azione rapida",
"Enable Quick Action on Selection": "Attiva azione rapida sulla selezione",
"None": "Nessuno",
"Annotation Tools": "Strumenti di annotazione",
"Enable Quick Actions": "Attiva azioni rapide",
"Quick Action": "Azione rapida",
"Copy to Notebook": "Copia nel taccuino",
"Copy text after selection": "Copia testo dopo la selezione",
"Highlight text after selection": "Evidenzia testo dopo la selezione",
"Annotate text after selection": "Annota testo dopo la selezione",
"Search text after selection": "Cerca testo dopo la selezione",
"Look up text in dictionary after selection": "Cerca testo nel dizionario dopo la selezione",
"Look up text in Wikipedia after selection": "Cerca testo in Wikipedia dopo la selezione",
"Translate text after selection": "Traduci testo dopo la selezione",
"Read text aloud after selection": "Leggi ad alta voce il testo dopo la selezione",
"Proofread text after selection": "Correggi il testo dopo la selezione",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} attivi, {{pendingCount}} in attesa",
"{{failedCount}} failed": "{{failedCount}} falliti",
"Waiting...": "In attesa...",
"Failed": "Fallito",
"Completed": "Completato",
"Cancelled": "Annullato",
"Retry": "Riprova",
"Active": "Attivi",
"Transfer Queue": "Coda trasferimenti",
"Upload All": "Carica tutti",
"Download All": "Scarica tutti",
"Resume Transfers": "Riprendi trasferimenti",
"Pause Transfers": "Sospendi trasferimenti",
"Pending": "In attesa",
"No transfers": "Nessun trasferimento",
"Retry All": "Riprova tutti",
"Clear Completed": "Cancella completati",
"Clear Failed": "Cancella falliti",
"Upload queued: {{title}}": "Caricamento in coda: {{title}}",
"Download queued: {{title}}": "Download in coda: {{title}}",
"Book not found in library": "Libro non trovato nella libreria",
"Unknown error": "Errore sconosciuto",
"Please log in to continue": "Accedi per continuare",
"Cloud File Transfers": "Trasferimenti file cloud",
"Show Search Results": "Mostra risultati",
"Search results for '{{term}}'": "Risultati per '{{term}}'",
"Close Search": "Chiudi ricerca",
"Previous Result": "Risultato precedente",
"Next Result": "Risultato successivo",
"Bookmarks": "Segnalibri",
"Annotations": "Annotazioni",
"Show Results": "Mostra risultati",
"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"
}
@@ -755,5 +755,76 @@
"Count": "件数",
"Start Page": "開始ページ",
"Search in OPDS Catalog...": "OPDSカタログ内を検索...",
"Please log in to use advanced TTS features.": "高度なTTS機能を使用するにはログインしてください"
"Please log in to use advanced TTS features": "高度なTTS機能を使用するにはログインしてください",
"Word limit of 30 words exceeded.": "30語の上限を超えています。",
"Proofread": "校正",
"Current selection": "現在の選択",
"All occurrences in this book": "この本内のすべての出現箇所",
"All occurrences in your library": "ライブラリ内のすべての出現箇所",
"Selected text:": "選択されたテキスト:",
"Replace with:": "置換後:",
"Enter text...": "テキストを入力…",
"Case sensitive:": "大文字と小文字を区別:",
"Scope:": "適用範囲:",
"Selection": "選択",
"Library": "ライブラリ",
"Yes": "はい",
"No": "いいえ",
"Proofread Replacement Rules": "校正置換ルール",
"Selected Text Rules": "選択テキストのルール",
"No selected text replacement rules": "選択されたテキストの置換ルールはありません",
"Book Specific Rules": "書籍別ルール",
"No book-level replacement rules": "書籍レベルの置換ルールはありません",
"Disable Quick Action": "クイックアクションを無効にする",
"Enable Quick Action on Selection": "選択時にクイックアクションを有効にする",
"None": "なし",
"Annotation Tools": "注釈ツール",
"Enable Quick Actions": "クイックアクションを有効にする",
"Quick Action": "クイックアクション",
"Copy to Notebook": "ノートにコピー",
"Copy text after selection": "選択後のテキストをコピー",
"Highlight text after selection": "選択後のテキストをハイライト",
"Annotate text after selection": "選択後のテキストに注釈を付ける",
"Search text after selection": "選択後のテキストを検索",
"Look up text in dictionary after selection": "選択後のテキストを辞書で調べる",
"Look up text in Wikipedia after selection": "選択後のテキストをWikipediaで調べる",
"Translate text after selection": "選択後のテキストを翻訳する",
"Read text aloud after selection": "選択後のテキストを音読する",
"Proofread text after selection": "選択後のテキストを校正する",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 進行中、{{pendingCount}} 待機中",
"{{failedCount}} failed": "{{failedCount}} 失敗",
"Waiting...": "待機中...",
"Failed": "失敗",
"Completed": "完了",
"Cancelled": "キャンセル",
"Retry": "再試行",
"Active": "進行中",
"Transfer Queue": "転送キュー",
"Upload All": "すべてアップロード",
"Download All": "すべてダウンロード",
"Resume Transfers": "転送を再開",
"Pause Transfers": "転送を一時停止",
"Pending": "待機中",
"No transfers": "転送なし",
"Retry All": "すべて再試行",
"Clear Completed": "完了を消去",
"Clear Failed": "失敗を消去",
"Upload queued: {{title}}": "アップロードをキューに追加: {{title}}",
"Download queued: {{title}}": "ダウンロードをキューに追加: {{title}}",
"Book not found in library": "ライブラリに本が見つかりません",
"Unknown error": "不明なエラー",
"Please log in to continue": "続行するにはログインしてください",
"Cloud File Transfers": "クラウドファイル転送",
"Show Search Results": "検索結果を表示",
"Search results for '{{term}}'": "「{{term}}」を含む結果",
"Close Search": "検索を閉じる",
"Previous Result": "前の結果",
"Next Result": "次の結果",
"Bookmarks": "ブックマーク",
"Annotations": "注釈",
"Show Results": "結果を表示",
"Clear search": "検索をクリア",
"Clear search history": "検索履歴をクリア",
"Quick action disabled": "クイックアクション無効",
"Tap to Toggle Footer": "タップでフッターを切り替え"
}
@@ -755,5 +755,76 @@
"Count": "개수",
"Start Page": "시작 페이지",
"Search in OPDS Catalog...": "OPDS 카탈로그에서 검색...",
"Please log in to use advanced TTS features.": "고급 TTS 기능을 사용하려면 로그인하세요."
"Please log in to use advanced TTS features": "고급 TTS 기능을 사용하려면 로그인하세요",
"Word limit of 30 words exceeded.": "30단어 제한을 초과했습니다.",
"Proofread": "교정",
"Current selection": "현재 선택",
"All occurrences in this book": "이 책의 모든 위치",
"All occurrences in your library": "라이브러리의 모든 위치",
"Selected text:": "선택한 텍스트:",
"Replace with:": "다음으로 바꾸기:",
"Enter text...": "텍스트 입력…",
"Case sensitive:": "대소문자 구분:",
"Scope:": "적용 범위:",
"Selection": "선택",
"Library": "라이브러리",
"Yes": "예",
"No": "아니요",
"Proofread Replacement Rules": "교정 대체 규칙",
"Selected Text Rules": "선택한 텍스트 규칙",
"No selected text replacement rules": "선택한 텍스트에 대한 대체 규칙이 없습니다",
"Book Specific Rules": "책별 규칙",
"No book-level replacement rules": "책 수준의 대체 규칙이 없습니다",
"Disable Quick Action": "빠른 작업 비활성화",
"Enable Quick Action on Selection": "선택 시 빠른 작업 활성화",
"None": "없음",
"Annotation Tools": "주석 도구",
"Enable Quick Actions": "빠른 작업 활성화",
"Quick Action": "빠른 작업",
"Copy to Notebook": "노트에 복사",
"Copy text after selection": "선택 후 텍스트 복사",
"Highlight text after selection": "선택 후 텍스트 강조",
"Annotate text after selection": "선택 후 텍스트 주석 달기",
"Search text after selection": "선택 후 텍스트 검색",
"Look up text in dictionary after selection": "선택 후 텍스트를 사전에서 찾기",
"Look up text in Wikipedia after selection": "선택 후 텍스트를 위키피디아에서 찾기",
"Translate text after selection": "선택 후 텍스트 번역",
"Read text aloud after selection": "선택 후 텍스트 음성 읽기",
"Proofread text after selection": "선택 후 텍스트 교정",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 진행 중, {{pendingCount}} 대기 중",
"{{failedCount}} failed": "{{failedCount}} 실패",
"Waiting...": "대기 중...",
"Failed": "실패",
"Completed": "완료됨",
"Cancelled": "취소됨",
"Retry": "재시도",
"Active": "진행 중",
"Transfer Queue": "전송 대기열",
"Upload All": "모두 업로드",
"Download All": "모두 다운로드",
"Resume Transfers": "전송 재개",
"Pause Transfers": "전송 일시정지",
"Pending": "대기 중",
"No transfers": "전송 없음",
"Retry All": "모두 재시도",
"Clear Completed": "완료된 항목 지우기",
"Clear Failed": "실패한 항목 지우기",
"Upload queued: {{title}}": "업로드 대기열에 추가됨: {{title}}",
"Download queued: {{title}}": "다운로드 대기열에 추가됨: {{title}}",
"Book not found in library": "라이브러리에서 책을 찾을 수 없음",
"Unknown error": "알 수 없는 오류",
"Please log in to continue": "계속하려면 로그인하세요",
"Cloud File Transfers": "클라우드 파일 전송",
"Show Search Results": "검색 결과 보기",
"Search results for '{{term}}'": "'{{term}}' 검색 결과",
"Close Search": "검색 닫기",
"Previous Result": "이전 결과",
"Next Result": "다음 결과",
"Bookmarks": "북마크",
"Annotations": "주석",
"Show Results": "결과 표시",
"Clear search": "검색 지우기",
"Clear search history": "검색 기록 지우기",
"Quick action disabled": "빠른 작업 비활성화됨",
"Tap to Toggle Footer": "탭하여 바닥글 전환"
}
@@ -755,5 +755,76 @@
"Count": "Jumlah",
"Start Page": "Halaman Awal",
"Search in OPDS Catalog...": "Cari dalam Katalog OPDS...",
"Please log in to use advanced TTS features.": "Sila log masuk untuk menggunakan ciri TTS lanjutan."
"Please log in to use advanced TTS features": "Sila log masuk untuk menggunakan ciri TTS lanjutan",
"Word limit of 30 words exceeded.": "Had 30 perkataan telah dilebihi.",
"Proofread": "Semakan",
"Current selection": "Pilihan semasa",
"All occurrences in this book": "Semua kejadian dalam buku ini",
"All occurrences in your library": "Semua kejadian dalam perpustakaan anda",
"Selected text:": "Teks dipilih:",
"Replace with:": "Ganti dengan:",
"Enter text...": "Masukkan teks…",
"Case sensitive:": "Peka huruf besar dan kecil:",
"Scope:": "Skop:",
"Selection": "Pilihan",
"Library": "Perpustakaan",
"Yes": "Ya",
"No": "Tidak",
"Proofread Replacement Rules": "Peraturan penggantian semakan",
"Selected Text Rules": "Peraturan teks dipilih",
"No selected text replacement rules": "Tiada peraturan penggantian untuk teks dipilih",
"Book Specific Rules": "Peraturan khusus buku",
"No book-level replacement rules": "Tiada peraturan penggantian peringkat buku",
"Disable Quick Action": "Nyahaktifkan Tindakan Pantas",
"Enable Quick Action on Selection": "Aktifkan Tindakan Pantas pada Pilihan",
"None": "Tiada",
"Annotation Tools": "Alat Anotasi",
"Enable Quick Actions": "Aktifkan Tindakan Pantas",
"Quick Action": "Tindakan Pantas",
"Copy to Notebook": "Salin ke Buku Nota",
"Copy text after selection": "Salin teks selepas pilihan",
"Highlight text after selection": "Sorot teks selepas pilihan",
"Annotate text after selection": "Anotasi teks selepas pilihan",
"Search text after selection": "Cari teks selepas pilihan",
"Look up text in dictionary after selection": "Cari teks dalam kamus selepas pilihan",
"Look up text in Wikipedia after selection": "Cari teks dalam Wikipedia selepas pilihan",
"Translate text after selection": "Terjemah teks selepas pilihan",
"Read text aloud after selection": "Baca teks dengan kuat selepas pilihan",
"Proofread text after selection": "Semak teks selepas pilihan",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} menunggu",
"{{failedCount}} failed": "{{failedCount}} gagal",
"Waiting...": "Menunggu...",
"Failed": "Gagal",
"Completed": "Selesai",
"Cancelled": "Dibatalkan",
"Retry": "Cuba lagi",
"Active": "Aktif",
"Transfer Queue": "Baris Gilir Pemindahan",
"Upload All": "Muat Naik Semua",
"Download All": "Muat Turun Semua",
"Resume Transfers": "Sambung Pemindahan",
"Pause Transfers": "Jeda Pemindahan",
"Pending": "Menunggu",
"No transfers": "Tiada pemindahan",
"Retry All": "Cuba Semua Lagi",
"Clear Completed": "Kosongkan Selesai",
"Clear Failed": "Kosongkan Gagal",
"Upload queued: {{title}}": "Muat naik dalam baris gilir: {{title}}",
"Download queued: {{title}}": "Muat turun dalam baris gilir: {{title}}",
"Book not found in library": "Buku tidak dijumpai dalam perpustakaan",
"Unknown error": "Ralat tidak diketahui",
"Please log in to continue": "Sila log masuk untuk meneruskan",
"Cloud File Transfers": "Pemindahan Fail Awan",
"Show Search Results": "Tunjukkan hasil carian",
"Search results for '{{term}}'": "Hasil untuk '{{term}}'",
"Close Search": "Tutup carian",
"Previous Result": "Hasil sebelumnya",
"Next Result": "Hasil seterusnya",
"Bookmarks": "Penanda buku",
"Annotations": "Anotasi",
"Show Results": "Tunjukkan Hasil",
"Clear search": "Kosongkan carian",
"Clear search history": "Kosongkan sejarah carian",
"Quick action disabled": "Tindakan pantas dilumpuhkan",
"Tap to Toggle Footer": "Ketik untuk togol pengaki"
}
@@ -765,5 +765,76 @@
"Count": "Aantal",
"Start Page": "Startpagina",
"Search in OPDS Catalog...": "Zoeken in OPDS-catalogus...",
"Please log in to use advanced TTS features.": "Log in om geavanceerde TTS-functies te gebruiken."
"Please log in to use advanced TTS features": "Log in om geavanceerde TTS-functies te gebruiken",
"Word limit of 30 words exceeded.": "De limiet van 30 woorden is overschreden.",
"Proofread": "Correctie",
"Current selection": "Huidige selectie",
"All occurrences in this book": "Alle voorkomens in dit boek",
"All occurrences in your library": "Alle voorkomens in je bibliotheek",
"Selected text:": "Geselecteerde tekst:",
"Replace with:": "Vervangen door:",
"Enter text...": "Tekst invoeren…",
"Case sensitive:": "Hoofdlettergevoelig:",
"Scope:": "Bereik:",
"Selection": "Selectie",
"Library": "Bibliotheek",
"Yes": "Ja",
"No": "Nee",
"Proofread Replacement Rules": "Correctie-vervangingsregels",
"Selected Text Rules": "Regels voor geselecteerde tekst",
"No selected text replacement rules": "Geen vervangingsregels voor geselecteerde tekst",
"Book Specific Rules": "Boekspecifieke regels",
"No book-level replacement rules": "Geen vervangingsregels op boekniveau",
"Disable Quick Action": "Snelle actie uitschakelen",
"Enable Quick Action on Selection": "Snelle actie bij selectie inschakelen",
"None": "Geen",
"Annotation Tools": "Annotatiehulpmiddelen",
"Enable Quick Actions": "Snelle acties inschakelen",
"Quick Action": "Snelle actie",
"Copy to Notebook": "Kopiëren naar notitieboek",
"Copy text after selection": "Kopieer tekst na selectie",
"Highlight text after selection": "Markeer tekst na selectie",
"Annotate text after selection": "Annoteren tekst na selectie",
"Search text after selection": "Zoek tekst na selectie",
"Look up text in dictionary after selection": "Zoek tekst op in woordenboek na selectie",
"Look up text in Wikipedia after selection": "Zoek tekst op in Wikipedia na selectie",
"Translate text after selection": "Vertaal tekst na selectie",
"Read text aloud after selection": "Lees tekst hardop na selectie",
"Proofread text after selection": "Corrigeer tekst na selectie",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} actief, {{pendingCount}} wachtend",
"{{failedCount}} failed": "{{failedCount}} mislukt",
"Waiting...": "Wachten...",
"Failed": "Mislukt",
"Completed": "Voltooid",
"Cancelled": "Geannuleerd",
"Retry": "Opnieuw proberen",
"Active": "Actief",
"Transfer Queue": "Overdrachtwachtrij",
"Upload All": "Alles uploaden",
"Download All": "Alles downloaden",
"Resume Transfers": "Overdrachten hervatten",
"Pause Transfers": "Overdrachten pauzeren",
"Pending": "Wachtend",
"No transfers": "Geen overdrachten",
"Retry All": "Alles opnieuw proberen",
"Clear Completed": "Voltooide wissen",
"Clear Failed": "Mislukte wissen",
"Upload queued: {{title}}": "Upload in wachtrij: {{title}}",
"Download queued: {{title}}": "Download in wachtrij: {{title}}",
"Book not found in library": "Boek niet gevonden in bibliotheek",
"Unknown error": "Onbekende fout",
"Please log in to continue": "Log in om door te gaan",
"Cloud File Transfers": "Cloud Bestandoverdrachten",
"Show Search Results": "Zoekresultaten tonen",
"Search results for '{{term}}'": "Resultaten voor '{{term}}'",
"Close Search": "Zoekopdracht sluiten",
"Previous Result": "Vorig resultaat",
"Next Result": "Volgend resultaat",
"Bookmarks": "Bladwijzers",
"Annotations": "Aantekeningen",
"Show Results": "Resultaten tonen",
"Clear search": "Zoekopdracht wissen",
"Clear search history": "Zoekgeschiedenis wissen",
"Quick action disabled": "Snelle actie uitgeschakeld",
"Tap to Toggle Footer": "Tik om voettekst te wisselen"
}
@@ -785,5 +785,76 @@
"Count": "Liczba",
"Start Page": "Strona startowa",
"Search in OPDS Catalog...": "Szukaj w katalogu OPDS...",
"Please log in to use advanced TTS features.": "Zaloguj się, aby korzystać z zaawansowanych funkcji TTS."
"Please log in to use advanced TTS features": "Zaloguj się, aby korzystać z zaawansowanych funkcji TTS",
"Word limit of 30 words exceeded.": "Przekroczono limit 30 słów.",
"Proofread": "Korekta",
"Current selection": "Bieżące zaznaczenie",
"All occurrences in this book": "Wszystkie wystąpienia w tej książce",
"All occurrences in your library": "Wszystkie wystąpienia w Twojej bibliotece",
"Selected text:": "Zaznaczony tekst:",
"Replace with:": "Zastąp przez:",
"Enter text...": "Wpisz tekst…",
"Case sensitive:": "Uwzględniaj wielkość liter:",
"Scope:": "Zakres:",
"Selection": "Zaznaczenie",
"Library": "Biblioteka",
"Yes": "Tak",
"No": "Nie",
"Proofread Replacement Rules": "Reguły zastępowania korekty",
"Selected Text Rules": "Reguły dla zaznaczonego tekstu",
"No selected text replacement rules": "Brak reguł zastępowania dla zaznaczonego tekstu",
"Book Specific Rules": "Reguły specyficzne dla książki",
"No book-level replacement rules": "Brak reguł zastępowania na poziomie książki",
"Disable Quick Action": "Wyłącz szybką akcję",
"Enable Quick Action on Selection": "Włącz szybką akcję przy zaznaczeniu",
"None": "Brak",
"Annotation Tools": "Narzędzia do adnotacji",
"Enable Quick Actions": "Włącz szybkie akcje",
"Quick Action": "Szybka akcja",
"Copy to Notebook": "Kopiuj do notatnika",
"Copy text after selection": "Kopiuj zaznaczony tekst",
"Highlight text after selection": "Podświetl zaznaczony tekst",
"Annotate text after selection": "Dodaj adnotację do zaznaczonego tekstu",
"Search text after selection": "Wyszukaj zaznaczony tekst",
"Look up text in dictionary after selection": "Sprawdź zaznaczony tekst w słowniku",
"Look up text in Wikipedia after selection": "Sprawdź zaznaczony tekst w Wikipedii",
"Translate text after selection": "Przetłumacz zaznaczony tekst",
"Read text aloud after selection": "Odczytaj zaznaczony tekst na głos",
"Proofread text after selection": "Sprawdź zaznaczony tekst",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktywnych, {{pendingCount}} oczekujących",
"{{failedCount}} failed": "{{failedCount}} nieudanych",
"Waiting...": "Oczekiwanie...",
"Failed": "Nieudane",
"Completed": "Ukończone",
"Cancelled": "Anulowane",
"Retry": "Ponów",
"Active": "Aktywne",
"Transfer Queue": "Kolejka transferów",
"Upload All": "Wyślij wszystko",
"Download All": "Pobierz wszystko",
"Resume Transfers": "Wznów transfery",
"Pause Transfers": "Wstrzymaj transfery",
"Pending": "Oczekujące",
"No transfers": "Brak transferów",
"Retry All": "Ponów wszystko",
"Clear Completed": "Wyczyść ukończone",
"Clear Failed": "Wyczyść nieudane",
"Upload queued: {{title}}": "Wysyłanie w kolejce: {{title}}",
"Download queued: {{title}}": "Pobieranie w kolejce: {{title}}",
"Book not found in library": "Książka nie została znaleziona w bibliotece",
"Unknown error": "Nieznany błąd",
"Please log in to continue": "Zaloguj się, aby kontynuować",
"Cloud File Transfers": "Transfery plików w chmurze",
"Show Search Results": "Pokaż wyniki wyszukiwania",
"Search results for '{{term}}'": "Wyniki dla '{{term}}'",
"Close Search": "Zamknij wyszukiwanie",
"Previous Result": "Poprzedni wynik",
"Next Result": "Następny wynik",
"Bookmarks": "Zakładki",
"Annotations": "Adnotacje",
"Show Results": "Pokaż wyniki",
"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ę"
}
@@ -775,5 +775,76 @@
"Count": "Contagem",
"Start Page": "Página Inicial",
"Search in OPDS Catalog...": "Pesquisar no Catálogo OPDS...",
"Please log in to use advanced TTS features.": "Por favor, faça login para usar recursos avançados de TTS."
"Please log in to use advanced TTS features": "Por favor, faça login para usar recursos avançados de TTS",
"Word limit of 30 words exceeded.": "O limite de 30 palavras foi excedido.",
"Proofread": "Revisão",
"Current selection": "Seleção atual",
"All occurrences in this book": "Todas as ocorrências neste livro",
"All occurrences in your library": "Todas as ocorrências na sua biblioteca",
"Selected text:": "Texto selecionado:",
"Replace with:": "Substituir por:",
"Enter text...": "Introduzir texto…",
"Case sensitive:": "Diferenciar maiúsculas e minúsculas:",
"Scope:": "Âmbito:",
"Selection": "Seleção",
"Library": "Biblioteca",
"Yes": "Sim",
"No": "Não",
"Proofread Replacement Rules": "Regras de substituição da revisão",
"Selected Text Rules": "Regras do texto selecionado",
"No selected text replacement rules": "Não existem regras de substituição para o texto selecionado",
"Book Specific Rules": "Regras específicas do livro",
"No book-level replacement rules": "Não existem regras de substituição ao nível do livro",
"Disable Quick Action": "Desativar Ação Rápida",
"Enable Quick Action on Selection": "Ativar Ação Rápida na Seleção",
"None": "Nenhum",
"Annotation Tools": "Ferramentas de Anotação",
"Enable Quick Actions": "Ativar Ações Rápidas",
"Quick Action": "Ação Rápida",
"Copy to Notebook": "Copiar para o Caderno",
"Copy text after selection": "Copiar texto após a seleção",
"Highlight text after selection": "Destacar texto após a seleção",
"Annotate text after selection": "Anotar texto após a seleção",
"Search text after selection": "Pesquisar texto após a seleção",
"Look up text in dictionary after selection": "Procurar texto no dicionário após a seleção",
"Look up text in Wikipedia after selection": "Procurar texto na Wikipedia após a seleção",
"Translate text after selection": "Traduzir texto após a seleção",
"Read text aloud after selection": "Ler texto em voz alta após a seleção",
"Proofread text after selection": "Revisar texto após a seleção",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} ativos, {{pendingCount}} pendentes",
"{{failedCount}} failed": "{{failedCount}} falharam",
"Waiting...": "Aguardando...",
"Failed": "Falhou",
"Completed": "Concluído",
"Cancelled": "Cancelado",
"Retry": "Tentar novamente",
"Active": "Ativos",
"Transfer Queue": "Fila de Transferências",
"Upload All": "Enviar Todos",
"Download All": "Baixar Todos",
"Resume Transfers": "Retomar Transferências",
"Pause Transfers": "Pausar Transferências",
"Pending": "Pendentes",
"No transfers": "Sem transferências",
"Retry All": "Tentar Todos Novamente",
"Clear Completed": "Limpar Concluídos",
"Clear Failed": "Limpar Falhos",
"Upload queued: {{title}}": "Upload na fila: {{title}}",
"Download queued: {{title}}": "Download na fila: {{title}}",
"Book not found in library": "Livro não encontrado na biblioteca",
"Unknown error": "Erro desconhecido",
"Please log in to continue": "Faça login para continuar",
"Cloud File Transfers": "Transferências de Arquivos",
"Show Search Results": "Mostrar resultados",
"Search results for '{{term}}'": "Resultados para '{{term}}'",
"Close Search": "Fechar pesquisa",
"Previous Result": "Resultado anterior",
"Next Result": "Próximo resultado",
"Bookmarks": "Marcadores",
"Annotations": "Anotações",
"Show Results": "Mostrar resultados",
"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é"
}
@@ -785,5 +785,76 @@
"Count": "Количество",
"Start Page": "Начальная страница",
"Search in OPDS Catalog...": "Поиск в каталоге OPDS...",
"Please log in to use advanced TTS features.": "Пожалуйста, войдите в систему, чтобы использовать расширенные функции TTS."
"Please log in to use advanced TTS features": "Пожалуйста, войдите в систему, чтобы использовать расширенные функции TTS",
"Word limit of 30 words exceeded.": "Превышен лимит в 30 слов.",
"Proofread": "Корректура",
"Current selection": "Текущее выделение",
"All occurrences in this book": "Все вхождения в этой книге",
"All occurrences in your library": "Все вхождения в вашей библиотеке",
"Selected text:": "Выделенный текст:",
"Replace with:": "Заменить на:",
"Enter text...": "Введите текст…",
"Case sensitive:": "Учитывать регистр:",
"Scope:": "Область применения:",
"Selection": "Выделение",
"Library": "Библиотека",
"Yes": "Да",
"No": "Нет",
"Proofread Replacement Rules": "Правила замены для корректуры",
"Selected Text Rules": "Правила для выделенного текста",
"No selected text replacement rules": "Нет правил замены для выделенного текста",
"Book Specific Rules": "Правила для конкретной книги",
"No book-level replacement rules": "Нет правил замены на уровне книги",
"Disable Quick Action": "Отключить быстрые действия",
"Enable Quick Action on Selection": "Включить быстрые действия при выборе",
"None": "Нет",
"Annotation Tools": "Инструменты для аннотаций",
"Enable Quick Actions": "Включить быстрые действия",
"Quick Action": "Быстрое действие",
"Copy to Notebook": "Скопировать в блокнот",
"Copy text after selection": "Копировать текст после выделения",
"Highlight text after selection": "Выделить текст после выделения",
"Annotate text after selection": "Аннотировать текст после выделения",
"Search text after selection": "Искать текст после выделения",
"Look up text in dictionary after selection": "Искать текст в словаре после выделения",
"Look up text in Wikipedia after selection": "Искать текст в Википедии после выделения",
"Translate text after selection": "Перевести текст после выделения",
"Read text aloud after selection": "Прочитать текст вслух после выделения",
"Proofread text after selection": "Корректировать текст после выделения",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} активных, {{pendingCount}} ожидающих",
"{{failedCount}} failed": "{{failedCount}} неудачных",
"Waiting...": "Ожидание...",
"Failed": "Ошибка",
"Completed": "Завершено",
"Cancelled": "Отменено",
"Retry": "Повторить",
"Active": "Активные",
"Transfer Queue": "Очередь передачи",
"Upload All": "Загрузить все",
"Download All": "Скачать все",
"Resume Transfers": "Возобновить передачу",
"Pause Transfers": "Приостановить передачу",
"Pending": "Ожидающие",
"No transfers": "Нет передач",
"Retry All": "Повторить все",
"Clear Completed": "Очистить завершенные",
"Clear Failed": "Очистить неудачные",
"Upload queued: {{title}}": "Загрузка в очереди: {{title}}",
"Download queued: {{title}}": "Скачивание в очереди: {{title}}",
"Book not found in library": "Книга не найдена в библиотеке",
"Unknown error": "Неизвестная ошибка",
"Please log in to continue": "Войдите, чтобы продолжить",
"Cloud File Transfers": "Передача файлов в облако",
"Show Search Results": "Показать результаты",
"Search results for '{{term}}'": "Результаты для «{{term}}»",
"Close Search": "Закрыть поиск",
"Previous Result": "Предыдущий результат",
"Next Result": "Следующий результат",
"Bookmarks": "Закладки",
"Annotations": "Аннотации",
"Show Results": "Показать результаты",
"Clear search": "Очистить поиск",
"Clear search history": "Очистить историю поиска",
"Quick action disabled": "Быстрое действие отключено",
"Tap to Toggle Footer": "Нажмите для переключения нижнего колонтитула"
}
@@ -765,5 +765,76 @@
"Count": "ගණන",
"Start Page": "ආරම්භක පිටුව",
"Search in OPDS Catalog...": "OPDS දත්තසමුදා තුළ සෙවීම...",
"Please log in to use advanced TTS features.": "උසස් TTS විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න."
"Please log in to use advanced TTS features": "උසස් TTS විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න",
"Word limit of 30 words exceeded.": "වචන 30ක සීමාව ඉක්මවා ඇත.",
"Proofread": "සංශෝධනය",
"Current selection": "වත්මන් තේරීම",
"All occurrences in this book": "මෙම පොත තුළ ඇති සියලුම පෙනීම්",
"All occurrences in your library": "ඔබගේ පුස්තකාලයේ ඇති සියලුම පෙනීම්",
"Selected text:": "තේරූ පෙළ:",
"Replace with:": "මෙයින් ප්‍රතිස්ථාපනය කරන්න:",
"Enter text...": "පෙළ ඇතුළත් කරන්න…",
"Case sensitive:": "අකුරු විශාල/කුඩා භේදය සලකා බලන්න:",
"Scope:": "පරාසය:",
"Selection": "තේරීම",
"Library": "පුස්තකාලය",
"Yes": "ඔව්",
"No": "නැහැ",
"Proofread Replacement Rules": "සංශෝධන ප්‍රතිස්ථාපන නියමයන්",
"Selected Text Rules": "තේරූ පෙළ සඳහා නියමයන්",
"No selected text replacement rules": "තේරූ පෙළ සඳහා ප්‍රතිස්ථාපන නියමයන් නොමැත",
"Book Specific Rules": "පොතට විශේෂිත නියමයන්",
"No book-level replacement rules": "පොත් මට්ටමේ ප්‍රතිස්ථාපන නියමයන් නොමැත",
"Disable Quick Action": "තට්ටු ක්‍රියාව අක්‍රිය කරන්න",
"Enable Quick Action on Selection": "තෝරා ගැනීමේදී තට්ටු ක්‍රියාව සක්‍රීය කරන්න",
"None": "කිසිවක් නැත",
"Annotation Tools": "සටහන් මෙවලම්",
"Enable Quick Actions": "තට්ටු ක්‍රියාවන් සක්‍රීය කරන්න",
"Quick Action": "තට්ටු ක්‍රියාව",
"Copy to Notebook": "සටහන් පොතට පිටපත් කරන්න",
"Copy text after selection": "තේරීමෙන් පසු පෙළ පිටපත් කරන්න",
"Highlight text after selection": "තේරීමෙන් පසු පෙළ හයිලයිට් කරන්න",
"Annotate text after selection": "තේරීමෙන් පසු පෙළ සටහන් කරන්න",
"Search text after selection": "තේරීමෙන් පසු පෙළ සෙවීම",
"Look up text in dictionary after selection": "තේරීමෙන් පසු ශබ්දකෝෂයේ පෙළ සෙවීම",
"Look up text in Wikipedia after selection": "තේරීමෙන් පසු විකිපීඩියාවේ පෙළ සෙවීම",
"Translate text after selection": "තේරීමෙන් පසු පෙළ පරිවර්තනය කරන්න",
"Read text aloud after selection": "තේරීමෙන් පසු පෙළ උච්චාරණය කරන්න",
"Proofread text after selection": "තේරීමෙන් පසු පෙළ සංශෝධනය කරන්න",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} සක්‍රීය, {{pendingCount}} බලාපොරොත්තුවේ",
"{{failedCount}} failed": "{{failedCount}} අසාර්ථක",
"Waiting...": "බලාපොරොත්තුවේ...",
"Failed": "අසාර්ථකයි",
"Completed": "සම්පූර්ණයි",
"Cancelled": "අවලංගුයි",
"Retry": "නැවත උත්සාහ කරන්න",
"Active": "සක්‍රීය",
"Transfer Queue": "හුවමාරු පෝලිම",
"Upload All": "සියල්ල උඩුගත කරන්න",
"Download All": "සියල්ල බාගන්න",
"Resume Transfers": "හුවමාරු නැවත ආරම්භ කරන්න",
"Pause Transfers": "හුවමාරු විරාම කරන්න",
"Pending": "බලාපොරොත්තුවේ",
"No transfers": "හුවමාරු නැත",
"Retry All": "සියල්ල නැවත උත්සාහ කරන්න",
"Clear Completed": "සම්පූර්ණ කළ ඒවා මකන්න",
"Clear Failed": "අසාර්ථක ඒවා මකන්න",
"Upload queued: {{title}}": "උඩුගත කිරීම පෝලිමේ: {{title}}",
"Download queued: {{title}}": "බාගත කිරීම පෝලිමේ: {{title}}",
"Book not found in library": "පුස්තකාලයේ පොත හමු නොවීය",
"Unknown error": "නොදන්නා දෝෂයක්",
"Please log in to continue": "ඉදිරියට යාමට පිවිසෙන්න",
"Cloud File Transfers": "කලාප ගොනු හුවමාරු",
"Show Search Results": "සෙවුම් ප්‍රතිඵල පෙන්වන්න",
"Search results for '{{term}}'": "'{{term}}' සඳහා ප්‍රතිඵල",
"Close Search": "සෙවුම වසන්න",
"Previous Result": "පෙර ප්‍රතිඵලය",
"Next Result": "ඊළඟ ප්‍රතිඵලය",
"Bookmarks": "පොත් සලකුණු",
"Annotations": "අනුසටහන්",
"Show Results": "ප්‍රතිඵල පෙන්වන්න",
"Clear search": "සෙවුම හිස් කරන්න",
"Clear search history": "සෙවුම් ඉතිහාසය හිස් කරන්න",
"Quick action disabled": "ඉක්මන් ක්‍රියාව අක්‍රීයයි",
"Tap to Toggle Footer": "පාදකය ටොගල් කිරීමට තට්ටු කරන්න"
}
@@ -765,5 +765,76 @@
"Count": "Antal",
"Start Page": "Start sida",
"Search in OPDS Catalog...": "Sök i OPDS-katalog...",
"Please log in to use advanced TTS features.": "Logga in för att använda avancerade TTS-funktioner."
"Please log in to use advanced TTS features": "Logga in för att använda avancerade TTS-funktioner",
"Word limit of 30 words exceeded.": "Gränsen på 30 ord har överskridits.",
"Proofread": "Korrekturläsning",
"Current selection": "Aktuell markering",
"All occurrences in this book": "Alla förekomster i den här boken",
"All occurrences in your library": "Alla förekomster i ditt bibliotek",
"Selected text:": "Markerad text:",
"Replace with:": "Ersätt med:",
"Enter text...": "Ange text…",
"Case sensitive:": "Skiftlägeskänslig:",
"Scope:": "Omfattning:",
"Selection": "Markering",
"Library": "Bibliotek",
"Yes": "Ja",
"No": "Nej",
"Proofread Replacement Rules": "Ersättningsregler för korrekturläsning",
"Selected Text Rules": "Regler för markerad text",
"No selected text replacement rules": "Inga ersättningsregler för markerad text",
"Book Specific Rules": "Bokspecifika regler",
"No book-level replacement rules": "Inga ersättningsregler på boknivå",
"Disable Quick Action": "Inaktivera snabbåtgärd",
"Enable Quick Action on Selection": "Aktivera snabbåtgärd vid val",
"None": "Ingen",
"Annotation Tools": "Anteckningsverktyg",
"Enable Quick Actions": "Aktivera snabba åtgärder",
"Quick Action": "Snabbåtgärd",
"Copy to Notebook": "Kopiera till anteckningsbok",
"Copy text after selection": "Kopiera text efter markering",
"Highlight text after selection": "Markera text efter markering",
"Annotate text after selection": "Anteckna text efter markering",
"Search text after selection": "Sök text efter markering",
"Look up text in dictionary after selection": "Slå upp text i ordbok efter markering",
"Look up text in Wikipedia after selection": "Slå upp text i Wikipedia efter markering",
"Translate text after selection": "Översätt text efter markering",
"Read text aloud after selection": "Läs upp text efter markering",
"Proofread text after selection": "Korrekturläs text efter markering",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktiva, {{pendingCount}} väntande",
"{{failedCount}} failed": "{{failedCount}} misslyckades",
"Waiting...": "Väntar...",
"Failed": "Misslyckades",
"Completed": "Slutförd",
"Cancelled": "Avbruten",
"Retry": "Försök igen",
"Active": "Aktiva",
"Transfer Queue": "Överföringskö",
"Upload All": "Ladda upp alla",
"Download All": "Ladda ner alla",
"Resume Transfers": "Återuppta överföringar",
"Pause Transfers": "Pausa överföringar",
"Pending": "Väntande",
"No transfers": "Inga överföringar",
"Retry All": "Försök alla igen",
"Clear Completed": "Rensa slutförda",
"Clear Failed": "Rensa misslyckade",
"Upload queued: {{title}}": "Uppladdning köad: {{title}}",
"Download queued: {{title}}": "Nedladdning köad: {{title}}",
"Book not found in library": "Boken hittades inte i biblioteket",
"Unknown error": "Okänt fel",
"Please log in to continue": "Logga in för att fortsätta",
"Cloud File Transfers": "Molnfilöverföringar",
"Show Search Results": "Visa sökresultat",
"Search results for '{{term}}'": "Resultat för '{{term}}'",
"Close Search": "Stäng sökning",
"Previous Result": "Föregående resultat",
"Next Result": "Nästa resultat",
"Bookmarks": "Bokmärken",
"Annotations": "Anteckningar",
"Show Results": "Visa resultat",
"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"
}
@@ -765,5 +765,76 @@
"Count": "எண்ணிக்கை",
"Start Page": "தொடக்கப் பக்கம்",
"Search in OPDS Catalog...": "OPDS பட்டியலில் தேடவும்...",
"Please log in to use advanced TTS features.": "மேம்பட்ட TTS அம்சங்களை பயன்படுத்த உள்நுழையவும்."
"Please log in to use advanced TTS features": "மேம்பட்ட TTS அம்சங்களை பயன்படுத்த உள்நுழையவும்",
"Word limit of 30 words exceeded.": "30 சொற்களின் வரம்பை மீறியுள்ளது.",
"Proofread": "திருத்தம்",
"Current selection": "தற்போதைய தேர்வு",
"All occurrences in this book": "இந்த புத்தகத்தில் உள்ள அனைத்து நிகழ்வுகளும்",
"All occurrences in your library": "உங்கள் நூலகத்தில் உள்ள அனைத்து நிகழ்வுகளும்",
"Selected text:": "தேர்ந்தெடுக்கப்பட்ட உரை:",
"Replace with:": "இதனால் மாற்றவும்:",
"Enter text...": "உரையை உள்ளிடவும்…",
"Case sensitive:": "எழுத்து பெரிய/சிறிய வேறுபாட்டை கருத்தில் கொள்ளவும்:",
"Scope:": "வளயம்:",
"Selection": "தேர்வு",
"Library": "நூலகம்",
"Yes": "ஆம்",
"No": "இல்லை",
"Proofread Replacement Rules": "திருத்த மாற்று விதிகள்",
"Selected Text Rules": "தேர்ந்தெடுக்கப்பட்ட உரைக்கான விதிகள்",
"No selected text replacement rules": "தேர்ந்தெடுக்கப்பட்ட உரைக்கான மாற்று விதிகள் இல்லை",
"Book Specific Rules": "புத்தகத்திற்கு குறிப்பிட்ட விதிகள்",
"No book-level replacement rules": "புத்தக மட்டத்தில் மாற்று விதிகள் இல்லை",
"Disable Quick Action": "விரைவு செயலை முடக்கு",
"Enable Quick Action on Selection": "தேர்வில் விரைவு செயலை இயக்கு",
"None": "எதுவும் இல்லை",
"Annotation Tools": "கருத்துரை கருவிகள்",
"Enable Quick Actions": "விரைவு செயல்களை இயக்கு",
"Quick Action": "விரைவு செயல்",
"Copy to Notebook": "குறிப்பேட்டியில் நகலெடு",
"Copy text after selection": "உரையை தேர்வுக்குப் பிறகு நகலெடு",
"Highlight text after selection": "உரையை தேர்வுக்குப் பிறகு முத்திரை இடு",
"Annotate text after selection": "உரையை தேர்வுக்குப் பிறகு கருத்துரை இடு",
"Search text after selection": "உரையை தேர்வுக்குப் பிறகு தேடு",
"Look up text in dictionary after selection": "தேர்வுக்குப் பிறகு அகராதியில் உரையைத் தேடு",
"Look up text in Wikipedia after selection": "தேர்வுக்குப் பிறகு விக்கிப்பீடியாவில் உரையைத் தேடு",
"Translate text after selection": "தேர்வுக்குப் பிறகு உரையை மொழிபெயர்",
"Read text aloud after selection": "தேர்வுக்குப் பிறகு உரையை ஓதுக",
"Proofread text after selection": "தேர்வுக்குப் பிறகு உரையை திருத்துக",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} செயலில், {{pendingCount}} காத்திருப்பு",
"{{failedCount}} failed": "{{failedCount}} தோல்வி",
"Waiting...": "காத்திருக்கிறது...",
"Failed": "தோல்வி",
"Completed": "முடிந்தது",
"Cancelled": "ரத்து செய்யப்பட்டது",
"Retry": "மீண்டும் முயற்சிக்கவும்",
"Active": "செயலில்",
"Transfer Queue": "பரிமாற்ற வரிசை",
"Upload All": "அனைத்தையும் பதிவேற்றவும்",
"Download All": "அனைத்தையும் பதிவிறக்கவும்",
"Resume Transfers": "பரிமாற்றங்களை தொடரவும்",
"Pause Transfers": "பரிமாற்றங்களை இடைநிறுத்தவும்",
"Pending": "காத்திருப்பு",
"No transfers": "பரிமாற்றங்கள் இல்லை",
"Retry All": "அனைத்தையும் மீண்டும் முயற்சிக்கவும்",
"Clear Completed": "முடிந்ததை அழிக்கவும்",
"Clear Failed": "தோல்வியை அழிக்கவும்",
"Upload queued: {{title}}": "பதிவேற்றம் வரிசையில்: {{title}}",
"Download queued: {{title}}": "பதிவிறக்கம் வரிசையில்: {{title}}",
"Book not found in library": "நூலகத்தில் புத்தகம் கிடைக்கவில்லை",
"Unknown error": "தெரியாத பிழை",
"Please log in to continue": "தொடர உள்நுழையவும்",
"Cloud File Transfers": "மேகக் கோப்பு பரிமாற்றங்கள்",
"Show Search Results": "தேடல் முடிவுகளைக் காட்டு",
"Search results for '{{term}}'": "'{{term}}' க்கான முடிவுகள்",
"Close Search": "தேடலை மூடு",
"Previous Result": "முந்தைய முடிவு",
"Next Result": "அடுத்த முடிவு",
"Bookmarks": "புக்மார்க்குகள்",
"Annotations": "சிறுகுறிப்புகள்",
"Show Results": "முடிவுகளைக் காட்டு",
"Clear search": "தேடலை அழி",
"Clear search history": "தேடல் வரலாற்றை அழி",
"Quick action disabled": "விரைவு செயல் முடக்கப்பட்டது",
"Tap to Toggle Footer": "அடிக்குறிப்பை மாற்ற தட்டவும்"
}
@@ -755,5 +755,76 @@
"Count": "นับ",
"Start Page": "หน้าเริ่มต้น",
"Search in OPDS Catalog...": "ค้นหาในแคตตาล็อก OPDS...",
"Please log in to use advanced TTS features.": "กรุณาเข้าสู่ระบบเพื่อใช้ฟีเจอร์ TTS ขั้นสูง"
"Please log in to use advanced TTS features": "กรุณาเข้าสู่ระบบเพื่อใช้ฟีเจอร์ TTS ขั้นสูง",
"Word limit of 30 words exceeded.": "เกินขีดจำกัด 30 คำแล้ว",
"Proofread": "ตรวจทาน",
"Current selection": "การเลือกปัจจุบัน",
"All occurrences in this book": "การปรากฏทั้งหมดในหนังสือเล่มนี้",
"All occurrences in your library": "การปรากฏทั้งหมดในคลังหนังสือของคุณ",
"Selected text:": "ข้อความที่เลือก:",
"Replace with:": "แทนที่ด้วย:",
"Enter text...": "ป้อนข้อความ…",
"Case sensitive:": "คำนึงถึงตัวพิมพ์เล็ก/ใหญ่:",
"Scope:": "ขอบเขต:",
"Selection": "การเลือก",
"Library": "คลังหนังสือ",
"Yes": "ใช่",
"No": "ไม่",
"Proofread Replacement Rules": "กฎการแทนที่สำหรับการตรวจทาน",
"Selected Text Rules": "กฎสำหรับข้อความที่เลือก",
"No selected text replacement rules": "ไม่มีกฎการแทนที่สำหรับข้อความที่เลือก",
"Book Specific Rules": "กฎเฉพาะของหนังสือ",
"No book-level replacement rules": "ไม่มีกฎการแทนที่ระดับหนังสือ",
"Disable Quick Action": "ปิดใช้งานการดำเนินการด่วน",
"Enable Quick Action on Selection": "เปิดใช้งานการดำเนินการด่วนเมื่อเลือก",
"None": "ไม่มี",
"Annotation Tools": "เครื่องมือหมายเหตุ",
"Enable Quick Actions": "เปิดใช้งานการดำเนินการด่วน",
"Quick Action": "การดำเนินการด่วน",
"Copy to Notebook": "คัดลอกไปยังสมุดบันทึก",
"Copy text after selection": "คัดลอกข้อความหลังการเลือก",
"Highlight text after selection": "ไฮไลต์ข้อความหลังการเลือก",
"Annotate text after selection": "เพิ่มหมายเหตุข้อความหลังการเลือก",
"Search text after selection": "ค้นหาข้อความหลังการเลือก",
"Look up text in dictionary after selection": "ค้นหาข้อความในพจนานุกรมหลังการเลือก",
"Look up text in Wikipedia after selection": "ค้นหาข้อความในวิกิพีเดียหลังการเลือก",
"Translate text after selection": "แปลข้อความหลังการเลือก",
"Read text aloud after selection": "อ่านข้อความออกเสียงหลังการเลือก",
"Proofread text after selection": "ตรวจทานข้อความหลังการเลือก",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} กำลังดำเนินการ, {{pendingCount}} รอดำเนินการ",
"{{failedCount}} failed": "{{failedCount}} ล้มเหลว",
"Waiting...": "กำลังรอ...",
"Failed": "ล้มเหลว",
"Completed": "เสร็จสิ้น",
"Cancelled": "ยกเลิก",
"Retry": "ลองใหม่",
"Active": "กำลังดำเนินการ",
"Transfer Queue": "คิวการถ่ายโอน",
"Upload All": "อัปโหลดทั้งหมด",
"Download All": "ดาวน์โหลดทั้งหมด",
"Resume Transfers": "ดำเนินการถ่ายโอนต่อ",
"Pause Transfers": "หยุดการถ่ายโอนชั่วคราว",
"Pending": "รอดำเนินการ",
"No transfers": "ไม่มีการถ่ายโอน",
"Retry All": "ลองใหม่ทั้งหมด",
"Clear Completed": "ล้างที่เสร็จสิ้น",
"Clear Failed": "ล้างที่ล้มเหลว",
"Upload queued: {{title}}": "อัปโหลดอยู่ในคิว: {{title}}",
"Download queued: {{title}}": "ดาวน์โหลดอยู่ในคิว: {{title}}",
"Book not found in library": "ไม่พบหนังสือในห้องสมุด",
"Unknown error": "ข้อผิดพลาดที่ไม่รู้จัก",
"Please log in to continue": "กรุณาเข้าสู่ระบบเพื่อดำเนินการต่อ",
"Cloud File Transfers": "การถ่ายโอนไฟล์คลาวด์",
"Show Search Results": "แสดงผลการค้นหา",
"Search results for '{{term}}'": "ผลลัพธ์สำหรับ '{{term}}'",
"Close Search": "ปิดการค้นหา",
"Previous Result": "ผลลัพธ์ก่อนหน้า",
"Next Result": "ผลลัพธ์ถัดไป",
"Bookmarks": "บุ๊กมาร์ก",
"Annotations": "คำอธิบายประกอบ",
"Show Results": "แสดงผลลัพธ์",
"Clear search": "ล้างการค้นหา",
"Clear search history": "ล้างประวัติการค้นหา",
"Quick action disabled": "ปิดใช้งานการดำเนินการด่วน",
"Tap to Toggle Footer": "แตะเพื่อสลับส่วนท้าย"
}
@@ -765,5 +765,76 @@
"Count": "Sayım",
"Start Page": "Başlangıç Sayfası",
"Search in OPDS Catalog...": "OPDS Kataloğunda ara...",
"Please log in to use advanced TTS features.": "Gelişmiş TTS özelliklerini kullanmak için lütfen giriş yapın."
"Please log in to use advanced TTS features": "Gelişmiş TTS özelliklerini kullanmak için lütfen giriş yapın",
"Word limit of 30 words exceeded.": "30 kelime sınırı aşıldı.",
"Proofread": "Düzelt",
"Current selection": "Mevcut seçim",
"All occurrences in this book": "Bu kitaptaki tüm geçenler",
"All occurrences in your library": "Kütüphanenizdeki tüm geçenler",
"Selected text:": "Seçili metin:",
"Replace with:": "Şununla değiştir:",
"Enter text...": "Metin girin…",
"Case sensitive:": "Büyük/küçük harfe duyarlı:",
"Scope:": "Kapsam:",
"Selection": "Seçim",
"Library": "Kütüphane",
"Yes": "Evet",
"No": "Hayır",
"Proofread Replacement Rules": "Düzeltme Değiştirme Kuralları",
"Selected Text Rules": "Seçili Metin Kuralları",
"No selected text replacement rules": "Seçili metin için değiştirme kuralı yok",
"Book Specific Rules": "Kitaba Özel Kurallar",
"No book-level replacement rules": "Kitap düzeyinde değiştirme kuralı yok",
"Disable Quick Action": "Hızlı işlemi devre dışı bırak",
"Enable Quick Action on Selection": "Seçimde hızlı işlemi etkinleştir",
"None": "Hiçbiri",
"Annotation Tools": "Not araçları",
"Enable Quick Actions": "Hızlı işlemleri etkinleştir",
"Quick Action": "Hızlı işlem",
"Copy to Notebook": "Deftere kopyala",
"Copy text after selection": "Seçimden sonra metni kopyala",
"Highlight text after selection": "Seçimden sonra metni vurgula",
"Annotate text after selection": "Seçimden sonra metni not al",
"Search text after selection": "Seçimden sonra metni ara",
"Look up text in dictionary after selection": "Seçimden sonra metni sözlükte ara",
"Look up text in Wikipedia after selection": "Seçimden sonra metni Vikipedya'da ara",
"Translate text after selection": "Seçimden sonra metni çevir",
"Read text aloud after selection": "Seçimden sonra metni sesli oku",
"Proofread text after selection": "Seçimden sonra metni düzelt",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} beklemede",
"{{failedCount}} failed": "{{failedCount}} başarısız",
"Waiting...": "Bekleniyor...",
"Failed": "Başarısız",
"Completed": "Tamamlandı",
"Cancelled": "İptal edildi",
"Retry": "Yeniden dene",
"Active": "Aktif",
"Transfer Queue": "Transfer Kuyruğu",
"Upload All": "Tümünü Yükle",
"Download All": "Tümünü İndir",
"Resume Transfers": "Transferleri Sürdür",
"Pause Transfers": "Transferleri Duraklat",
"Pending": "Beklemede",
"No transfers": "Transfer yok",
"Retry All": "Tümünü Yeniden Dene",
"Clear Completed": "Tamamlananları Temizle",
"Clear Failed": "Başarısızları Temizle",
"Upload queued: {{title}}": "Yükleme sıraya alındı: {{title}}",
"Download queued: {{title}}": "İndirme sıraya alındı: {{title}}",
"Book not found in library": "Kitap kütüphanede bulunamadı",
"Unknown error": "Bilinmeyen hata",
"Please log in to continue": "Devam etmek için giriş yapın",
"Cloud File Transfers": "Bulut Dosya Transferleri",
"Show Search Results": "Arama sonuçlarını göster",
"Search results for '{{term}}'": "'{{term}}' için sonuçlar",
"Close Search": "Aramayı kapat",
"Previous Result": "Önceki sonuç",
"Next Result": "Sonraki sonuç",
"Bookmarks": "Yer İşaretleri",
"Annotations": "Açıklamalar",
"Show Results": "Sonuçları Göster",
"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"
}
@@ -785,5 +785,76 @@
"Count": "Кількість",
"Start Page": "Початкова сторінка",
"Search in OPDS Catalog...": "Пошук у каталозі OPDS...",
"Please log in to use advanced TTS features.": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS."
"Please log in to use advanced TTS features": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS",
"Word limit of 30 words exceeded.": "Перевищено ліміт у 30 слів.",
"Proofread": "Вичитування",
"Current selection": "Поточне виділення",
"All occurrences in this book": "Усі входження в цій книзі",
"All occurrences in your library": "Усі входження у вашій бібліотеці",
"Selected text:": "Виділений текст:",
"Replace with:": "Замінити на:",
"Enter text...": "Введіть текст…",
"Case sensitive:": "З урахуванням регістру:",
"Scope:": "Область застосування:",
"Selection": "Виділення",
"Library": "Бібліотека",
"Yes": "Так",
"No": "Ні",
"Proofread Replacement Rules": "Правила заміни для вичитування",
"Selected Text Rules": "Правила для виділеного тексту",
"No selected text replacement rules": "Немає правил заміни для виділеного тексту",
"Book Specific Rules": "Правила для конкретної книги",
"No book-level replacement rules": "Немає правил заміни на рівні книги",
"Disable Quick Action": "Вимкнути швидку дію",
"Enable Quick Action on Selection": "Увімкнути швидку дію при виборі",
"None": "Жоден",
"Annotation Tools": "Інструменти анотацій",
"Enable Quick Actions": "Увімкнути швидкі дії",
"Quick Action": "Швидка дія",
"Copy to Notebook": "Скопіювати до блокнота",
"Copy text after selection": "Скопіювати текст після виділення",
"Highlight text after selection": "Виділити текст після виділення",
"Annotate text after selection": "Додати анотацію до тексту після виділення",
"Search text after selection": "Шукати текст після виділення",
"Look up text in dictionary after selection": "Шукати текст у словнику після виділення",
"Look up text in Wikipedia after selection": "Шукати текст у Вікіпедії після виділення",
"Translate text after selection": "Перекласти текст після виділення",
"Read text aloud after selection": "Прочитати текст вголос після виділення",
"Proofread text after selection": "Вичитати текст після виділення",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} активних, {{pendingCount}} очікують",
"{{failedCount}} failed": "{{failedCount}} невдалих",
"Waiting...": "Очікування...",
"Failed": "Помилка",
"Completed": "Завершено",
"Cancelled": "Скасовано",
"Retry": "Повторити",
"Active": "Активні",
"Transfer Queue": "Черга передачі",
"Upload All": "Завантажити все",
"Download All": "Завантажити все",
"Resume Transfers": "Продовжити передачу",
"Pause Transfers": "Призупинити передачу",
"Pending": "Очікують",
"No transfers": "Немає передач",
"Retry All": "Повторити все",
"Clear Completed": "Очистити завершені",
"Clear Failed": "Очистити невдалі",
"Upload queued: {{title}}": "Завантаження в черзі: {{title}}",
"Download queued: {{title}}": "Завантаження в черзі: {{title}}",
"Book not found in library": "Книгу не знайдено в бібліотеці",
"Unknown error": "Невідома помилка",
"Please log in to continue": "Увійдіть, щоб продовжити",
"Cloud File Transfers": "Передача файлів у хмару",
"Show Search Results": "Показати результати",
"Search results for '{{term}}'": "Результати для «{{term}}»",
"Close Search": "Закрити пошук",
"Previous Result": "Попередній результат",
"Next Result": "Наступний результат",
"Bookmarks": "Закладки",
"Annotations": "Анотації",
"Show Results": "Показати результати",
"Clear search": "Очистити пошук",
"Clear search history": "Очистити історію пошуку",
"Quick action disabled": "Швидку дію вимкнено",
"Tap to Toggle Footer": "Торкніться, щоб перемкнути нижній колонтитул"
}
@@ -755,5 +755,76 @@
"Count": "Số lượng",
"Start Page": "Trang bắt đầu",
"Search in OPDS Catalog...": "Tìm kiếm trong danh mục OPDS...",
"Please log in to use advanced TTS features.": "Vui lòng đăng nhập để sử dụng các tính năng TTS nâng cao."
"Please log in to use advanced TTS features": "Vui lòng đăng nhập để sử dụng các tính năng TTS nâng cao",
"Word limit of 30 words exceeded.": "Đã vượt quá giới hạn 30 từ.",
"Proofread": "Hiệu đính",
"Current selection": "Vùng chọn hiện tại",
"All occurrences in this book": "Tất cả các lần xuất hiện trong sách này",
"All occurrences in your library": "Tất cả các lần xuất hiện trong thư viện của bạn",
"Selected text:": "Văn bản đã chọn:",
"Replace with:": "Thay thế bằng:",
"Enter text...": "Nhập văn bản…",
"Case sensitive:": "Phân biệt chữ hoa/thường:",
"Scope:": "Phạm vi:",
"Selection": "Vùng chọn",
"Library": "Thư viện",
"Yes": "Có",
"No": "Không",
"Proofread Replacement Rules": "Quy tắc thay thế hiệu đính",
"Selected Text Rules": "Quy tắc cho văn bản đã chọn",
"No selected text replacement rules": "Không có quy tắc thay thế cho văn bản đã chọn",
"Book Specific Rules": "Quy tắc dành riêng cho sách",
"No book-level replacement rules": "Không có quy tắc thay thế ở cấp sách",
"Disable Quick Action": "Vô hiệu hóa Hành động Nhanh",
"Enable Quick Action on Selection": "Bật Hành động Nhanh khi chọn",
"None": "Không có",
"Annotation Tools": "Công cụ chú thích",
"Enable Quick Actions": "Bật Hành động Nhanh",
"Quick Action": "Hành động Nhanh",
"Copy to Notebook": "Sao chép vào Sổ tay",
"Copy text after selection": "Sao chép văn bản sau khi chọn",
"Highlight text after selection": "Đánh dấu văn bản sau khi chọn",
"Annotate text after selection": "Chú thích văn bản sau khi chọn",
"Search text after selection": "Tìm kiếm văn bản sau khi chọn",
"Look up text in dictionary after selection": "Tra cứu văn bản trong từ điển sau khi chọn",
"Look up text in Wikipedia after selection": "Tra cứu văn bản trong Wikipedia sau khi chọn",
"Translate text after selection": "Dịch văn bản sau khi chọn",
"Read text aloud after selection": "Đọc to văn bản sau khi chọn",
"Proofread text after selection": "Hiệu đính văn bản sau khi chọn",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} đang hoạt động, {{pendingCount}} đang chờ",
"{{failedCount}} failed": "{{failedCount}} thất bại",
"Waiting...": "Đang chờ...",
"Failed": "Thất bại",
"Completed": "Hoàn thành",
"Cancelled": "Đã hủy",
"Retry": "Thử lại",
"Active": "Đang hoạt động",
"Transfer Queue": "Hàng đợi truyền",
"Upload All": "Tải lên tất cả",
"Download All": "Tải xuống tất cả",
"Resume Transfers": "Tiếp tục truyền",
"Pause Transfers": "Tạm dừng truyền",
"Pending": "Đang chờ",
"No transfers": "Không có truyền",
"Retry All": "Thử lại tất cả",
"Clear Completed": "Xóa đã hoàn thành",
"Clear Failed": "Xóa thất bại",
"Upload queued: {{title}}": "Tải lên trong hàng đợi: {{title}}",
"Download queued: {{title}}": "Tải xuống trong hàng đợi: {{title}}",
"Book not found in library": "Không tìm thấy sách trong thư viện",
"Unknown error": "Lỗi không xác định",
"Please log in to continue": "Vui lòng đăng nhập để tiếp tục",
"Cloud File Transfers": "Truyền tệp đám mây",
"Show Search Results": "Hiển thị kết quả tìm kiếm",
"Search results for '{{term}}'": "Kết quả cho '{{term}}'",
"Close Search": "Đóng tìm kiếm",
"Previous Result": "Kết quả trước",
"Next Result": "Kết quả tiếp theo",
"Bookmarks": "Dấu trang",
"Annotations": "Chú thích",
"Show Results": "Hiển thị kết quả",
"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"
}
@@ -755,5 +755,76 @@
"Count": "数量",
"Start Page": "起始页",
"Search in OPDS Catalog...": "在 OPDS 目录中搜索...",
"Please log in to use advanced TTS features.": "请登录以使用高级 TTS 功能"
"Please log in to use advanced TTS features": "请登录以使用高级 TTS 功能",
"Word limit of 30 words exceeded.": "已超过 30 个词的限制。",
"Proofread": "校对",
"Current selection": "当前选中内容",
"All occurrences in this book": "本书中的所有出现位置",
"All occurrences in your library": "整个书库中的所有出现位置",
"Selected text:": "选中文本:",
"Replace with:": "替换为:",
"Enter text...": "请输入文本…",
"Case sensitive:": "区分大小写:",
"Scope:": "作用范围:",
"Selection": "选中内容",
"Library": "书库",
"Yes": "是",
"No": "否",
"Proofread Replacement Rules": "校对替换规则",
"Selected Text Rules": "选中文本规则",
"No selected text replacement rules": "暂无选中文本的替换规则",
"Book Specific Rules": "书籍范围规则",
"No book-level replacement rules": "暂无书籍级别的替换规则",
"Disable Quick Action": "禁用快速操作",
"Enable Quick Action on Selection": "在选择时启用快速操作",
"None": "无",
"Annotation Tools": "注释工具",
"Enable Quick Actions": "启用快捷操作",
"Quick Action": "快捷操作",
"Copy to Notebook": "复制到笔记本",
"Copy text after selection": "复制选中文本",
"Highlight text after selection": "划线选中文本",
"Annotate text after selection": "注释选中文本",
"Search text after selection": "搜索选中文本",
"Look up text in dictionary after selection": "在字典中查找选中文本",
"Look up text in Wikipedia after selection": "在维基百科中查找选中文本",
"Translate text after selection": "翻译选中文本",
"Read text aloud after selection": "朗读选中文本",
"Proofread text after selection": "校对选中文本",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 进行中,{{pendingCount}} 等待中",
"{{failedCount}} failed": "{{failedCount}} 失败",
"Waiting...": "等待中...",
"Failed": "失败",
"Completed": "已完成",
"Cancelled": "已取消",
"Retry": "重试",
"Active": "进行中",
"Transfer Queue": "传输队列",
"Upload All": "全部上传",
"Download All": "全部下载",
"Resume Transfers": "继续传输",
"Pause Transfers": "暂停传输",
"Pending": "等待中",
"No transfers": "没有传输",
"Retry All": "全部重试",
"Clear Completed": "清除已完成",
"Clear Failed": "清除失败",
"Upload queued: {{title}}": "上传已排队:{{title}}",
"Download queued: {{title}}": "下载已排队:{{title}}",
"Book not found in library": "在书库中找不到书籍",
"Unknown error": "未知错误",
"Please log in to continue": "请登录以继续",
"Cloud File Transfers": "云文件传输",
"Show Search Results": "显示搜索结果",
"Search results for '{{term}}'": "包含\"{{term}}\"的结果",
"Close Search": "关闭搜索",
"Previous Result": "上一个结果",
"Next Result": "下一个结果",
"Bookmarks": "书签",
"Annotations": "注释",
"Show Results": "显示结果",
"Clear search": "清除搜索",
"Clear search history": "清除搜索历史",
"Quick action disabled": "快捷操作已禁用",
"Tap to Toggle Footer": "点击切换页脚"
}
@@ -755,5 +755,76 @@
"Count": "數量",
"Start Page": "起始頁",
"Search in OPDS Catalog...": "在 OPDS 目錄中搜尋...",
"Please log in to use advanced TTS features.": "請登入以使用進階 TTS 功能"
"Please log in to use advanced TTS features": "請登入以使用進階 TTS 功能",
"Word limit of 30 words exceeded.": "已超過 30 個詞的限制。",
"Proofread": "校對",
"Current selection": "目前選取內容",
"All occurrences in this book": "本書中的所有出現位置",
"All occurrences in your library": "整個書庫中的所有出現位置",
"Selected text:": "已選取文字:",
"Replace with:": "取代為:",
"Enter text...": "請輸入文字…",
"Case sensitive:": "區分大小寫:",
"Scope:": "適用範圍:",
"Selection": "選取內容",
"Library": "書庫",
"Yes": "是",
"No": "否",
"Proofread Replacement Rules": "校對取代規則",
"Selected Text Rules": "已選取文字規則",
"No selected text replacement rules": "沒有已選取文字的取代規則",
"Book Specific Rules": "書籍層級規則",
"No book-level replacement rules": "沒有書籍層級的取代規則",
"Disable Quick Action": "停用快速操作",
"Enable Quick Action on Selection": "選取時啟用快速操作",
"None": "無",
"Annotation Tools": "註解工具",
"Enable Quick Actions": "啟用快速操作",
"Quick Action": "快速操作",
"Copy to Notebook": "複製到筆記本",
"Copy text after selection": "複製選取後的文字",
"Highlight text after selection": "劃線選取後的文字",
"Annotate text after selection": "註解選取後的文字",
"Search text after selection": "搜尋選取後的文字",
"Look up text in dictionary after selection": "在字典中查找選取後的文字",
"Look up text in Wikipedia after selection": "在維基百科中查找選取後的文字",
"Translate text after selection": "翻譯選取後的文字",
"Read text aloud after selection": "朗讀選取後的文字",
"Proofread text after selection": "校對選取後的文字",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 進行中,{{pendingCount}} 等待中",
"{{failedCount}} failed": "{{failedCount}} 失敗",
"Waiting...": "等待中...",
"Failed": "失敗",
"Completed": "已完成",
"Cancelled": "已取消",
"Retry": "重試",
"Active": "進行中",
"Transfer Queue": "傳輸佇列",
"Upload All": "全部上傳",
"Download All": "全部下載",
"Resume Transfers": "繼續傳輸",
"Pause Transfers": "暫停傳輸",
"Pending": "等待中",
"No transfers": "沒有傳輸",
"Retry All": "全部重試",
"Clear Completed": "清除已完成",
"Clear Failed": "清除失敗",
"Upload queued: {{title}}": "上傳已排隊:{{title}}",
"Download queued: {{title}}": "下載已排隊:{{title}}",
"Book not found in library": "在書庫中找不到書籍",
"Unknown error": "未知錯誤",
"Please log in to continue": "請登入以繼續",
"Cloud File Transfers": "雲端檔案傳輸",
"Show Search Results": "顯示搜尋結果",
"Search results for '{{term}}'": "包含「{{term}}」的結果",
"Close Search": "關閉搜尋",
"Previous Result": "上一個結果",
"Next Result": "下一個結果",
"Bookmarks": "書籤",
"Annotations": "註解",
"Show Results": "顯示結果",
"Clear search": "清除搜尋",
"Clear search history": "清除搜尋歷史",
"Quick action disabled": "快捷操作已停用",
"Tap to Toggle Footer": "點擊切換頁尾"
}
+18
View File
@@ -1,5 +1,23 @@
{
"releases": {
"0.9.97": {
"date": "2026-01-10",
"notes": [
"Annotations: Added annotation bubble icons directly on text with instant note popups",
"Annotations: Enabled quick highlighting when selecting text",
"Annotations: Added the ability to edit the text range of existing highlights",
"Annotations: Added an annotation navigation bar for easier browsing",
"OPDS: Fixed an issue where CBZ files could not be downloaded from OPDS catalogs",
"OPDS: Improved compatibility with Komga OPDS servers",
"OPDS: Added support for OPDS servers using self-signed SSL certificates",
"PDF: Fixed an issue where some PDF files failed to open",
"E-ink: Optimized colors and layout for improved e-ink device readability",
"Search: Added search term history for quicker repeated searches",
"Text-to-Speech: Reduced excessive pauses between sentences for smoother playback",
"File Transfer: Introduced a background transfer queue for uploading and downloading books",
"Proofreading: Added a proofread tool that can replace text directly within books"
]
},
"0.9.96": {
"date": "2025-12-19",
"notes": [
@@ -3,6 +3,7 @@ package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.webkit.WebView
import android.net.Uri
import android.util.Log
@@ -47,6 +48,48 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
interceptBackKeyEnabled = enabled
}
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
val action = when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> "touchstart"
MotionEvent.ACTION_UP -> "touchend"
MotionEvent.ACTION_CANCEL -> "touchcancel"
MotionEvent.ACTION_POINTER_DOWN -> "touchstart"
MotionEvent.ACTION_POINTER_UP -> "touchend"
else -> null
}
action?.let { eventType ->
val pointerIndex = event.actionIndex
val pointerId = event.getPointerId(pointerIndex)
val x = event.getX(pointerIndex)
val y = event.getY(pointerIndex)
val pressure = event.getPressure(pointerIndex)
wv.evaluateJavascript(
"""
try {
if (window.onNativeTouch) {
window.onNativeTouch({
type: "$eventType",
pointerId: $pointerId,
x: $x,
y: $y,
pressure: $pressure,
pointerCount: ${event.pointerCount},
timestamp: ${event.eventTime}
});
}
} catch (err) {
console.error('Native touch error:', err);
}
""".trimIndent(),
null
)
}
return super.dispatchTouchEvent(event)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
@@ -53,7 +53,18 @@
WriteRegStr HKCR ".cbr\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
DetailPrint "Thumbnail provider registered successfully."
Delete "$DESKTOP\Readest.lnk"
Delete "$SMPROGRAMS\Readest\Readest.lnk"
RMDir "$SMPROGRAMS\Readest"
; Create new shortcuts pointing to current installation
CreateShortcut "$DESKTOP\Readest.lnk" "$INSTDIR\Readest.exe"
CreateDirectory "$SMPROGRAMS\Readest"
CreateShortcut "$SMPROGRAMS\Readest\Readest.lnk" "$INSTDIR\Readest.exe"
DetailPrint "Shortcuts updated."
; Refresh shell to apply changes - SHCNE_ASSOCCHANGED
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
!macroend
@@ -4,29 +4,29 @@
import PackageDescription
let package = Package(
name: "tauri-plugin-native-tts",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-tts",
type: .static,
targets: ["tauri-plugin-native-tts"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-tts",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
name: "tauri-plugin-native-tts",
platforms: [
.macOS(.v10_13),
.iOS(.v14),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-tts",
type: .static,
targets: ["tauri-plugin-native-tts"])
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-tts",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
)
@@ -0,0 +1,122 @@
use std::process::Command;
/// Known e-ink device manufacturers and brands (case-insensitive matching)
const EINK_MANUFACTURERS: &[&str] = &[
"onyx", // BOOX devices
"boox", // BOOX devices (alternate)
"amazon", // Kindle devices
"kobo", // Kobo e-readers
"remarkable", // reMarkable tablets
"pocketbook", // PocketBook e-readers
"boyue", // Boyue/Likebook devices
"likebook", // Likebook devices
"dasung", // Dasung e-ink monitors
"bigme", // Bigme e-readers
"hisense", // Hisense e-ink phones (A5, A7, etc.)
"hanvon", // Hanvon e-readers
"tolino", // Tolino e-readers
"bookeen", // Bookeen e-readers
"supernote", // Supernote devices
"mobiscribe", // Mobiscribe e-readers
"xiaomi", // Xiaomi InkPalm (needs model check)
"meebook", // Meebook e-readers
];
/// Known e-ink device models (for manufacturers that also make non-e-ink devices)
const EINK_MODELS: &[&str] = &[
"kindle",
"a5pro",
"a7cc", // Hisense e-ink models
"a7e",
"a9",
"inkpalm", // Xiaomi InkPalm
"eink",
"e-ink",
"paper",
"note air",
"note2",
"note3",
"note5",
"nova",
"poke",
"leaf",
"page",
"tab ultra",
"max lumi",
];
/// Get Android system property using getprop command
fn get_system_property(prop: &str) -> Option<String> {
Command::new("getprop")
.arg(prop)
.output()
.ok()
.and_then(|output| {
if output.status.success() {
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
} else {
None
}
})
}
/// Check if the current Android device is an e-ink device
pub fn is_eink_device() -> bool {
// Get device manufacturer and model
let manufacturer = get_system_property("ro.product.manufacturer")
.or_else(|| get_system_property("ro.product.brand"))
.unwrap_or_default()
.to_lowercase();
let model = get_system_property("ro.product.model")
.or_else(|| get_system_property("ro.product.device"))
.unwrap_or_default()
.to_lowercase();
let device = get_system_property("ro.product.device")
.unwrap_or_default()
.to_lowercase();
// Check if manufacturer matches known e-ink manufacturers
for eink_manufacturer in EINK_MANUFACTURERS {
if manufacturer.contains(eink_manufacturer) {
// Special case for manufacturers that make both e-ink and non-e-ink devices
if *eink_manufacturer == "hisense" || *eink_manufacturer == "xiaomi" {
// Need to also check the model for these manufacturers
for eink_model in EINK_MODELS {
if model.contains(eink_model) || device.contains(eink_model) {
return true;
}
}
} else {
return true;
}
}
}
// Check if model matches known e-ink models
for eink_model in EINK_MODELS {
if model.contains(eink_model) || device.contains(eink_model) {
return true;
}
}
// Check for e-ink specific system properties
if let Some(eink_support) = get_system_property("ro.eink.support") {
if eink_support == "1" || eink_support.to_lowercase() == "true" {
return true;
}
}
// Check for BOOX specific property
if get_system_property("ro.onyx.devicename").is_some() {
return true;
}
false
}
@@ -0,0 +1,3 @@
pub mod eink;
pub use eink::is_eink_device;
+48 -25
View File
@@ -9,6 +9,9 @@ extern crate objc;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "android")]
mod android;
use tauri::utils::config::BackgroundThrottlingPolicy;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -270,34 +273,54 @@ pub fn run() {
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();
#[cfg(not(target_os = "android"))]
let is_eink = false;
let eink_script = if is_eink {
"window.__READEST_IS_EINK = true;"
} else {
""
};
let init_script = format!(
r#"
{eink_script}
window.addEventListener('DOMContentLoaded', function() {{
document.documentElement.classList.add('edge-to-edge');
const isTauriLocal = window.location.protocol === 'tauri:' ||
window.location.protocol === 'about:' ||
window.location.hostname === 'tauri.localhost';
const needsSafeArea = !isTauriLocal;
if (needsSafeArea && !document.getElementById('safe-area-style')) {{
const style = document.createElement('style');
style.id = 'safe-area-style';
style.textContent = `
body {{
padding-top: env(safe-area-inset-top) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
padding-left: env(safe-area-inset-left) !important;
padding-right: env(safe-area-inset-right) !important;
}}
`;
document.head.appendChild(style);
}}
}});
"#,
eink_script = eink_script
);
let app_handle = app.handle().clone();
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.background_throttling(BackgroundThrottlingPolicy::Disabled)
.background_color(tauri::window::Color(50, 49, 48, 255))
.initialization_script(
r#"
window.addEventListener('DOMContentLoaded', function() {
document.documentElement.classList.add('edge-to-edge');
const isTauriLocal = window.location.protocol === 'tauri:' ||
window.location.protocol === 'about:' ||
window.location.hostname === 'tauri.localhost';
const needsSafeArea = !isTauriLocal;
if (needsSafeArea && !document.getElementById('safe-area-style')) {
const style = document.createElement('style');
style.id = 'safe-area-style';
style.textContent = `
body {
padding-top: env(safe-area-inset-top) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
padding-left: env(safe-area-inset-left) !important;
padding-right: env(safe-area-inset-right) !important;
}
`;
document.head.appendChild(style);
}
});
"#,
)
.background_color(if is_eink {
tauri::window::Color(255, 255, 255, 255)
} else {
tauri::window::Color(50, 49, 48, 255)
})
.initialization_script(&init_script)
.on_navigation(move |url| {
if url.scheme() == "alipays" || url.scheme() == "alipay" {
let url_str = url.as_str().to_string();
@@ -108,6 +108,7 @@ pub async fn download_file(
headers: HashMap<String, String>,
body: Option<String>,
single_threaded: Option<bool>,
skip_ssl_verification: Option<bool>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
use futures::stream::{self, StreamExt};
@@ -116,7 +117,10 @@ pub async fn download_file(
const PART_SIZE: u64 = 1024 * 1024;
let client = reqwest::Client::new();
let client = reqwest::ClientBuilder::new()
.danger_accept_invalid_certs(skip_ssl_verification.unwrap_or(false))
.danger_accept_invalid_hostnames(skip_ssl_verification.unwrap_or(false))
.build()?;
let force_single = single_threaded.unwrap_or(false);
async fn single_threaded_download(
+3 -3
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.netlify.app 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.netlify.app https://cdnjs.cloudflare.com",
"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",
"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"
},
@@ -73,7 +73,7 @@
"iOS": {
"developmentTeam": "J5W48D69VR",
"infoPlist": "./Info-ios.plist",
"minimumSystemVersion": "14.0"
"minimumSystemVersion": "15.0"
},
"fileAssociations": [
{
@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { EnvProvider } from '@/context/EnvContext';
import ProofreadPopup from '@/app/reader/components/annotator/ProofreadPopup';
vi.mock('@/services/environment', async () => {
const actual = await vi.importActual('@/services/environment');
const mockAppService = {
init: vi.fn().mockResolvedValue(undefined),
// Add any other methods from AppService interface
};
return {
...actual,
default: {
getAppService: vi.fn().mockResolvedValue(mockAppService),
},
};
});
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe('ProofreadPopup Component', () => {
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
bookKey: 'test-book',
isVertical: false,
selectedText: 'test word',
selection: {
key: 'test-book',
text: 'test word',
cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)',
range: {
deleteContents: vi.fn(),
insertNode: vi.fn(),
startContainer: document.createTextNode('test word here'),
endContainer: document.createTextNode('test word here'),
startOffset: 5,
endOffset: 9,
} as unknown as Range,
index: 0,
},
position: { point: { x: 100, y: 100 } },
trianglePosition: { point: { x: 100, y: 100 } },
popupWidth: 440,
popupHeight: 200,
onConfirm: mockOnConfirm,
onDismiss: mockOnClose,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('Rendering', () => {
it('should render default replacement scope options', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Current selection')).toBeTruthy();
});
it('should render the replacement text input field', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
expect(input).toBeTruthy();
});
it('should render the case sensitive checkbox', () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Case sensitive:')).toBeTruthy();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
});
it('should render the Apply button', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Apply')).toBeTruthy();
});
it('should display selected text preview', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText(/Selected text:/)).toBeTruthy();
expect(screen.getByText(/"test word"/)).toBeTruthy();
});
});
describe('Case Sensitive Checkbox', () => {
it('should be checked by default (case-sensitive)', () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('should toggle when clicked', async () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
});
describe('Replacement Text Input', () => {
it('should update value when user types', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'new text' } });
expect(input.value).toBe('new text');
});
it('should trim whitespace from replacement text', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: ' trimmed ' } });
const confirmButton = screen.getByText('Apply');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith(
expect.objectContaining({
replacement: 'trimmed',
}),
);
});
});
describe('Scope Selection Handlers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createValidSelection = () => ({
...defaultProps,
selection: {
...defaultProps.selection,
text: 'word',
cfi: 'epubcfi(/6/4[chap01ref]!/4/2/1:0)',
range: {
deleteContents: vi.fn(),
insertNode: vi.fn(),
startContainer: document.createTextNode('test word here'),
endContainer: document.createTextNode('test word here'),
startOffset: 5,
endOffset: 9,
} as unknown as Range,
},
});
it('should call onConfirm with correct scope for "selection"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'selection' }));
});
});
it('should call onConfirm with correct scope for "book"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const scopeSelect = screen.getByRole('combobox');
fireEvent.change(scopeSelect, { target: { value: 'book' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' }));
});
});
it('should call onConfirm with correct scope for "library"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const scopeSelect = screen.getByRole('combobox');
fireEvent.change(scopeSelect, { target: { value: 'library' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' }));
});
});
});
describe('Click Outside Behavior', () => {
it('should not call onClose when clicking inside the menu', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.mouseDown(input);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,559 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import {
ProofreadRulesManager,
setProofreadRulesVisibility,
} from '@/app/reader/components/ProofreadRules';
import BookMenu from '@/app/reader/components/sidebar/BookMenu';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ProofreadRule } from '@/types/book';
// ------------------------------
// NEXT.JS ROUTER MOCK
// ------------------------------
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
toString: () => '',
}),
}));
// ------------------------------
// TRANSLATION MOCK
// ------------------------------
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
vi.mock('@/services/translators/cache', () => ({
initCache: vi.fn(),
loadCacheFromDB: vi.fn(),
pruneCache: vi.fn(),
}));
// ------------------------------
// ENV PROVIDER WRAPPER
// ------------------------------
// mock environment module so EnvProvider uses fake values
vi.mock('@/services/environment', async (importOriginal) => {
const actual = await importOriginal();
return {
...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform)
default: {
...(typeof actual === 'object' &&
actual !== null &&
'default' in actual &&
typeof actual.default === 'object' &&
actual.default !== null
? actual.default
: {}), // keep all real default fields
API_BASE: 'http://localhost',
ENABLE_TRANSLATOR: false,
getAppService: vi.fn().mockResolvedValue(null),
},
};
});
import { EnvProvider } from '@/context/EnvContext';
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe('ProofreadRulesManager', () => {
beforeEach(() => {
// Reset stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: {
enabled: false,
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
useSidebarStore.setState({ sideBarBookKey: null });
(useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} });
});
afterEach(() => {
cleanup();
});
it('renders book and library (global) proofreading rules from stores', async () => {
// Arrange: populate stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [
{
id: 'g1',
scope: 'library',
pattern: 'foo',
replacement: 'bar',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
},
{
id: 'g2',
scope: 'library',
pattern: 'hello',
replacement: 'world',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
},
],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ProofreadRulesManager />);
// wait a tick so the component's effect attaches the event listener
await Promise.resolve();
// open via helper which dispatches the custom event
setProofreadRulesVisibility(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Library (global) rules
expect(screen.getByText('foo')).toBeTruthy();
expect(screen.getByText('bar')).toBeTruthy();
expect(screen.getByText('hello')).toBeTruthy();
expect(screen.getByText('world')).toBeTruthy();
});
it('renders selection rules separately from book/library rules', async () => {
// Arrange: populate stores with a selection rule persisted in book config
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
const selectionRule: ProofreadRule = {
id: 's1',
scope: 'selection',
pattern: 'only-once',
replacement: 'single-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
sectionHref: 'chapter1.html',
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-wide',
replacement: 'book-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Single Instance Rules section
expect(screen.getByText('Selected Text Rules')).toBeTruthy();
expect(screen.getByText('only-once')).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();
});
it('displays correct scope labels for different rule types', async () => {
const selectionRule: ProofreadRule = {
id: 's1',
scope: 'selection',
pattern: 'select-text',
replacement: 'replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
sectionHref: 'chapter1.html',
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-pattern',
replacement: 'book-replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
const libraryRule: ProofreadRule = {
id: 'l1',
scope: 'library',
pattern: 'library-pattern',
replacement: 'library-replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 3,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [libraryRule],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
const selectionRuleElement = screen.getByText('select-text').closest('li');
expect(within(selectionRuleElement!).getByText(/Selection/)).toBeTruthy();
const bookRuleElement = screen.getByText('book-pattern').closest('li');
expect(within(bookRuleElement!).getByText(/Book/)).toBeTruthy();
const libraryRuleElement = screen.getByText('library-pattern').closest('li');
expect(within(libraryRuleElement!).getByText(/Library/)).toBeTruthy();
});
it('shows case sensitivity status for each rule', async () => {
const caseSensitiveRule: ProofreadRule = {
id: 'cs1',
scope: 'book',
pattern: 'case-sensitive',
replacement: 'CS-REPLACED',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
const caseInsensitiveRule: ProofreadRule = {
id: 'ci1',
scope: 'book',
pattern: 'case-insensitive',
replacement: 'CI-REPLACED',
enabled: true,
isRegex: false,
caseSensitive: false,
order: 2,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [caseSensitiveRule, caseInsensitiveRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [caseSensitiveRule, caseInsensitiveRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
const csRuleElement = screen.getByText('case-sensitive').closest('li');
expect(within(csRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(csRuleElement!).getByText(/Yes/)).toBeTruthy();
const ciRuleElement = screen.getByText('case-insensitive').closest('li');
expect(within(ciRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(ciRuleElement!).getByText(/No/)).toBeTruthy();
});
it('opens when BookMenu item is clicked (integration)', async () => {
// Arrange stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: { viewSettings: { proofreadRules: [] } },
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Render both menu and window
renderWithProviders(
<div>
<BookMenu />
<ProofreadRulesManager />
</div>,
);
// wait a tick so effects attach
await Promise.resolve();
// Click the menu item
const menuItem = screen.getByRole('menuitem', { name: 'Proofread' });
fireEvent.click(menuItem);
// The dialog should open
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Proofread Replacement Rules')).toBeTruthy();
});
it('shows empty state messages when no rules exist', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Check for empty state messages
expect(screen.getByText('No selected text replacement rules')).toBeTruthy();
expect(screen.getByText('No book-level replacement rules')).toBeTruthy();
});
it('merges book and library rules correctly in book section', async () => {
const libraryRule: ProofreadRule = {
id: 'l1',
scope: 'library',
pattern: 'library-wide',
replacement: 'LIBRARY',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-specific',
replacement: 'BOOK',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [libraryRule],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Both library and book rules should appear in the Book Specific Rules section
expect(screen.getByText('library-wide')).toBeTruthy();
expect(screen.getByText('book-specific')).toBeTruthy();
// But they should both be under Book Specific Rules section
const bookSection = screen.getByText('Book Specific Rules').parentElement;
expect(within(bookSection!).getByText('library-wide')).toBeTruthy();
expect(within(bookSection!).getByText('book-specific')).toBeTruthy();
});
});
@@ -1,390 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import ReplacementOptions from '@/app/reader/components/annotator/ReplacementOptions';
describe('ReplacementOptions Component', () => {
// IMPORTANT: ReplacementOptions should ONLY be rendered for EPUB books.
// for non-EPUB formats (PDF, TXT, etc), the button is disabled
// and ReplacementOptions is never rendered/shown to the user.
//
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
isVertical: false,
style: { left: '100px', top: '100px' },
selectedText: 'test word',
onConfirm: mockOnConfirm,
onClose: mockOnClose,
};
// Note: ReplacementOptions component should only be rendered for EPUB books.
// All tests here implicitly test EPUB book scenarios.
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('Rendering', () => {
it('should render all three replacement scope options', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Fix this once')).toBeTruthy();
expect(screen.getByText('Fix in this book')).toBeTruthy();
expect(screen.getByText('Fix in library')).toBeTruthy();
});
it('should render the replacement text input field', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
expect(input).toBeTruthy();
});
it('should render the Case Sensitive checkbox', () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Case Sensitive')).toBeTruthy();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
});
it('should render the Cancel button', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Cancel')).toBeTruthy();
});
it('should display selected text preview', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText(/Selected:/)).toBeTruthy();
expect(screen.getByText(/"test word"/)).toBeTruthy();
});
it('should truncate long selected text in preview', () => {
const longText = 'a'.repeat(100);
render(<ReplacementOptions {...defaultProps} selectedText={longText} />);
// Should show truncated version with ellipsis
const preview = screen.getByText(/Selected:/);
expect(preview.parentElement?.textContent).toContain('...');
});
});
describe('Case Sensitive Checkbox', () => {
it('should be checked by default (case-sensitive)', () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('should toggle when clicked', async () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
it('should pass case sensitivity value to onConfirm when checked', async () => {
render(<ReplacementOptions {...defaultProps} />);
// Enter replacement text
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
// Checkbox is checked by default (case sensitive = true)
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Should show confirmation dialog
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('Yes')).toBeTruthy(); // Case sensitive: Yes
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith({
replacementText: 'replacement',
caseSensitive: true,
scope: 'once',
});
});
it('should pass case sensitivity value to onConfirm when unchecked', async () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
// Enter replacement text
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
// Uncheck the checkbox (default is true, so we click to toggle to false)
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith({
replacementText: 'replacement',
caseSensitive: false,
scope: 'once',
});
});
});
describe('Replacement Text Input', () => {
it('should update value when user types', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'new text' } });
expect(input.value).toBe('new text');
});
it('should disable scope buttons when input is empty', () => {
render(<ReplacementOptions {...defaultProps} />);
const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement;
const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement;
const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement;
expect(fixOnceButton.disabled).toBe(true);
expect(fixInBookButton.disabled).toBe(true);
expect(fixInLibraryButton.disabled).toBe(true);
});
it('should enable scope buttons when input has text', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement;
const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement;
const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement;
expect(fixOnceButton.disabled).toBe(false);
expect(fixInBookButton.disabled).toBe(false);
expect(fixInLibraryButton.disabled).toBe(false);
});
it('should trim whitespace from replacement text', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: ' trimmed ' } });
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith(
expect.objectContaining({
replacementText: 'trimmed',
}),
);
});
});
describe('Scope Button Click Handlers', () => {
it('should show confirmation dialog when "Fix this once" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix this once');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('this instance')).toBeTruthy();
});
it('should show confirmation dialog when "Fix in this book" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix in this book');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('all instances in this book')).toBeTruthy();
});
it('should show confirmation dialog when "Fix in library" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix in library');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('all instances in your library')).toBeTruthy();
});
it('should call onConfirm with correct scope for "once"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'once' }));
});
it('should call onConfirm with correct scope for "book"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix in this book'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' }));
});
it('should call onConfirm with correct scope for "library"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix in library'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' }));
});
});
describe('Confirmation Dialog', () => {
it('should display original text in confirmation', () => {
render(<ReplacementOptions {...defaultProps} selectedText='original' />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('"original"')).toBeTruthy();
});
it('should display replacement text in confirmation', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'new text' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('"new text"')).toBeTruthy();
});
it('should go back to main view when Back is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
fireEvent.click(screen.getByText('Back'));
// Should be back to main view
expect(screen.queryByText('Confirm Replacement')).toBeNull();
expect(screen.getByText('Fix this once')).toBeTruthy();
});
it('should not call onConfirm when Back is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
fireEvent.click(screen.getByText('Back'));
expect(mockOnConfirm).not.toHaveBeenCalled();
});
});
describe('Cancel Button', () => {
it('should call onClose when Cancel is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('Click Outside Behavior', () => {
it('should call onClose when clicking outside the menu', () => {
render(
<div>
<div data-testid='outside'>Outside element</div>
<ReplacementOptions {...defaultProps} />
</div>,
);
const outsideElement = screen.getByTestId('outside');
fireEvent.mouseDown(outsideElement);
expect(mockOnClose).toHaveBeenCalled();
});
it('should not call onClose when clicking inside the menu', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.mouseDown(input);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
});
@@ -1,267 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import {
ReplacementRulesWindow,
setReplacementRulesWindowVisible,
} from '@/app/reader/components/ReplacementRulesWindow';
import BookMenu from '@/app/reader/components/sidebar/BookMenu';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ReplacementRule } from '@/types/book';
// ------------------------------
// NEXT.JS ROUTER MOCK
// ------------------------------
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
toString: () => '',
}),
}));
// ------------------------------
// TRANSLATION MOCK
// ------------------------------
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
vi.mock('@/services/translators/cache', () => ({
initCache: vi.fn(),
loadCacheFromDB: vi.fn(),
pruneCache: vi.fn(),
}));
// ------------------------------
// ENV PROVIDER WRAPPER
// ------------------------------
// mock environment module so EnvProvider uses fake values
vi.mock('@/services/environment', async (importOriginal) => {
const actual = await importOriginal();
return {
...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform)
default: {
...(typeof actual === 'object' &&
actual !== null &&
'default' in actual &&
typeof actual.default === 'object' &&
actual.default !== null
? actual.default
: {}), // keep all real default fields
API_BASE: 'http://localhost',
ENABLE_TRANSLATOR: false,
getAppService: vi.fn().mockResolvedValue(null),
},
};
});
import { EnvProvider } from '@/context/EnvContext';
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe.skip('ReplacementRulesWindow', () => {
beforeEach(() => {
// Reset stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: {
enabled: false,
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
useSidebarStore.setState({ sideBarBookKey: null });
(useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} });
});
afterEach(() => {
cleanup();
});
it('renders book and global replacement rules from stores', async () => {
// Arrange: populate stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
replacementRules: [
{
id: 'g1',
pattern: 'foo',
replacement: 'bar',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
},
{
id: 'b1',
pattern: 'hello',
replacement: 'world',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
},
],
kosync: { enabled: false },
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
replacementRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ReplacementRulesWindow />);
// wait a tick so the component's effect attaches the event listener
await Promise.resolve();
// open via helper which dispatches the custom event
setReplacementRulesWindowVisible(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Global rules
expect(screen.getByText('foo')).toBeTruthy();
expect(screen.getByText('bar')).toBeTruthy();
expect(screen.getByText('hello')).toBeTruthy();
expect(screen.getByText('world')).toBeTruthy();
});
it('renders single-instance rules separately from book/global rules', async () => {
// Arrange: populate stores with a single rule persisted in book config
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: { enabled: false },
},
});
const singleRule: ReplacementRule = {
id: 's1',
pattern: 'only-once',
replacement: 'single-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
singleInstance: true,
};
const bookRule: ReplacementRule = {
id: 'b1',
pattern: 'book-wide',
replacement: 'book-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
replacementRules: [singleRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
replacementRules: [singleRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ReplacementRulesWindow />);
await Promise.resolve();
setReplacementRulesWindowVisible(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Single-instance section
expect(screen.getByText('Single Instance Rules')).toBeTruthy();
expect(screen.getByText('only-once')).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();
});
it('opens when BookMenu item is clicked (integration)', async () => {
// Arrange stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: { viewSettings: { replacementRules: [] } },
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Render both menu and window
renderWithProviders(
<div>
<BookMenu />
<ReplacementRulesWindow />
</div>,
);
// wait a tick so effects attach
await Promise.resolve();
// Click the menu item
const menuItem = screen.getByRole('menuitem', { name: 'Replacement Rules' });
fireEvent.click(menuItem);
// The dialog should open
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Replacement Rules')).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@ describe.concurrent('suite', () => {
beforeAll(async () => {
const wasmPath = join(process.cwd(), 'public/vendor/simplecc/simplecc_wasm_bg.wasm');
const wasmBuffer = await readFile(wasmPath);
await init(wasmBuffer);
await init({ module_or_path: wasmBuffer });
});
test('basic s2t and t2s', () => {
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getWordCount, isWordLimitExceeded } from '../../utils/wordLimit';
import { getWordCount } from '../../utils/word';
describe('Word Limit Feature', () => {
describe('getWordCount', () => {
@@ -49,49 +49,6 @@ describe('Word Limit Feature', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(getWordCount(thirtyOneWords)).toBe(31);
});
});
describe('isWordLimitExceeded', () => {
it('should return false for text under 30 words', () => {
expect(isWordLimitExceeded('hello world')).toBe(false);
expect(isWordLimitExceeded('a')).toBe(false);
});
it('should return false for exactly 30 words', () => {
const thirtyWords = Array(30).fill('word').join(' ');
expect(isWordLimitExceeded(thirtyWords)).toBe(false);
});
it('should return true for 31 words', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(isWordLimitExceeded(thirtyOneWords)).toBe(true);
});
it('should return true for many words', () => {
const manyWords = Array(100).fill('word').join(' ');
expect(isWordLimitExceeded(manyWords)).toBe(true);
});
it('should return false for empty string', () => {
expect(isWordLimitExceeded('')).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle very long words', () => {
const longWord = 'a'.repeat(1000);
expect(getWordCount(longWord)).toBe(1);
expect(isWordLimitExceeded(longWord)).toBe(false);
});
it('should handle mixed content with newlines', () => {
const text = `Line one with words.
Line two with more words.
Line three.`;
// "Line one with words." = 4, "Line two with more words." = 5, "Line three." = 2 = 11 total
expect(getWordCount(text)).toBe(11);
expect(isWordLimitExceeded(text)).toBe(false);
});
it('should handle unicode characters', () => {
expect(getWordCount('你好 世界')).toBe(2);
@@ -104,6 +61,21 @@ describe('Word Limit Feature', () => {
expect(getWordCount('chapter 1 section 2')).toBe(4);
});
});
describe('Edge cases', () => {
it('should handle very long words', () => {
const longWord = 'a'.repeat(1000);
expect(getWordCount(longWord)).toBe(1);
});
it('should handle mixed content with newlines', () => {
const text = `Line one with words.
Line two with more words.
Line three.`;
// "Line one with words." = 4, "Line two with more words." = 5, "Line three." = 2 = 11 total
expect(getWordCount(text)).toBe(11);
});
});
});
describe('Case Sensitivity Matching', () => {
@@ -113,7 +113,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
});
}
if (stream === 'true' || (contentLength && parseInt(contentLength) > 1024 * 1024)) {
if (stream === 'true' && contentLength && parseInt(contentLength) > 1024 * 1024) {
console.log(`[OPDS Proxy] Streaming: ${url}`);
return new NextResponse(response.body, {
@@ -129,13 +129,15 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
},
});
} else {
const data = await response.text();
console.log(`[OPDS Proxy] Success: ${url} (${data.length} bytes)`);
const buf = await response.arrayBuffer();
const length = buf.byteLength;
console.log(`[OPDS Proxy] Success: ${url} (${length} bytes)`);
return new NextResponse(data, {
return new NextResponse(buf, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': length.toString(),
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+4 -1
View File
@@ -41,7 +41,10 @@ export const viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<html
lang='en'
className={process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri' ? 'edge-to-edge' : ''}
>
<head>
<title>{title}</title>
<meta
@@ -26,7 +26,7 @@ interface BookItemProps {
bookSelected: boolean;
transferProgress: number | null;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
showBookDetailsModal: (book: Book) => void;
}
@@ -61,13 +61,19 @@ const BookItem: React.FC<BookItemProps> = ({
>
<div
className={clsx(
'relative flex aspect-[28/41] justify-center',
'bookitem-main relative flex aspect-[28/41] justify-center rounded',
coverFit === 'crop' && 'overflow-hidden shadow-md',
mode === 'grid' && 'items-end',
mode === 'list' && 'min-w-20 items-center',
)}
>
<BookCover mode={mode} book={book} coverFit={coverFit} showSpine={false} />
<BookCover
mode={mode}
book={book}
coverFit={coverFit}
showSpine={false}
imageClassName='rounded shadow-md'
/>
{bookSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
@@ -154,7 +160,7 @@ const BookItem: React.FC<BookItemProps> = ({
if (!book.uploadedAt) {
handleBookUpload(book);
} else if (!book.downloadedAt) {
handleBookDownload(book);
handleBookDownload(book, { queued: true });
}
}}
>
@@ -10,6 +10,7 @@ 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';
@@ -23,6 +24,7 @@ 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[];
@@ -30,7 +32,10 @@ interface BookshelfProps {
isSelectAll: boolean;
isSelectNone: boolean;
handleImportBooks: () => void;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDownload: (
book: Book,
options?: { redownload?: boolean; queued?: boolean },
) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
@@ -82,6 +87,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const { getGroupName } = useLibraryStore();
const { isTransferQueueOpen } = useTransferStore();
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
const updateUrlParams = useCallback(
@@ -330,7 +337,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<button
aria-label={_('Import Books')}
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50',
'bookitem-main bg-base-100 hover:bg-base-300/50',
'flex items-center justify-center',
'aspect-[28/41] w-full',
)}
@@ -376,9 +383,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
/>
</ModalPortal>
)}
{isTransferQueueOpen && (
<ModalPortal>
<TransferQueuePanel />
</ModalPortal>
)}
{showDeleteAlert && (
<div
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
className={clsx('delete-alert fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
@@ -82,7 +82,10 @@ interface BookshelfItemProps {
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleGroupBooks: () => void;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDownload: (
book: Book,
options?: { redownload?: boolean; queued?: boolean },
) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
@@ -112,9 +115,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const { updateBook } = useLibraryStore();
const showBookDetailsModal = useCallback(async (book: Book) => {
if (await makeBookAvailable(book)) {
handleShowDetailsBook(book);
}
handleShowDetailsBook(book);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -131,7 +132,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
available = await handleBookDownload(book);
available = await handleBookDownload(book, { queued: false });
await updateBook(envConfig, book);
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
@@ -216,7 +217,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const downloadBookMenuItem = await MenuItem.new({
text: _('Download Book'),
action: async () => {
handleBookDownload(book);
handleBookDownload(book, { queued: true });
},
});
const uploadBookMenuItem = await MenuItem.new({
@@ -360,7 +361,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-2 sm:px-4 sm:py-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
appService?.isMobileApp && 'no-context-menu',
pressing && mode === 'grid' ? 'scale-95' : 'scale-100',
pressing && mode === 'grid' ? 'not-eink:scale-95' : 'scale-100',
)}
role='button'
tabIndex={0}
@@ -99,7 +99,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
<div className={clsx('group-item', appService?.hasContextMenu ? 'cursor-pointer' : '')}>
<div
className={clsx(
'relative flex overflow-hidden',
'groupitem-main relative flex overflow-hidden rounded',
mode === 'grid' && 'bg-base-100 aspect-[28/41] items-center justify-center shadow-md',
mode === 'list' && 'items-center justify-start gap-4 py-2',
)}
@@ -139,7 +139,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
mode === 'list' && 'flex-shrink-0',
)}
>
<BookCover book={book} isPreview />
<BookCover book={book} isPreview imageClassName='rounded-[2px]' />
</div>
))}
</div>
@@ -102,6 +102,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
setShowInput(true);
setIsRenaming(false);
setOriginalGroupName(null);
setSelectedGroup(null);
};
const handleRenameGroup = () => {
@@ -12,7 +12,6 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { debounce } from '@/utils/debounce';
@@ -48,13 +47,11 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { currentBookshelf } = useLibraryStore();
const { isTrafficLightVisible } = useTrafficLight();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const viewSettings = settings.globalViewSettings;
const headerRef = useRef<HTMLDivElement>(null);
const iconSize18 = useResponsiveSize(18);
const { safeAreaInsets: insets } = useThemeStore();
@@ -110,7 +107,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
<div className='relative flex h-9 w-full items-center sm:h-7'>
<span className='text-base-content/50 absolute left-3'>
<span className='text-base-content/50 absolute ps-3'>
<FaSearch className='h-4 w-4' />
</span>
<input
@@ -126,10 +123,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge h-9 w-full pl-10 pr-[30%] sm:h-7',
viewSettings?.isEink
? 'border-1 border-base-content focus:border-base-content'
: 'bg-base-300/45 border-none',
'search-input input h-9 w-full rounded-full pr-[30%] ps-10 sm:h-7',
'bg-base-300/45 border-0',
'font-sans text-sm font-light',
'placeholder:text-base-content/50 truncate',
'focus:outline-none focus:ring-0',
@@ -17,12 +17,9 @@ export function CatalogDialog({ onClose }: CatalogDialogProps) {
bgClassName={'sm:!bg-black/75'}
boxClassName='sm:min-w-[520px] sm:w-3/4 sm:h-[85%] sm:!max-w-screen-sm'
>
<div className={clsx('bg-base-100 relative flex flex-col overflow-y-auto')}>
<div className={clsx('bg-base-100 relative flex flex-col overflow-y-auto pb-4')}>
<CatalogManager />
</div>
<form method='dialog' className='modal-backdrop'>
<button onClick={onClose}>{_('Close')}</button>
</form>
</Dialog>
);
}
@@ -41,8 +41,8 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
>
<div
className={clsx(
'flex items-center justify-center shadow-lg',
'bg-base-300 text-base-content text-xs',
'text-base-content flex items-center justify-center text-xs shadow-lg',
'not-eink:bg-base-300 eink:bg-base-100 eink:border eink:border-base-content',
'mx-auto w-fit space-x-6 rounded-lg p-4',
)}
>
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
import { PiUserCircle, PiUserCircleCheck, PiGear } from 'react-icons/pi';
import { PiSun, PiMoon } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { MdCloudSync } from 'react-icons/md';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
@@ -15,6 +16,7 @@ import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
@@ -61,6 +63,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
);
const iconSize = useResponsiveSize(16);
const { stats, hasActiveTransfers, setIsTransferQueueOpen } = useTransferQueue();
const openTransferQueue = () => {
setIsTransferQueueOpen(true);
setIsDropdownOpen?.(false);
};
const showAboutReadest = () => {
setAboutDialogVisible(true);
setIsDropdownOpen?.(false);
@@ -223,7 +232,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
return (
<Menu
className={clsx(
'settings-menu dropdown-content no-triangle border-base-100',
'settings-menu dropdown-content no-triangle',
'z-20 mt-2 max-w-[90vw] shadow-2xl',
)}
onCancel={() => setIsDropdownOpen?.(false)}
@@ -255,6 +264,23 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
) : (
<MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem>
)}
{user && (
<MenuItem
label={_('Cloud File Transfers')}
Icon={MdCloudSync}
description={
hasActiveTransfers
? _('{{activeCount}} active, {{pendingCount}} pending', {
activeCount: stats.active,
pendingCount: stats.pending,
})
: stats.failed > 0
? _('{{failedCount}} failed', { failedCount: stats.failed })
: ''
}
onClick={openTransferQueue}
/>
)}
<MenuItem
label={_('Auto Upload Books to Cloud')}
toggled={isAutoUpload}
@@ -0,0 +1,354 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import {
MdClose,
MdRefresh,
MdPause,
MdPlayArrow,
MdCloudUpload,
MdCloudDownload,
MdCheckCircle,
MdError,
MdCancel,
MdDeleteSweep,
} from 'react-icons/md';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useLibraryStore } from '@/store/libraryStore';
import { TransferItem, TransferStatus, useTransferStore } from '@/store/transferStore';
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const formatSpeed = (bytesPerSec: number): string => {
return `${formatBytes(bytesPerSec)}/s`;
};
const formatDateTime = (timestamp: number): string => {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) {
return timeStr;
} else if (isYesterday) {
return `Yesterday ${timeStr}`;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + timeStr;
}
};
const StatusIcon: React.FC<{
status: TransferStatus;
type: 'upload' | 'download';
size: number;
}> = ({ status, type, size }) => {
switch (status) {
case 'completed':
return <MdCheckCircle className='text-success' size={size} />;
case 'failed':
return <MdError className='text-error' size={size} />;
case 'cancelled':
return <MdCancel className='text-warning' size={size} />;
case 'in_progress':
case 'pending':
default:
return type === 'upload' ? (
<MdCloudUpload className='text-primary' size={size} />
) : (
<MdCloudDownload className='text-primary' size={size} />
);
}
};
const TransferItemRow: React.FC<{
transfer: TransferItem;
onCancel: (id: string) => void;
onRetry: (id: string) => void;
iconSize: number;
}> = ({ transfer, onCancel, onRetry, iconSize }) => {
const _ = useTranslation();
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} />
<div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{transfer.bookTitle}</div>
<div className='text-base-content/60 text-xs'>
{transfer.status === 'in_progress' && (
<>
{Math.round(transfer.progress)}% - {formatSpeed(transfer.transferSpeed)}
</>
)}
{transfer.status === 'pending' && transfer.error && (
<span className='text-warning'>{transfer.error}</span>
)}
{transfer.status === 'pending' && !transfer.error && _('Waiting...')}
{transfer.status === 'failed' && (
<span className='text-error'>{transfer.error || _('Failed')}</span>
)}
{transfer.status === 'completed' && _('Completed')}
{transfer.status === 'cancelled' && _('Cancelled')}
{' · '}
{formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
</div>
{transfer.status === 'in_progress' && (
<div className='bg-base-300 mt-1 h-1.5 w-full overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all'
style={{ width: `${transfer.progress}%` }}
/>
</div>
)}
</div>
<div className='flex items-center gap-1'>
{(transfer.status === 'failed' || transfer.status === 'cancelled') && (
<button
onClick={() => onRetry(transfer.id)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={_('Retry')}
>
<MdRefresh size={iconSize} />
</button>
)}
{['pending', 'in_progress'].includes(transfer.status) && (
<button
onClick={() => onCancel(transfer.id)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={_('Cancel')}
>
<MdClose size={iconSize} />
</button>
)}
</div>
</div>
);
};
type FilterType = 'all' | 'active' | 'completed' | 'failed';
const TransferQueuePanel: React.FC = () => {
const _ = useTranslation();
const iconSize = useResponsiveSize(18);
const setIsOpen = useTransferStore((state) => state.setIsTransferQueueOpen);
const getVisibleLibrary = useLibraryStore((state) => state.getVisibleLibrary);
const {
transfers,
stats,
isQueuePaused,
cancelTransfer,
retryTransfer,
retryAllFailed,
pauseQueue,
resumeQueue,
clearCompleted,
clearFailed,
queueUpload,
queueDownload,
} = useTransferQueue();
const [filter, setFilter] = useState<FilterType>('all');
const onClose = () => setIsOpen(false);
const divRef = useKeyDownActions({ onCancel: onClose, onConfirm: onClose });
const booksToUpload = getVisibleLibrary().filter(
(book) => book.downloadedAt && !book.uploadedAt,
);
const booksToDownload = getVisibleLibrary().filter(
(book) => book.uploadedAt && !book.downloadedAt,
);
const handleUploadAll = () => {
booksToUpload.forEach((book) => queueUpload(book));
};
const handleDownloadAll = () => {
booksToDownload.forEach((book) => queueDownload(book));
};
const filteredTransfers = transfers
.filter((t) => {
switch (filter) {
case 'active':
return ['pending', 'in_progress'].includes(t.status);
case 'completed':
return t.status === 'completed';
case 'failed':
return t.status === 'failed' || t.status === 'cancelled';
default:
return true;
}
})
.sort((a, b) => {
// Sort by status priority then by createdAt
const statusOrder: Record<TransferStatus, number> = {
in_progress: 0,
pending: 1,
failed: 2,
cancelled: 3,
completed: 4,
};
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff;
return b.createdAt - a.createdAt;
});
const filterLabels: Record<FilterType, string> = {
all: _('All'),
active: _('Active'),
completed: _('Completed'),
failed: _('Failed'),
};
return (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className='absolute inset-0 bg-black/50' onClick={onClose} />
<div
ref={divRef}
className={clsx(
'modal-box bg-base-100 relative flex max-h-[85%] min-h-[65%] w-[95%] flex-col rounded-2xl p-0 shadow-xl',
'min-w-64 max-w-lg overflow-hidden',
)}
>
{/* Header */}
<div className='border-base-300 flex items-center justify-between border-b p-4'>
<h2 className='text-lg font-semibold'>{_('Transfer Queue')}</h2>
<div className='flex items-center gap-2'>
{booksToUpload.length > 0 && (
<button
onClick={handleUploadAll}
className='btn btn-ghost btn-sm gap-1'
title={_('Upload All')}
aria-label={_('Upload All')}
>
<MdCloudUpload size={iconSize} />
<span className='text-xs'>{booksToUpload.length}</span>
</button>
)}
{booksToDownload.length > 0 && (
<button
onClick={handleDownloadAll}
className='btn btn-ghost btn-sm gap-1'
title={_('Download All')}
aria-label={_('Download All')}
>
<MdCloudDownload size={iconSize} />
<span className='text-xs'>{booksToDownload.length}</span>
</button>
)}
<button
onClick={isQueuePaused ? resumeQueue : pauseQueue}
className='btn btn-ghost btn-sm btn-circle'
title={isQueuePaused ? _('Resume Transfers') : _('Pause Transfers')}
aria-label={isQueuePaused ? _('Resume Transfers') : _('Pause Transfers')}
>
{isQueuePaused ? <MdPlayArrow size={iconSize} /> : <MdPause size={iconSize} />}
</button>
<button
onClick={onClose}
className='btn btn-ghost btn-sm btn-circle'
title={_('Close')}
aria-label={_('Close')}
>
<MdClose size={iconSize} />
</button>
</div>
</div>
{/* Stats bar */}
<div className='bg-base-200 flex items-center gap-4 px-4 py-2 text-sm'>
<span>
{_('Active')}: {stats.active}
</span>
<span>
{_('Pending')}: {stats.pending}
</span>
<span>
{_('Completed')}: {stats.completed}
</span>
<span>
{_('Failed')}: {stats.failed}
</span>
</div>
{/* Filter tabs */}
<div className='border-base-300 flex gap-2 border-b p-4'>
{(['all', 'active', 'completed', 'failed'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={clsx(
'rounded-lg px-3 py-1 text-sm transition-colors',
filter === f ? 'bg-primary text-primary-content' : 'bg-base-200 hover:bg-base-300',
)}
>
{filterLabels[f]}
</button>
))}
</div>
{/* Transfer list */}
<div className='flex-1 overflow-y-auto p-2'>
{filteredTransfers.length === 0 ? (
<div className='text-base-content/60 py-8 text-center'>{_('No transfers')}</div>
) : (
filteredTransfers.map((transfer) => (
<TransferItemRow
key={transfer.id}
transfer={transfer}
onCancel={cancelTransfer}
onRetry={retryTransfer}
iconSize={iconSize}
/>
))
)}
</div>
{/* Footer actions */}
<div className='border-base-300 flex items-center justify-between border-t p-4'>
<div className='flex items-center gap-2'>
{stats.failed > 0 && (
<button onClick={retryAllFailed} className='btn btn-ghost btn-sm gap-1'>
<MdRefresh size={iconSize - 2} />
{_('Retry All')}
</button>
)}
</div>
<div className='flex items-center gap-2'>
{stats.completed > 0 && (
<button onClick={clearCompleted} className='btn btn-ghost btn-sm gap-1'>
<MdDeleteSweep size={iconSize - 2} />
{_('Clear Completed')}
</button>
)}
{stats.failed > 0 && (
<button onClick={clearFailed} className='btn btn-ghost btn-sm gap-1'>
<MdDeleteSweep size={iconSize - 2} />
{_('Clear Failed')}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default TransferQueuePanel;
@@ -99,7 +99,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
return (
<Menu
className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
onCancel={() => setIsDropdownOpen?.(false)}
>
{viewOptions.map((option) => (
@@ -136,11 +136,11 @@ export const useBooksSync = () => {
await appService?.downloadBookCovers(batch);
await Promise.all(batch.map(processNewBook));
const progress = Math.min((i + batchSize) / newBooks.length, 1);
setLibrary([...updatedLibrary]);
setSyncProgress(progress);
setLibrary([...updatedLibrary]);
appService?.saveLibraryBooks(updatedLibrary);
}
setLibrary(updatedLibrary);
appService?.saveLibraryBooks(updatedLibrary);
} finally {
if (newBooks.length > 0) {
setIsSyncing(false);
+52 -59
View File
@@ -10,11 +10,12 @@ import 'overlayscrollbars/overlayscrollbars.css';
import { Book } from '@/types/book';
import { AppService, DeleteAction } from '@/types/system';
import { navigateToLibrary, navigateToLogin, navigateToReader } from '@/utils/nav';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { transferManager } from '@/services/transferManager';
import { getDirPath, getFilename, joinPaths } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
@@ -55,6 +56,7 @@ import { UpdaterWindow } from '@/components/UpdaterWindow';
import { CatalogDialog } from './components/OPDSDialog';
import { MigrateDataWindow } from './components/MigrateDataWindow';
import { useDragDropImport } from './hooks/useDragDropImport';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { Toast } from '@/components/Toast';
import { getBreadcrumbs } from './utils/libraryUtils';
import Spinner from '@/components/Spinner';
@@ -120,6 +122,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useUICSS();
useOpenWithBooks();
useTransferQueue(libraryLoaded);
const { pullLibrary, pushLibrary } = useBooksSync();
const { isDragging } = useDragDropImport();
@@ -233,6 +236,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (book) {
bookIds.push(book.hash);
}
if (user && book && !temp && !book.uploadedAt && settings.autoUpload) {
setTimeout(() => {
console.log('Queueing upload for book:', book.title);
transferManager.queueUpload(book);
// wait for the initialization of the transfer manager and opening of the book
}, 3000);
}
} catch (error) {
console.log('Failed to import book:', file, error);
}
@@ -409,8 +419,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
await updateBook(envConfig, book);
}
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book, false);
console.log('Queueing upload for book:', book.title);
transferManager.queueUpload(book);
}
if (book) {
successfulImports.push(book.title);
@@ -471,83 +481,66 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}, 500);
const handleBookUpload = useCallback(
async (book: Book, syncBooks = true) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
await updateBook(envConfig, book);
if (syncBooks) pushLibrary();
async (book: Book, _syncBooks = true) => {
// Use transfer queue for uploads - priority 1 for manual uploads (higher priority)
const transferId = transferManager.queueUpload(book, 1);
if (transferId) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book uploaded: {{title}}', {
message: _('Upload queued: {{title}}', {
title: book.title,
}),
});
return true;
} catch (err) {
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return false;
}
}
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to upload book: {{title}}', {
title: book.title,
}),
});
return false;
}
return false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
[],
);
const handleBookDownload = useCallback(
async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
async (book: Book, downloadOptions: { redownload?: boolean; queued?: boolean } = {}) => {
const { redownload = false, queued = false } = downloadOptions;
if (redownload || !queued) {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
}
// Use transfer queue for normal downloads - priority 1 for manual downloads
const transferId = transferManager.queueDownload(book, 1);
if (transferId) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
message: _('Download queued: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
return false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
@@ -406,9 +406,6 @@ export function CatalogManager() {
</div>
</form>
</div>
<form method='dialog' className='modal-backdrop'>
<button onClick={() => setShowAddDialog(false)}>{_('Close')}</button>
</form>
</dialog>
</ModalPortal>
)}
@@ -25,13 +25,19 @@ export function NavigationCard({ item, baseURL, onClick, resolveURL }: Navigatio
<div className='card-body flex justify-center p-4'>
<div className='flex items-start gap-3'>
<div className='min-w-0 flex-1'>
<h3 className='line-clamp-1 text-sm font-semibold'>{item.title || _('Untitled')}</h3>
<h3 title={item.title || _('Untitled')} className='line-clamp-1 text-sm font-semibold'>
{item.title || _('Untitled')}
</h3>
{item.properties?.numberOfItems && (
<p className='text-base-content/60 text-xs'>
{_('{{count}} items', { count: item.properties.numberOfItems })}
</p>
)}
{summary && <p className='text-base-content/70 mt-1 line-clamp-1 text-xs'>{summary}</p>}
{summary && (
<p title={summary} className='text-base-content/70 mt-1 line-clamp-1 text-xs'>
{summary}
</p>
)}
</div>
<div className='flex h-full items-center justify-center'>
<IoChevronForward className='text-base-content/40 h-5 w-5 flex-shrink-0' />
@@ -163,19 +163,13 @@ export function PublicationView({
<Dropdown
label={_('Download')}
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost p-0 hover:bg-transparent'
buttonClassName={clsx(
'btn btn-ghost btn-primary min-w-20 rounded-3xl p-0 hover:bg-transparent',
downloadedBook && 'btn-success',
)}
disabled={downloading}
toggleButton={
<div
role='button'
tabIndex={0}
className={clsx(
`btn btn-primary min-w-20 rounded-3xl ${downloading ? 'btn-disabled' : ''}`,
downloadedBook && 'btn-success',
)}
>
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
</div>
<div>{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}</div>
}
>
<div
+25 -4
View File
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { isOPDSCatalog, getPublication, getFeed, getOpenSearch } from 'foliate-js/opds.js';
import { openUrl } from '@tauri-apps/plugin-opener';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { isWebAppPlatform } from '@/services/environment';
import { downloadFile } from '@/libs/storage';
import { Toast } from '@/components/Toast';
@@ -14,12 +15,20 @@ import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { transferManager } from '@/services/transferManager';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useTheme } from '@/hooks/useTheme';
import { useLibrary } from '@/hooks/useLibrary';
import { eventDispatcher } from '@/utils/event';
import { getFileExtFromMimeType } from '@/libs/document';
import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
import { isSearchLink, MIME, parseMediaType, resolveURL } from './utils/opdsUtils';
import {
getFileExtFromPath,
isSearchLink,
MIME,
parseMediaType,
resolveURL,
} from './utils/opdsUtils';
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { FeedView } from './components/FeedView';
@@ -49,6 +58,7 @@ export default function BrowserPage() {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { user } = useAuth();
const { libraryLoaded } = useLibrary();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const { settings } = useSettingsStore();
@@ -78,6 +88,7 @@ export default function BrowserPage() {
const searchTermRef = useRef('');
useTheme({ systemUIVisible: false });
useTransferQueue(libraryLoaded);
useEffect(() => {
startURLRef.current = state.startURL;
@@ -403,8 +414,9 @@ export default function BrowserPage() {
}
return;
} else {
const ext = parsed?.mediaType ? getFileExtFromMimeType(parsed.mediaType) : '';
const basename = new URL(url).pathname.replaceAll('/', '_');
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) {
@@ -414,6 +426,7 @@ export default function BrowserPage() {
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);
@@ -430,10 +443,16 @@ export default function BrowserPage() {
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;
@@ -445,7 +464,8 @@ export default function BrowserPage() {
}
return;
},
[state.baseURL, appService, libraryLoaded],
// eslint-disable-next-line react-hooks/exhaustive-deps
[user, state.baseURL, appService, libraryLoaded],
);
const handleGenerateCachedImageUrl = useCallback(
@@ -479,6 +499,7 @@ export default function BrowserPage() {
cfp: '',
url: downloadUrl,
singleThreaded: true,
skipSslVerification: true,
headers,
});
return await appService.getImageURL(cachedPath);
@@ -207,7 +207,7 @@ export const probeAuth = async (
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml',
Accept: 'application/atom+xml, application/xml, text/xml, */*',
};
// Probe with HEAD request
@@ -215,6 +215,7 @@ export const probeAuth = async (
const res = await fetch(fetchURL, {
method: 'HEAD',
headers,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true },
});
// Check if authentication is required
@@ -240,7 +241,8 @@ export const probeAuth = async (
}
}
return null;
// Komga returns 200 even if requires auth, so we return Basic auth header in this case
return createBasicAuth(finalUsername, finalPassword);
};
/**
@@ -265,7 +267,7 @@ export const fetchWithAuth = async (
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml',
Accept: 'application/atom+xml, application/xml, text/xml, */*',
...(options.headers as Record<string, string>),
};
@@ -274,6 +276,7 @@ export const fetchWithAuth = async (
...options,
method: options.method || 'GET',
headers,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true },
});
// Handle authentication if needed
@@ -301,6 +304,7 @@ export const fetchWithAuth = async (
...options,
method: options.method || 'GET',
headers: useProxy ? headers : { ...headers, Authorization: authHeader },
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true },
});
}
}
@@ -1,5 +1,6 @@
import { isOPDSCatalog } from 'foliate-js/opds.js';
import { OPDSLink } from '@/types/opds';
import { EXTS } from '@/libs/document';
import { fetchWithAuth } from './opdsReq';
export const groupByArray = <T, K>(arr: T[] | undefined, f: (el: T) => K | K[]): Map<K, T[]> => {
@@ -215,3 +216,13 @@ export const validateOPDSURL = async (
};
}
};
export const getFileExtFromPath = (pathname: string, delimiter = '/'): string => {
const parts = pathname.split(delimiter);
for (const ext of Object.values(EXTS)) {
if (parts.includes(ext)) {
return ext;
}
}
return '';
};
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { MdOutlineBookmarkAdd, MdOutlineBookmark } from 'react-icons/md';
import * as CFI from 'foliate-js/epubcfi.js';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -12,6 +11,7 @@ import { uniqueId } from '@/utils/misc';
import Button from '@/components/Button';
import { getCurrentPage } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { isCfiInLocation } from '@/utils/cfi';
interface BookmarkTogglerProps {
bookKey: string;
@@ -66,13 +66,8 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
}
} else {
setIsBookmarked(false);
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
bookmarks.forEach((item) => {
if (
item.type === 'bookmark' &&
CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0
) {
if (item.type === 'bookmark' && isCfiInLocation(item.cfi, cfi)) {
item.deletedAt = Date.now();
}
});
@@ -101,11 +96,9 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const { location: cfi } = progress || {};
if (!cfi) return;
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
const locationBookmarked = booknotes
.filter((booknote) => booknote.type === 'bookmark' && !booknote.deletedAt)
.some((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0);
.some((item) => isCfiInLocation(item.cfi, cfi));
setIsBookmarked(locationBookmarked);
setBookmarkRibbonVisibility(bookKey, locationBookmarked);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -3,14 +3,14 @@ import React, { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import SettingsDialog from '@/components/settings/SettingsDialog';
import SearchResultsNav from './sidebar/SearchResultsNav';
import BooknotesNav from './sidebar/BooknotesNav';
import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
@@ -34,7 +34,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { setGridInsets, hoveredBookKey } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { isSettingsDialogOpen } = useSettingsStore();
const { safeAreaInsets: screenInsets } = useThemeStore();
const aspectRatio = window.innerWidth / window.innerHeight;
@@ -198,6 +197,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
/>
)}
<Annotator bookKey={bookKey} />
<SearchResultsNav bookKey={bookKey} gridInsets={gridInsets} />
<BooknotesNav bookKey={bookKey} gridInsets={gridInsets} toc={bookDoc.toc || []} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
<FooterBar
bookKey={bookKey}
@@ -207,7 +208,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isHoveredAnim={false}
gridInsets={gridInsets}
/>
{isSettingsDialogOpen && <SettingsDialog bookKey={bookKey} />}
</div>
);
})}
@@ -132,7 +132,8 @@ const FoliateViewer: React.FC<{
const bookData = getBookData(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(data, width, height, viewSettings.vertical);
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
const isHtml = detail.type === 'application/xhtml+xml' || detail.type === 'text/html';
if (viewSettings && bookData && isHtml) {
const ctx: TransformContext = {
bookKey,
viewSettings,
@@ -141,6 +142,7 @@ const FoliateViewer: React.FC<{
primaryLanguage: bookData.book?.primaryLanguage,
userLocale: getLocale(),
content: data,
sectionHref: detail.name,
transformers: [
'style',
'punctuation',
@@ -149,9 +151,8 @@ const FoliateViewer: React.FC<{
'language',
'sanitizer',
'simplecc',
'replacement',
'proofread',
],
sectionHref: detail.name, // Pass section href for single-instance replacements
};
return Promise.resolve(transformContent(ctx));
}
@@ -328,8 +329,8 @@ const FoliateViewer: React.FC<{
});
const viewWidth = appService?.isMobile ? screen.width : window.innerWidth;
const viewHeight = appService?.isMobile ? screen.height : window.innerHeight;
const width = viewWidth - insets.left - insets.right;
const height = viewHeight - insets.top - insets.bottom;
const width = viewWidth;
const height = viewHeight;
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
applyTranslationStyle(viewSettings);
@@ -8,11 +8,18 @@ import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import WindowButtons from '@/components/WindowButtons';
import { getHighlightColorHex } from '../utils/annotatorUtil';
import { annotationToolQuickActions } from './annotator/AnnotationTools';
import { AnnotationToolType } from '@/types/annotator';
import { saveViewSettings } from '@/helpers/settings';
import { HighlighterIcon } from '@/components/HighlighterIcon';
import Dropdown from '@/components/Dropdown';
import WindowButtons from '@/components/WindowButtons';
import QuickActionMenu from './annotator/QuickActionMenu';
import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler';
import NotebookToggler from './NotebookToggler';
@@ -38,23 +45,46 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onCloseBook,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { isTrafficLightVisible } = useTrafficLight();
const { trafficLightInFullscreen, setTrafficLightVisibility } = useTrafficLightStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { bookKeys, hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { bookKeys, hoveredBookKey } = useReaderStore();
const { isDarkMode, systemUIVisible, statusBarHeight } = useThemeStore();
const { isSideBarVisible } = useSidebarStore();
const iconSize16 = useResponsiveSize(16);
const { getView, getViewSettings, setHoveredBookKey } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const view = getView(bookKey);
const iconSize16 = useResponsiveSize(16);
const headerRef = useRef<HTMLDivElement>(null);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const docs = view?.renderer.getContents() ?? [];
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
const enableAnnotationQuickActions = viewSettings?.enableAnnotationQuickActions;
const annotationQuickActionButton =
annotationToolQuickActions.find(
(button) => button.type === viewSettings?.annotationQuickAction,
) || annotationToolQuickActions[0]!;
const annotationQuickAction = viewSettings?.annotationQuickAction;
const AnnotationToolQuickActionIcon = annotationQuickActionButton.Icon;
const highlightStyle = settings.globalReadSettings.highlightStyle;
const highlightColor = settings.globalReadSettings.highlightStyles[highlightStyle];
const highlightHexColor = getHighlightColorHex(settings, highlightColor);
const handleToggleDropdown = (isOpen: boolean) => {
setIsDropdownOpen(isOpen);
if (!isOpen) setHoveredBookKey('');
};
const handleAnnotationQuickActionSelect = (action: AnnotationToolType | null) => {
if (viewSettings?.annotationQuickAction === action) action = null;
saveViewSettings(envConfig, bookKey, 'annotationQuickAction', action, false, true);
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
if (isSideBarVisible) return;
@@ -90,7 +120,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
>
<div
role='none'
className={clsx('absolute top-0 z-10 h-11 w-full')}
className={clsx('absolute top-0 z-10 h-11 w-full', pointerInDoc && 'pointer-events-none')}
onClick={() => setHoveredBookKey(bookKey)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
@@ -137,6 +167,40 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
</div>
<BookmarkToggler bookKey={bookKey} />
<TranslationToggler bookKey={bookKey} />
{enableAnnotationQuickActions && (
<Dropdown
label={
annotationQuickAction
? _('Disable Quick Action')
: _('Enable Quick Action on Selection')
}
className='exclude-title-bar-mousedown dropdown-bottom'
buttonClassName={clsx(
'btn btn-ghost h-8 min-h-8 w-8 p-0',
viewSettings?.annotationQuickAction && 'bg-base-300/50',
)}
toggleButton={
annotationQuickAction === 'highlight' || annotationQuickAction === null ? (
<HighlighterIcon
size={iconSize16}
tipColor={annotationQuickAction === null ? '#8F8F8F' : highlightHexColor}
tipStyle={{
opacity: annotationQuickAction === null ? 0.5 : 0.8,
mixBlendMode: isDarkMode ? 'screen' : 'multiply',
}}
/>
) : (
<AnnotationToolQuickActionIcon size={iconSize16} />
)
}
onToggle={handleToggleDropdown}
>
<QuickActionMenu
selectedAction={viewSettings.annotationQuickAction}
onActionSelect={handleAnnotationQuickActionSelect}
/>
</Dropdown>
)}
</div>
<div
@@ -159,7 +223,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
</div>
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4 ps-2'>
<SettingsToggler />
<SettingsToggler bookKey={bookKey} />
<NotebookToggler bookKey={bookKey} />
<Dropdown
label={_('View Options')}
@@ -86,7 +86,7 @@ const HintInfo: React.FC<SectionInfoProps> = ({
style={
isVertical
? {
bottom: `${contentInsets.bottom * 1.5}px`,
bottom: `${(contentInsets.bottom - gridInsets.bottom) * 1.5}px`,
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
@@ -74,6 +74,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
const [progressInfoMode, setProgressInfoMode] = useState(viewSettings.progressInfoMode);
const cycleProgressInfoModes = () => {
if (!viewSettings.tapToToggleFooter) return;
const hasRemainingInfo = viewSettings.showRemainingTime || viewSettings.showRemainingPages;
const hasProgressInfo = viewSettings.showProgressInfo;
const modeSequence: (typeof progressInfoMode)[] = ['all', 'remaining', 'progress', 'none'];
@@ -139,7 +141,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
style={
isVertical
? {
bottom: `${contentInsets.bottom * 1.5}px`,
bottom: `${(contentInsets.bottom - gridInsets.bottom) * 1.5}px`,
left: showDoubleBorder
? `calc(${contentInsets.left}px)`
: `calc(${Math.max(0, contentInsets.left - 32)}px)`,
@@ -0,0 +1,307 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { RiEditLine, RiDeleteBin7Line } from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useProofreadStore } from '@/store/proofreadStore';
import { ProofreadRule, ProofreadScope } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import Dialog from '@/components/Dialog';
const dialogId = 'proofread_rules_window';
export const setProofreadRulesVisibility = (visible: boolean) => {
const dialog = document.getElementById(dialogId);
if (dialog) {
dialog.dispatchEvent(new CustomEvent('setProofreadRulesVisibility', { detail: { visible } }));
}
};
const RuleItem: React.FC<{
rule: ProofreadRule;
scope: ProofreadScope;
isEditing: boolean;
editingData: { pattern: string; replacement: string; enabled: boolean };
onEdit: () => void;
onDelete: () => void;
onSave: () => void;
onCancel: () => void;
onEditChange: (field: 'replacement', value: string) => void;
}> = ({
rule,
scope,
isEditing,
editingData,
onEdit,
onDelete,
onSave,
onCancel,
onEditChange,
}) => {
const _ = useTranslation();
const { sideBarBookKey } = useSidebarStore();
const { getView } = useReaderStore();
const navigateToSelection = () => {
if (!sideBarBookKey || !rule.cfi) return;
eventDispatcher.dispatch('navigate', { bookKey: sideBarBookKey, cfi: rule.cfi });
getView(sideBarBookKey)?.goTo(rule.cfi);
};
if (isEditing) {
return (
<div className='flex flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<label className='text-base-content/70 text-xs font-medium'>{_('Selected text:')}</label>
<input
className='input input-sm bg-base-200 text-sm opacity-60'
value={editingData.pattern}
disabled
/>
</div>
<div className='flex flex-col gap-1.5'>
<label className='text-base-content/70 text-xs font-medium'>{_('Replace with:')}</label>
<input
className='input input-sm text-sm'
value={editingData.replacement}
onChange={(e) => onEditChange('replacement', e.target.value)}
/>
</div>
<div className='mt-1 flex gap-2'>
<button className='btn btn-primary btn-sm flex-1' onClick={onSave}>
{_('Save')}
</button>
<button className='btn btn-sm flex-1' onClick={onCancel}>
{_('Cancel')}
</button>
</div>
</div>
);
}
return (
<div className='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='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>
</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'>
<span className='text-base-content/50'>{_('Scope:')}</span>
<span
role='none'
className={clsx(
'text-base-content/70 font-medium',
scope === 'selection' && 'cursor-pointer text-blue-400 hover:text-blue-500',
)}
onClick={scope === 'selection' ? navigateToSelection : undefined}
>
{scope === 'selection' ? _('Selection') : scope === 'book' ? _('Book') : _('Library')}
</span>
</span>
<span className='text-base-content/30'></span>
<span className='inline-flex items-center gap-1'>
<span className='text-base-content/50'>{_('Case sensitive:')}</span>
<span className='text-base-content/70 font-medium'>
{rule.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</span>
</div>
</div>
<div className='flex shrink-0 items-center gap-1'>
<button
className='btn btn-ghost btn-sm h-8 w-8 p-0'
onClick={onEdit}
aria-label={_('Edit')}
>
<RiEditLine className='h-4 w-4' />
</button>
<button
className='btn btn-ghost btn-sm h-8 w-8 p-0'
onClick={onDelete}
aria-label={_('Delete')}
>
<RiDeleteBin7Line className='h-4 w-4' />
</button>
</div>
</div>
);
};
// Hook to manage rules logic
const useReplacementRules = (bookKey: string | null) => {
const { settings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const { getConfig } = useBookDataStore();
const viewSettings = bookKey ? getViewSettings(bookKey) : null;
const inMemoryRules = viewSettings?.proofreadRules || [];
const persistedConfig = bookKey ? getConfig(bookKey) : null;
const persistedBookRules = persistedConfig?.viewSettings?.proofreadRules || [];
// Prefer persisted rules; fall back to in-memory
const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules;
const singleRules = bookRuleSource.filter((r: ProofreadRule) => r.scope === 'selection');
const bookScopedRules = bookRuleSource.filter((r: ProofreadRule) => r.scope === 'book');
const globalRules = settings?.globalViewSettings?.proofreadRules || [];
// Merge book-scoped and global rules
const globalRuleIds = new Set(globalRules.map((gr: ProofreadRule) => gr.id));
// Remove orphaned overrides (disabled global rules that no longer exist)
const validBookRules = bookScopedRules.filter(
(br: ProofreadRule) => br.enabled !== false || globalRuleIds.has(br.id),
);
const mergedBookRules = validBookRules.concat(
globalRules.filter(
(gr: ProofreadRule) => !validBookRules.find((br: ProofreadRule) => br.id === gr.id),
),
);
return { singleRules, bookRules: mergedBookRules };
};
export const ProofreadRulesManager: React.FC = () => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { recreateViewer } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { updateRule, removeRule } = useProofreadStore();
const [isOpen, setIsOpen] = useState(false);
const [editing, setEditing] = useState<{
id: string | null;
scope: ProofreadScope | null;
pattern: string;
replacement: string;
enabled: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
const { singleRules, bookRules } = useReplacementRules(sideBarBookKey);
useEffect(() => {
const handleVisibility = (event: CustomEvent) => setIsOpen(!!event.detail?.visible);
const el = document.getElementById(dialogId);
el?.addEventListener('setProofreadRulesVisibility', handleVisibility as EventListener);
return () =>
el?.removeEventListener('setProofreadRulesVisibility', handleVisibility as EventListener);
}, []);
const startEdit = (rule: ProofreadRule, scope: ProofreadScope) => {
setEditing({
id: rule.id,
scope,
pattern: rule.pattern,
replacement: rule.replacement,
enabled: !!rule.enabled,
});
};
const cancelEdit = () => {
setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
};
const saveEdit = async () => {
if (!editing.id || !editing.scope || !sideBarBookKey) return;
await updateRule(envConfig, sideBarBookKey, editing.id, {
scope: editing.scope,
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
});
cancelEdit();
recreateViewer(envConfig, sideBarBookKey);
};
const deleteRule = async (ruleId: string, scope: ProofreadScope) => {
if (!sideBarBookKey) return;
await removeRule(envConfig, sideBarBookKey, ruleId, scope);
recreateViewer(envConfig, sideBarBookKey);
};
const renderRuleList = (
rules: ProofreadRule[],
scopeType: ProofreadScope,
title: string,
emptyMessage: string,
) => (
<div className='flex flex-col gap-3'>
<h3 className='text-base-content/90 text-sm font-semibold'>{title}</h3>
{rules.length === 0 ? (
<div className='border-base-content/10 bg-base-200/30 rounded-lg border border-dashed p-6 text-center'>
<p className='text-base-content/50 text-sm'>{emptyMessage}</p>
</div>
) : (
<ul className='flex flex-col gap-2'>
{rules.map((rule) => (
<li
key={rule.id}
className='border-base-content/10 bg-base-100 hover:border-base-content/20 rounded-lg border transition-colors'
>
<RuleItem
rule={rule}
scope={scopeType === 'selection' ? 'selection' : rule.scope}
isEditing={
editing.id === rule.id &&
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)
}
onSave={saveEdit}
onCancel={cancelEdit}
onEditChange={(_, value) => setEditing({ ...editing, replacement: value })}
/>
</li>
))}
</ul>
)}
</div>
);
return (
<Dialog
id={dialogId}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Proofread Replacement Rules')}
boxClassName='sm:!min-w-[560px] sm:!max-w-[640px] sm:h-auto'
>
{isOpen && (
<div className='flex max-h-[70vh] flex-col gap-6 p-4 sm:p-6'>
{renderRuleList(
singleRules,
'selection',
_('Selected Text Rules'),
_('No selected text replacement rules'),
)}
{renderRuleList(
bookRules,
'book',
_('Book Specific Rules'),
_('No book-level replacement rules'),
)}
<div className='p-1'></div>
</div>
)}
</Dialog>
);
};
export default ProofreadRulesManager;
@@ -15,6 +15,7 @@ import { useNotebookStore } from '@/store/notebookStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { eventDispatcher } from '@/utils/event';
import { interceptWindowOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/styles/fonts';
@@ -23,7 +24,7 @@ import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { KOSyncSettingsWindow } from './KOSyncSettings';
import { ReplacementRulesWindow } from './ReplacementRulesWindow';
import { ProofreadRulesManager } from './ProofreadRules';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
import { initDayjs } from '@/utils/time';
@@ -54,18 +55,19 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const router = useRouter();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { libraryLoaded } = useLibrary();
const { sideBarBookKey } = useSidebarStore();
const { hoveredBookKey, getView } = useReaderStore();
const { hoveredBookKey } = useReaderStore();
const { showSystemUI, dismissSystemUI } = useThemeStore();
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore();
const { showSystemUI, dismissSystemUI } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const { libraryLoaded } = useLibrary();
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useTransferQueue(libraryLoaded, 5000);
useEffect(() => {
mountAdditionalFonts(document);
@@ -98,14 +100,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}, [appService]);
const handleKeyDown = (event: CustomEvent) => {
const view = getView(sideBarBookKey!);
if (event.detail.keyName === 'Back') {
if (getIsSideBarVisible()) {
setSideBarVisible(false);
} else if (getIsNotebookVisible()) {
setNotebookVisible(false);
} else if (view?.history.canGoBack) {
view.history.back();
} else {
eventDispatcher.dispatch('close-reader');
router.back();
@@ -161,7 +160,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<AboutWindow />
<UpdaterWindow />
<KOSyncSettingsWindow />
<ReplacementRulesWindow />
<ProofreadRulesManager />
<Toast />
</Suspense>
</div>
@@ -29,6 +29,7 @@ import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import SettingsDialog from '@/components/settings/SettingsDialog';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const _ = useTranslation();
@@ -41,6 +42,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { getConfig, getBookData, saveConfig } = useBookDataStore();
const { getView, setBookKeys, getViewSettings } = useReaderStore();
const { initViewState, getViewState, clearViewState } = useReaderStore();
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const isInitiating = useRef(false);
const [loading, setLoading] = useState(false);
@@ -212,6 +214,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
<div className='reader-content full-height flex'>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
<Notebook />
{showDetailsBook && (
<BookDetailModal
@@ -1,440 +0,0 @@
import React, { useEffect, useState } from 'react';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ReplacementRule } from '@/types/book';
import environmentConfig from '@/services/environment';
import { updateReplacementRule, removeReplacementRule } from '@/services/transformers/replacement';
import { eventDispatcher } from '@/utils/event';
import { RiEditLine, RiDeleteBin7Line } from 'react-icons/ri';
export const setReplacementRulesWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('replacement_rules_window');
if (dialog) {
const event = new CustomEvent('setReplacementRulesVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const ReplacementRulesWindow: React.FC = () => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { getConfig } = useBookDataStore();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(!!event.detail?.visible);
};
const el = document.getElementById('replacement_rules_window');
el?.addEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener);
};
}, []);
const viewSettings = sideBarBookKey ? getViewSettings(sideBarBookKey) : null;
const inMemoryRules = viewSettings?.replacementRules || [];
const persistedConfig = sideBarBookKey ? getConfig(sideBarBookKey) : null;
const persistedBookRules = persistedConfig?.viewSettings?.replacementRules || [];
// Prefer persisted rules; fall back to in-memory so we show unsaved edits in tests/dev
const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules;
const singleRules = bookRuleSource.filter((r: ReplacementRule) => !!r.singleInstance);
const bookScopedRules = bookRuleSource.filter((r: ReplacementRule) => !r.singleInstance);
// Book rules = book-scoped rules + global rules (merged for display)
// Merge logic:
// 1. Include all book-scoped rules (including disabled overrides of global rules)
// 2. Include global rules that aren't overridden at book level
// 3. Filter out orphaned overrides (disabled global rules that no longer exist globally)
const globalRules = settings?.globalViewSettings?.replacementRules || [];
// Create a map of global rule IDs to identify overridden rules
const globalRuleIds = new Set(globalRules.map((gr: ReplacementRule) => gr.id));
// Filter out book rules that are disabled overrides of non-existent global rules
const validBookRules = bookScopedRules.filter((br: ReplacementRule) => {
// If it's enabled, it's a real book rule
if (br.enabled !== false) return true;
// If it's disabled and the global rule still exists, keep it (it's an override)
// If the global rule doesn't exist, filter it out (orphaned override)
return globalRuleIds.has(br.id);
});
const mergedRules = validBookRules.concat(
globalRules.filter(
(gr: ReplacementRule) => !validBookRules.find((br: ReplacementRule) => br.id === gr.id),
),
);
// Create a map to track the scope of each rule for editing/deleting
const getRuleScope = (rule: ReplacementRule): 'single' | 'book' | 'global' => {
if (rule.singleInstance) return 'single';
// If the rule is in validBookRules and originates from global, it's an override
return globalRuleIds.has(rule.id) ? 'global' : 'book';
};
const bookRules = mergedRules;
const [editing, setEditing] = useState<{
id: string | null;
scope: 'single' | 'book' | 'global' | null;
pattern: string;
replacement: string;
enabled: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
// Track when a delete/edit operation is in progress to prevent rapid successive operations
const [isReloading, setIsReloading] = useState(false);
const startEdit = (r: ReplacementRule, scope: 'single' | 'book' | 'global') => {
setEditing({
id: r.id,
scope,
pattern: r.pattern,
replacement: r.replacement,
enabled: !!r.enabled,
});
};
const cancelEdit = () =>
setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
const saveEdit = async () => {
if (!editing.id || !editing.scope) return;
// Prevent rapid successive operations
if (isReloading) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Please wait for the current operation to complete.'),
timeout: 3000,
});
return;
}
setIsReloading(true);
try {
const bookKey = sideBarBookKey || '';
if (editing.scope === 'global') {
await updateReplacementRule(
environmentConfig,
bookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'global',
);
} else if (editing.scope === 'book' && sideBarBookKey) {
await updateReplacementRule(
environmentConfig,
sideBarBookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'book',
);
} else if (editing.scope === 'single' && sideBarBookKey) {
await updateReplacementRule(
environmentConfig,
sideBarBookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'single',
);
}
cancelEdit();
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Replacement rule updated. Reloading book to apply changes...'),
timeout: 3000,
});
if (sideBarBookKey) {
const { clearViewState, initViewState } = useReaderStore.getState();
const id = sideBarBookKey.split('-')[0]!;
// Hard reload: clear and reinit viewer to load from original source
clearViewState(sideBarBookKey);
await initViewState(environmentConfig, id, sideBarBookKey, true, true);
}
} catch (err) {
console.error('Failed to save replacement rule', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to update replacement rule'),
timeout: 3000,
});
} finally {
setIsReloading(false);
}
};
const deleteRule = async (ruleId: string, scope: 'single' | 'book' | 'global') => {
console.log('Deleting rule', ruleId, 'scope', scope);
// Prevent rapid successive deletions
if (isReloading) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Please wait for the book to finish reloading.'),
timeout: 3000,
});
return;
}
setIsReloading(true);
try {
const bookKey = sideBarBookKey || '';
if (scope === 'global') {
// delete global rule for all books
await removeReplacementRule(environmentConfig, '', ruleId, 'global');
} else {
await removeReplacementRule(environmentConfig, bookKey, ruleId, scope);
}
const successMessage =
scope === 'global'
? _(
'Global replacement rule deleted for all books in the library. Reloading book to apply changes...',
)
: _('Replacement rule deleted. Reloading book to apply changes...');
eventDispatcher.dispatch('toast', {
type: 'success',
message: successMessage,
timeout: 3000,
});
if (sideBarBookKey) {
const { clearViewState, initViewState } = useReaderStore.getState();
const id = sideBarBookKey.split('-')[0]!;
// Hard reload: clear and reinit viewer to load from original source
clearViewState(sideBarBookKey);
await initViewState(environmentConfig, id, sideBarBookKey, true, true);
}
} catch (err) {
console.error('Failed to delete replacement rule', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to delete replacement rule'),
timeout: 3000,
});
} finally {
setIsReloading(false);
}
};
return (
<Dialog
id='replacement_rules_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Replacement Rules')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
<div>
<h3 className='text-sm font-semibold'>{_('Single Instance Rules')}</h3>
{singleRules.length === 0 ? (
<p className='text-base-content/70 mt-2 text-sm'>
{_('No single replacement rules')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{singleRules.map((r) => (
<li key={r.id} className='rounded border p-2'>
{editing.id === r.id && editing.scope === 'single' ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Selected phrase:')}
</label>
<input
className='input input-sm flex-1 text-sm opacity-60'
value={editing.pattern}
disabled
/>
</div>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Replace with:')}
</label>
<input
className='input input-sm flex-1'
value={editing.replacement}
onChange={(e) =>
setEditing({ ...editing, replacement: e.target.value })
}
/>
</div>
<div className='flex gap-2'>
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
{_('Save')}
</button>
<button className='btn btn-sm' onClick={cancelEdit}>
{_('Cancel')}
</button>
</div>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
<div className='text-base-content/70 mt-1 break-all text-sm'>
<span className='text-base-content/80 mr-2 text-xs font-medium'>
{_('Replace with:')}
</span>
{r.replacement}
</div>
<div className='text-base-content/60 mt-1 text-xs'>
{_('Scope:')}&nbsp;<span className='font-medium'>Single Instance</span>
&nbsp;|&nbsp;{_('Case sensitive:')}&nbsp;
<span className='font-medium'>
{r.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</div>
</div>
<div className='flex items-center gap-2'>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => startEdit(r, 'single')}
aria-label={_('Edit')}
>
<RiEditLine />
</button>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => deleteRule(r.id, 'single')}
aria-label={_('Delete')}
>
<RiDeleteBin7Line />
</button>
</div>
</div>
)}
</li>
))}
</ul>
)}
<h3 className='mt-4 text-sm font-semibold'>{_('Book Specific Rules')}</h3>
{bookRules.length === 0 ? (
<p className='text-base-content/70 mt-2 text-sm'>
{_('No book-level replacement rules')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{bookRules.map((r) => {
const ruleScope = getRuleScope(r);
const isEditing = editing.id === r.id && editing.scope === ruleScope;
return (
<li key={r.id} className='rounded border p-2'>
{isEditing ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Selected phrase:')}
</label>
<input
className='input input-sm flex-1 text-sm opacity-60'
value={editing.pattern}
disabled
/>
</div>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Replace with:')}
</label>
<input
className='input input-sm flex-1'
value={editing.replacement}
onChange={(e) =>
setEditing({ ...editing, replacement: e.target.value })
}
/>
</div>
<div className='flex gap-2'>
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
{_('Save')}
</button>
<button className='btn btn-sm' onClick={cancelEdit}>
{_('Cancel')}
</button>
</div>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
<div className='text-base-content/70 mt-1 break-all text-sm'>
<span className='text-base-content/80 mr-2 text-xs font-medium'>
{_('Replace with:')}
</span>
{r.replacement}
</div>
<div className='text-base-content/60 mt-1 text-xs'>
{_('Scope:')}&nbsp;
<span className='font-medium'>
{getRuleScope(r) === 'book' ? _('Book') : _('Global')}
</span>
&nbsp;|&nbsp;{_('Case sensitive:')}&nbsp;
<span className='font-medium'>
{r.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</div>
</div>
<div className='flex items-center gap-2'>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => startEdit(r, getRuleScope(r))}
aria-label={_('Edit')}
>
<RiEditLine />
</button>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => deleteRule(r.id, ruleScope)}
aria-label={_('Delete')}
>
<RiDeleteBin7Line />
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</Dialog>
);
};
export default ReplacementRulesWindow;
@@ -61,7 +61,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
style={
isVertical
? {
top: `${contentInsets.top * 1.5}px`,
top: `${(contentInsets.top - gridInsets.top) * 1.5}px`,
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
@@ -6,12 +6,18 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import Button from '@/components/Button';
const SettingsToggler = () => {
interface SettingsTogglerProps {
bookKey: string;
}
const SettingsToggler: React.FC<SettingsTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { setHoveredBookKey } = useReaderStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const { setSettingsDialogBookKey } = useSettingsStore();
const handleToggleSettings = () => {
setHoveredBookKey('');
setSettingsDialogBookKey(bookKey);
setSettingsDialogOpen(!isSettingsDialogOpen);
};
return (
@@ -147,7 +147,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
<Menu
className={clsx(
'view-menu dropdown-content dropdown-right no-triangle z-20 mt-1 border',
'bgcolor-base-200 border-base-200 shadow-2xl',
'bgcolor-base-200 shadow-2xl',
)}
style={{
maxWidth: `${window.innerWidth - 40}px`,
@@ -0,0 +1,146 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import React, { useMemo } from 'react';
import { BookNote } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface AnnotationNotesProps {
bookKey: string;
isVertical: boolean;
notes: BookNote[];
toolsVisible: boolean;
triangleDir: 'up' | 'down' | 'left' | 'right';
popupWidth: number;
popupHeight: number;
onDismiss: () => void;
}
const AnnotationNotes: React.FC<AnnotationNotesProps> = ({
bookKey,
isVertical,
notes,
toolsVisible,
triangleDir,
popupWidth,
popupHeight,
onDismiss,
}) => {
const { appService } = useEnv();
const { getConfig, setConfig } = useBookDataStore();
const { setHoveredBookKey } = useReaderStore();
const { setSideBarVisible } = useSidebarStore();
const config = getConfig(bookKey);
const maxSize = useResponsiveSize(250);
const sortedNotes = useMemo(() => {
return [...notes].sort((a, b) => b.updatedAt - a.updatedAt);
}, [notes]);
const handleShowAnnotation = (note: BookNote) => {
if (!note.id) return;
if (appService?.isMobile) {
onDismiss();
}
setHoveredBookKey('');
setSideBarVisible(true);
if (config?.viewSettings) {
setConfig(bookKey, {
viewSettings: { ...config.viewSettings, sideBarTab: 'annotations' },
});
}
};
return (
<div
className={clsx('annotation-notes absolute flex rounded-lg text-white')}
style={{
...(isVertical
? {
right: triangleDir === 'left' ? `${toolsVisible ? popupWidth + 16 : 0}px` : undefined,
left: triangleDir === 'right' ? `${toolsVisible ? popupWidth + 16 : 0}px` : undefined,
height: `${popupHeight}px`,
maxWidth: `${maxSize}px`,
overflowX: 'auto',
}
: {
top: triangleDir === 'down' ? `${toolsVisible ? popupHeight + 16 : 0}px` : undefined,
bottom: triangleDir === 'up' ? `${toolsVisible ? popupHeight + 16 : 0}px` : undefined,
width: `${popupWidth}px`,
maxHeight: `${maxSize}px`,
overflowY: 'auto',
}),
scrollbarWidth: 'thin',
}}
>
<div
className={clsx('flex gap-4', isVertical ? 'h-full flex-row' : 'w-full flex-col')}
style={
isVertical
? {
display: 'grid',
gridAutoFlow: 'column',
gridAutoColumns: 'max-content',
minWidth: 'min-content',
height: `${popupHeight}px`,
maxHeight: `${popupHeight}px`,
}
: {}
}
>
{sortedNotes.map((note, index) => (
<div
role='none'
key={note.id || index}
onClick={() => handleShowAnnotation?.(note)}
className={clsx(
'popup-container cursor-pointer rounded-lg bg-gray-600 shadow-lg transition-colors',
)}
style={
isVertical
? {
minWidth: 'max-content',
height: `${popupHeight}px`,
maxHeight: `${popupHeight}px`,
}
: {}
}
>
{note.note && (
<div
dir='auto'
className={clsx(
'm-4 hyphens-auto text-justify font-sans text-sm',
'not-eink:text-white eink:text-base-content',
isVertical && 'writing-vertical-rl',
)}
style={
isVertical
? {
fontFeatureSettings: "'vrt2' 1, 'vert' 1",
minWidth: 'max-content',
}
: {}
}
>
<div className={clsx('flex flex-col justify-between gap-2')}>
{note.note}
<span className='not-eink:text-white/50 eink:text-base-content/50 text-sm sm:text-xs'>
{dayjs(note.createdAt).fromNow()}
</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
};
export default AnnotationNotes;
@@ -1,13 +1,14 @@
import clsx from 'clsx';
import React from 'react';
import Popup from '@/components/Popup';
import PopupButton from './PopupButton';
import HighlightOptions from './HighlightOptions';
import { Position } from '@/utils/sel';
import { HighlightColor, HighlightStyle } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
import Popup from '@/components/Popup';
import AnnotationToolButton from './AnnotationToolButton';
import AnnotationNotes from './AnnotationNotes';
import HighlightOptions from './HighlightOptions';
interface AnnotationPopupProps {
bookKey: string;
dir: 'ltr' | 'rtl';
isVertical: boolean;
buttons: Array<{
@@ -17,6 +18,7 @@ interface AnnotationPopupProps {
disabled?: boolean;
visible?: boolean;
}>;
notes: BookNote[];
position: Position;
trianglePosition: Position;
highlightOptionsVisible: boolean;
@@ -25,16 +27,15 @@ interface AnnotationPopupProps {
popupWidth: number;
popupHeight: number;
onHighlight: (update?: boolean) => void;
onDismiss?: () => void;
onDismiss: () => void;
}
const OPTIONS_HEIGHT_PIX = 28;
const OPTIONS_PADDING_PIX = 16;
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
bookKey,
dir,
isVertical,
buttons,
notes,
position,
trianglePosition,
highlightOptionsVisible,
@@ -45,8 +46,6 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
onHighlight,
onDismiss,
}) => {
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
return (
<div dir={dir}>
<Popup
@@ -55,61 +54,62 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
minHeight={isVertical ? popupWidth : popupHeight}
position={position}
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 text-white'
className={clsx(
'selection-popup bg-gray-600 text-white',
notes.length > 0 && 'bg-transparent',
)}
triangleClassName='text-gray-600'
onDismiss={onDismiss}
>
<div
className={clsx(
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<PopupButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
<div className={clsx('flex h-full gap-4', isVertical ? 'flex-row' : 'flex-col')}>
<div
className={clsx(
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
notes.length > 0 && 'hidden',
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<AnnotationToolButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
);
})}
</div>
{notes.length > 0 ? (
<AnnotationNotes
bookKey={bookKey}
isVertical={isVertical}
notes={notes}
toolsVisible={false}
triangleDir={trianglePosition.dir!}
popupWidth={isVertical ? popupHeight : popupWidth}
popupHeight={isVertical ? popupWidth : popupHeight}
onDismiss={onDismiss}
/>
) : (
highlightOptionsVisible && (
<HighlightOptions
isVertical={isVertical}
triangleDir={trianglePosition.dir!}
popupWidth={isVertical ? popupHeight : popupWidth}
popupHeight={isVertical ? popupWidth : popupHeight}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
onHandleHighlight={onHighlight}
/>
);
})}
)
)}
</div>
</Popup>
{highlightOptionsVisible && (
<HighlightOptions
isVertical={isVertical}
style={{
width: `${isVertical ? popupHeight : popupWidth}px`,
height: `${isVertical ? popupWidth : popupHeight}px`,
...(isVertical
? {
left: `${
position.point.x +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'left' ? -1 : 1)
}px`,
top: `${position.point.y}px`,
}
: {
left: `${position.point.x}px`,
top: `${
position.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}),
}}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
onHandleHighlight={onHighlight}
/>
)}
</div>
);
};
@@ -0,0 +1,248 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BookNote, HighlightColor } from '@/types/book';
import { Point, TextSelection } from '@/utils/sel';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useAnnotationEditor } from '../../hooks/useAnnotationEditor';
import { getHighlightColorHex } from '../../utils/annotatorUtil';
interface HandleProps {
position: Point;
isVertical: boolean;
type: 'start' | 'end';
color: string;
onDragStart: () => void;
onDrag: (point: Point) => void;
onDragEnd: () => void;
}
const Handle: React.FC<HandleProps> = ({
position,
isVertical,
type,
color,
onDragStart,
onDrag,
onDragEnd,
}) => {
const isDragging = useRef(false);
const size = useResponsiveSize(24);
const circleRadius = useResponsiveSize(8);
const stemHeight = useResponsiveSize(12);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
isDragging.current = true;
onDragStart();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[onDragStart],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging.current) return;
e.preventDefault();
e.stopPropagation();
onDrag({ x: e.clientX, y: e.clientY });
},
[onDrag],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!isDragging.current) return;
e.preventDefault();
e.stopPropagation();
isDragging.current = false;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
onDragEnd();
},
[onDragEnd],
);
return (
<div
className='pointer-events-auto absolute z-50 cursor-grab touch-none active:cursor-grabbing'
style={{
left: isVertical
? type === 'start'
? position.x - size / 2 + stemHeight / 4
: position.x - size / 2
: position.x - size / 2,
top: isVertical
? type === 'start'
? position.y - size + stemHeight / 2
: position.y - size / 2 - stemHeight / 2
: type === 'start'
? position.y - size
: position.y - size / 2 - stemHeight / 8,
width: size,
height: size + stemHeight,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<svg
width={size}
height={size + stemHeight}
viewBox={`0 0 ${size} ${size + stemHeight}`}
className={clsx(type === 'start' && 'rotate-180')}
style={{
transform: isVertical
? type === 'start'
? 'rotate(270deg)'
: 'rotate(90deg)'
: type === 'start'
? 'rotate(180deg)'
: undefined,
}}
>
{/* Stem/line */}
<line
x1={size / 2}
y1={0}
x2={size / 2}
y2={stemHeight}
stroke={color}
strokeWidth={2}
strokeLinecap='round'
/>
{/* Circle handle */}
<circle cx={size / 2} cy={stemHeight + circleRadius} r={circleRadius} fill={color} />
</svg>
</div>
);
};
interface AnnotationRangeEditorProps {
bookKey: string;
isVertical: boolean;
annotation: BookNote;
selection: TextSelection;
handleColor: HighlightColor;
getAnnotationText: (range: Range) => Promise<string>;
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>;
onStartEdit: () => void;
}
const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
bookKey,
isVertical,
annotation,
selection,
handleColor,
getAnnotationText,
setSelection,
onStartEdit,
}) => {
const { settings } = useSettingsStore();
const { isDarkMode } = useThemeStore();
const isEink = settings.globalViewSettings.isEink;
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
const { handlePositions, getHandlePositionsFromRange, handleAnnotationRangeChange } =
useAnnotationEditor({ bookKey, annotation, getAnnotationText, setSelection });
const initializedRef = useRef(false);
const handleColorHex = getHighlightColorHex(settings, handleColor) ?? '#FFFF00';
const draggingRef = useRef<'start' | 'end' | null>(null);
const startRef = useRef<Point>({ x: 0, y: 0 });
const endRef = useRef<Point>({ x: 0, y: 0 });
const [currentStart, setCurrentStart] = useState<Point>({ x: 0, y: 0 });
const [currentEnd, setCurrentEnd] = useState<Point>({ x: 0, y: 0 });
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const range = selection.range;
const positions = getHandlePositionsFromRange(range, isVertical);
if (positions) {
setTimeout(() => {
setCurrentStart(positions.start);
setCurrentEnd(positions.end);
}, 0);
startRef.current = positions.start;
endRef.current = positions.end;
}
}, [annotation, selection, isVertical, getHandlePositionsFromRange]);
useEffect(() => {
if (!handlePositions || draggingRef.current) return;
setTimeout(() => {
setCurrentStart(handlePositions.start);
setCurrentEnd(handlePositions.end);
}, 0);
startRef.current = handlePositions.start;
endRef.current = handlePositions.end;
}, [handlePositions]);
const handleStartDragStart = useCallback(() => {
draggingRef.current = 'start';
onStartEdit();
}, [onStartEdit]);
const handleEndDragStart = useCallback(() => {
draggingRef.current = 'end';
onStartEdit();
}, [onStartEdit]);
const handleStartDrag = useCallback(
(point: Point) => {
setCurrentStart(point);
startRef.current = point;
handleAnnotationRangeChange(point, endRef.current, isVertical, true);
},
[isVertical, handleAnnotationRangeChange],
);
const handleEndDrag = useCallback(
(point: Point) => {
setCurrentEnd(point);
endRef.current = point;
handleAnnotationRangeChange(startRef.current, point, isVertical, true);
},
[isVertical, handleAnnotationRangeChange],
);
const handleDragEnd = useCallback(() => {
draggingRef.current = null;
handleAnnotationRangeChange(startRef.current, endRef.current, isVertical, false);
}, [isVertical, handleAnnotationRangeChange]);
if (currentStart.x === 0 && currentStart.y === 0) {
return null;
}
return (
<div className='pointer-events-none fixed inset-0 z-40'>
<Handle
position={currentStart}
isVertical={isVertical}
type='start'
color={isEink ? einkFgColor : handleColorHex}
onDragStart={handleStartDragStart}
onDrag={handleStartDrag}
onDragEnd={handleDragEnd}
/>
<Handle
position={currentEnd}
isVertical={isVertical}
type='end'
color={isEink ? einkFgColor : handleColorHex}
onDragStart={handleEndDragStart}
onDrag={handleEndDrag}
onDragEnd={handleDragEnd}
/>
</div>
);
};
export default AnnotationRangeEditor;
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import React, { useState } from 'react';
interface PopupButtonProps {
interface AnnotationToolButtonProps {
showTooltip: boolean;
tooltipText: string;
disabled?: boolean;
@@ -9,7 +9,7 @@ interface PopupButtonProps {
onClick: () => void;
}
const PopupButton: React.FC<PopupButtonProps> = ({
const AnnotationToolButton: React.FC<AnnotationToolButtonProps> = ({
showTooltip,
tooltipText,
disabled,
@@ -24,13 +24,15 @@ const PopupButton: React.FC<PopupButtonProps> = ({
return (
<div
className='lg:tooltip lg:tooltip-bottom'
data-tip={!buttonClicked && showTooltip ? tooltipText : null}
title={!buttonClicked && showTooltip ? tooltipText : undefined}
>
<button
onClick={handleClick}
className={clsx(
'flex h-8 min-h-8 w-8 items-center justify-center p-0',
disabled ? 'cursor-not-allowed opacity-50' : 'rounded-md hover:bg-gray-500',
disabled
? 'cursor-not-allowed opacity-50'
: 'not-eink:hover:bg-gray-500 eink:hover:border rounded-md',
)}
disabled={disabled}
>
@@ -40,4 +42,4 @@ const PopupButton: React.FC<PopupButtonProps> = ({
);
};
export default PopupButton;
export default AnnotationToolButton;
@@ -0,0 +1,98 @@
import { IconType } from 'react-icons';
import { FiSearch } from 'react-icons/fi';
import { FiCopy } from 'react-icons/fi';
import { PiHighlighterFill } from 'react-icons/pi';
import { FaWikipediaW } from 'react-icons/fa';
import { BsPencilSquare } from 'react-icons/bs';
import { BsTranslate } from 'react-icons/bs';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import { IoIosBuild } from 'react-icons/io';
import { AnnotationToolType } from '@/types/annotator';
import { stubTranslation as _ } from '@/utils/misc';
type AnnotationToolButton = {
type: AnnotationToolType;
label: string;
tooltip: string;
Icon: IconType;
quickAction?: boolean;
};
function createAnnotationToolButtons<T extends AnnotationToolType>(
buttons: AnnotationToolType extends T
? {
[K in T]: {
type: K;
label: string;
tooltip: string;
Icon: IconType;
quickAction?: boolean;
};
}[T][]
: never,
): AnnotationToolButton[] {
return buttons;
}
export const annotationToolButtons = createAnnotationToolButtons([
{ type: 'copy', label: _('Copy'), tooltip: _('Copy text after selection'), Icon: FiCopy },
{
type: 'highlight',
label: _('Highlight'),
tooltip: _('Highlight text after selection'),
Icon: PiHighlighterFill,
quickAction: true,
},
{
type: 'annotate',
label: _('Annotate'),
tooltip: _('Annotate text after selection'),
Icon: BsPencilSquare,
},
{
type: 'search',
label: _('Search'),
tooltip: _('Search text after selection'),
Icon: FiSearch,
quickAction: true,
},
{
type: 'dictionary',
label: _('Dictionary'),
tooltip: _('Look up text in dictionary after selection'),
Icon: TbHexagonLetterD,
quickAction: true,
},
{
type: 'wikipedia',
label: _('Wikipedia'),
tooltip: _('Look up text in Wikipedia after selection'),
Icon: FaWikipediaW,
quickAction: true,
},
{
type: 'translate',
label: _('Translate'),
tooltip: _('Translate text after selection'),
Icon: BsTranslate,
quickAction: true,
},
{
type: 'tts',
label: _('Speak'),
tooltip: _('Read text aloud after selection'),
Icon: FaHeadphones,
quickAction: true,
},
{
type: 'proofread',
label: _('Proofread'),
tooltip: _('Proofread text after selection'),
Icon: IoIosBuild,
},
]);
export const annotationToolQuickActions = annotationToolButtons.filter(
(button) => button.quickAction,
);
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import React, { useState } from 'react';
import { FaCheckCircle } from 'react-icons/fa';
import { HighlightColor, HighlightStyle } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { saveSysSettings } from '@/helpers/settings';
@@ -12,28 +13,40 @@ const colors = ['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[];
interface HighlightOptionsProps {
isVertical: boolean;
style: React.CSSProperties;
popupWidth: number;
popupHeight: number;
triangleDir: 'up' | 'down' | 'left' | 'right';
selectedStyle: HighlightStyle;
selectedColor: HighlightColor;
onHandleHighlight: (update: boolean) => void;
}
const OPTIONS_HEIGHT_PIX = 28;
const OPTIONS_PADDING_PIX = 16;
const HighlightOptions: React.FC<HighlightOptionsProps> = ({
style,
isVertical,
popupWidth,
popupHeight,
triangleDir,
selectedStyle: _selectedStyle,
selectedColor: _selectedColor,
onHandleHighlight,
}) => {
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { isDarkMode } = useThemeStore();
const globalReadSettings = settings.globalReadSettings;
const isEink = settings.globalViewSettings.isEink;
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
const einkFgColor = isDarkMode ? '#ffffff' : '#000000';
const customColors = globalReadSettings.customHighlightColors;
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(_selectedStyle);
const [selectedColor, setSelectedColor] = useState<HighlightColor>(_selectedColor);
const size16 = useResponsiveSize(16);
const size18 = useResponsiveSize(18);
const size28 = useResponsiveSize(28);
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
const handleSelectStyle = (style: HighlightStyle) => {
const newGlobalReadSettings = { ...globalReadSettings, highlightStyle: style };
@@ -58,7 +71,23 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
'highlight-options absolute flex items-center justify-between',
isVertical ? 'flex-col' : 'flex-row',
)}
style={style}
style={{
width: `${popupWidth}px`,
height: `${popupHeight}px`,
...(isVertical
? {
left: `${
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(triangleDir === 'left' ? -1 : 1)
}px`,
}
: {
top: `${
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(triangleDir === 'up' ? -1 : 1)
}px`,
}),
}}
>
<div
className={clsx('flex gap-2', isVertical ? 'flex-col' : 'flex-row')}
@@ -68,16 +97,17 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
<button
key={style}
onClick={() => handleSelectStyle(style)}
className='flex items-center justify-center rounded-full bg-gray-700 p-0'
className='not-eink:bg-gray-700 eink-bordered flex items-center justify-center rounded-full p-0'
style={{ width: size28, height: size28, minHeight: size28 }}
>
<div
style={{
width: size16,
height: style === 'squiggly' ? size18 : size16,
height: size16,
...(style === 'highlight' &&
selectedStyle === 'highlight' && {
backgroundColor: customColors[selectedColor],
backgroundColor: isEink ? einkFgColor : customColors[selectedColor],
color: isEink ? einkBgColor : '#d1d5db',
paddingTop: '2px',
}),
...(style === 'highlight' &&
@@ -86,11 +116,15 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
paddingTop: '2px',
}),
...((style === 'underline' || style === 'squiggly') && {
color: '#d1d5db',
color: isEink ? einkFgColor : '#d1d5db',
textDecoration: 'underline',
textDecorationThickness: '2px',
textDecorationColor:
selectedStyle === style ? customColors[selectedColor] : '#d1d5db',
selectedStyle === style
? isEink
? einkFgColor
: customColors[selectedColor]
: '#d1d5db',
...(style === 'squiggly' && { textDecorationStyle: 'wavy' }),
}),
}}
@@ -104,27 +138,32 @@ const HighlightOptions: React.FC<HighlightOptionsProps> = ({
<div
className={clsx(
'flex items-center justify-center gap-2 rounded-3xl bg-gray-700',
'not-eink:bg-gray-700 eink-bordered flex items-center justify-center gap-2 rounded-3xl',
isVertical ? 'flex-col py-2' : 'flex-row px-2',
)}
style={isVertical ? { width: size28 } : { height: size28 }}
>
{colors.map((color) => (
<button
key={color}
onClick={() => handleSelectColor(color)}
style={{
width: size16,
height: size16,
backgroundColor: selectedColor !== color ? customColors[color] : 'transparent',
}}
className='rounded-full p-0'
>
{selectedColor === color && (
<FaCheckCircle size={size16} style={{ fill: customColors[color] }} />
)}
</button>
))}
{colors
.filter((c) => (isEink ? selectedColor === c : true))
.map((color) => (
<button
key={color}
onClick={() => handleSelectColor(color)}
style={{
width: size16,
height: size16,
backgroundColor: selectedColor !== color ? customColors[color] : 'transparent',
}}
className='rounded-full p-0'
>
{selectedColor === color && (
<FaCheckCircle
size={size16}
style={{ fill: isEink ? einkFgColor : customColors[color] }}
/>
)}
</button>
))}
</div>
</div>
);
@@ -0,0 +1,191 @@
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useAutoFocus } from '@/hooks/useAutoFocus';
import { CreateProofreadRuleOptions, useProofreadStore } from '@/store/proofreadStore';
import { ProofreadScope } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import { Position, TextSelection } from '@/utils/sel';
import { isWholeWord } from '@/utils/word';
import Select from '@/components/Select';
import Popup from '@/components/Popup';
interface ProofreadPopupProps {
bookKey: string;
selection?: TextSelection;
position: Position;
trianglePosition: Position;
popupWidth: number;
popupHeight: number;
onConfirm?: (options: CreateProofreadRuleOptions) => void;
onDismiss: () => void;
}
const ProofreadPopup: React.FC<ProofreadPopupProps> = ({
bookKey,
selection,
position,
trianglePosition,
popupWidth,
popupHeight,
onConfirm,
onDismiss,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getProgress, getView, recreateViewer } = useReaderStore();
const { addRule } = useProofreadStore();
const progress = getProgress(bookKey)!;
const [replacementText, setReplacementText] = useState('');
const [caseSensitive, setCaseSensitive] = useState(true);
const [scope, setScope] = useState<ProofreadScope>('selection');
const inputRef = useRef<HTMLInputElement>(null);
useAutoFocus<HTMLInputElement>({ ref: inputRef });
const handleScopeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setScope(event.target.value as ProofreadScope);
};
const handleApply = async () => {
if (!selection) return;
const range = selection?.range;
if (range) {
const isValidWholeWord = isWholeWord(range, selection?.text || '');
if (!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.`,
timeout: 5000,
});
return;
}
if (scope === 'selection') {
range.deleteContents();
const textNode = document.createTextNode(replacementText);
range.insertNode(textNode);
}
const options = {
scope,
pattern: selection.text,
replacement: replacementText.trim(),
cfi: selection.cfi,
sectionHref: progress?.sectionHref,
isRegex: false,
enabled: true,
caseSensitive,
wholeWord: true,
};
onConfirm?.(options);
await addRule(envConfig, bookKey, options);
onDismiss();
if (scope !== 'selection') {
if (getView(bookKey)) {
recreateViewer(envConfig, bookKey);
}
}
}
};
const scopeOptions = [
{ value: 'selection', label: _('Current selection') },
{ value: 'book', label: _('All occurrences in this book') },
{ value: 'library', label: _('All occurrences in your library') },
];
return (
<div>
<Popup
trianglePosition={trianglePosition}
width={popupWidth}
minHeight={popupHeight}
position={position}
className='flex flex-col justify-between rounded-lg bg-gray-700 text-gray-400'
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'>
&quot;{selection?.text || ''}&quot;
</span>
</div>
<div className='flex items-center justify-between gap-2'>
<label htmlFor='replacement-input' className='text-xs'>
{_('Replace with:')}
</label>
<input
ref={inputRef}
type='text'
value={replacementText}
onChange={(e) => setReplacementText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && replacementText.trim()) {
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'
/>
<button
onClick={handleApply}
disabled={!replacementText.trim()}
className={clsx(
'btn btn-sm btn-ghost text-blue-600 disabled:text-gray-600',
'bg-transparent hover:bg-transparent disabled:bg-transparent',
)}
>
{_('Apply')}
</button>
</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'>
<span className='line-clamp-1 text-xs' title={_('Case sensitive:')}>
{_('Case sensitive:')}
</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={caseSensitive}
onChange={(e) => setCaseSensitive(e.target.checked)}
/>
</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}
/>
</div>
</div>
</Popup>
</div>
);
};
export default ProofreadPopup;
@@ -0,0 +1,67 @@
import clsx from 'clsx';
import React from 'react';
import { AnnotationToolType } from '@/types/annotator';
import { useTranslation } from '@/hooks/useTranslation';
import { annotationToolQuickActions } from './AnnotationTools';
import { eventDispatcher } from '@/utils/event';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
interface QuickActionMenuProps {
selectedAction?: AnnotationToolType | null;
onActionSelect: (action: AnnotationToolType) => void;
setIsDropdownOpen?: (open: boolean) => void;
}
const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
selectedAction,
onActionSelect,
setIsDropdownOpen,
}) => {
const _ = useTranslation();
const handleActionClick = (action: AnnotationToolType) => {
onActionSelect(action);
if (selectedAction === action) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Quick action disabled'),
});
} else {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _(annotationToolQuickActions.find((btn) => btn.type === action)?.tooltip || ''),
});
}
setIsDropdownOpen?.(false);
};
return (
<Menu
className={clsx(
'annotation-quick-action-menu dropdown-content z-20 mt-1 border',
'bgcolor-base-200 shadow-2xl',
)}
style={{
maxWidth: `${window.innerWidth - 40}px`,
}}
onCancel={() => setIsDropdownOpen?.(false)}
>
{annotationToolQuickActions.map((button) => (
<MenuItem
key={button.type}
label={_(button.label)}
tooltip={_(button.tooltip)}
buttonClass={selectedAction === button.type ? 'bg-base-300/85' : ''}
Icon={button.Icon}
onClick={() => handleActionClick(button.type)}
/>
))}
</Menu>
);
};
export default QuickActionMenu;
@@ -1,284 +0,0 @@
'use client';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
export interface ReplacementConfig {
replacementText: string;
caseSensitive: boolean;
scope: 'once' | 'book' | 'library';
}
interface ReplacementOptionsProps {
isVertical: boolean;
style: React.CSSProperties;
selectedText: string;
onConfirm: (config: ReplacementConfig) => void;
onClose: () => void;
}
const ReplacementOptions: React.FC<ReplacementOptionsProps> = ({
style,
isVertical,
selectedText,
onConfirm,
onClose,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [replacementText, setReplacementText] = useState('');
const [caseSensitive, setCaseSensitive] = useState(true);
const [selectedScope, setSelectedScope] = useState<'once' | 'book' | 'library' | null>(null);
const [showConfirmation, setShowConfirmation] = useState(false);
const [adjustedStyle, setAdjustedStyle] = useState<React.CSSProperties | null>(null);
const [isPositioned, setIsPositioned] = useState(false);
const hasAdjusted = useRef(false);
// Adjust position to stay within viewport - only once on initial render
useEffect(() => {
// Only adjust once to prevent jumping when other UI elements appear
if (menuRef.current && !hasAdjusted.current) {
// Use requestAnimationFrame to ensure the element is rendered before measuring
requestAnimationFrame(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const padding = 10;
const newStyle = { ...style };
// Check if popup extends beyond bottom of viewport
if (rect.bottom > viewportHeight - padding) {
const currentTop = parseFloat(String(style.top)) || 0;
// Move popup above the selection instead
newStyle.top = `${Math.max(padding, currentTop - rect.height - 40)}px`;
}
// Check if popup extends beyond right of viewport
if (rect.right > viewportWidth - padding) {
newStyle.left = `${Math.max(padding, viewportWidth - rect.width - padding)}px`;
}
// Check if popup extends beyond left of viewport
if (rect.left < padding) {
newStyle.left = `${padding}px`;
}
setAdjustedStyle(newStyle);
hasAdjusted.current = true;
setIsPositioned(true);
}
});
}
}, [style]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Focus input on mount
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleScopeClick = (scope: 'once' | 'book' | 'library') => {
if (!replacementText.trim()) {
// Show error if no replacement text
return;
}
setSelectedScope(scope);
setShowConfirmation(true);
};
const handleConfirm = () => {
if (selectedScope && replacementText.trim()) {
onConfirm({
replacementText: replacementText.trim(),
caseSensitive,
scope: selectedScope,
});
}
};
const handleCancelConfirmation = () => {
setShowConfirmation(false);
setSelectedScope(null);
};
const handleCancel = () => {
onClose();
};
const getScopeLabel = (scope: 'once' | 'book' | 'library' | null) => {
switch (scope) {
case 'once':
return 'this instance';
case 'book':
return 'all instances in this book';
case 'library':
return 'all instances in your library';
default:
return '';
}
};
// Secondary confirmation dialog
if (showConfirmation) {
return (
<div
ref={menuRef}
className={clsx(
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
)}
style={{
...(adjustedStyle || style),
minWidth: '320px',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
<div className='text-sm text-white'>
<p className='mb-2 font-semibold'>Confirm Replacement</p>
<p className='mb-1 text-gray-300'>
Replace: <span className='text-yellow-300'>&quot;{selectedText}&quot;</span>
</p>
<p className='mb-1 text-gray-300'>
With: <span className='text-green-300'>&quot;{replacementText}&quot;</span>
</p>
<p className='mb-1 text-gray-300'>
Scope: <span className='text-blue-300'>{getScopeLabel(selectedScope)}</span>
</p>
<p className='text-gray-300'>
Case sensitive: <span className='text-purple-300'>{caseSensitive ? 'Yes' : 'No'}</span>
</p>
</div>
<div className='mt-2 flex gap-2'>
<button
onClick={handleCancelConfirmation}
className='flex-1 rounded-md bg-gray-600 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-500'
>
Back
</button>
<button
onClick={handleConfirm}
className='flex-1 rounded-md bg-green-600 px-3 py-2 text-sm text-white transition-colors hover:bg-green-500'
>
Confirm
</button>
</div>
</div>
);
}
return (
<div
ref={menuRef}
className={clsx(
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
isVertical ? 'flex-col' : 'flex-col',
)}
style={{
...(adjustedStyle || style),
minWidth: '280px',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
{/* Selected text preview */}
<div className='text-xs text-gray-400'>
<span>Selected: </span>
<span className='break-words text-yellow-300'>
&quot;{selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText}
&quot;
</span>
</div>
{/* Replacement text input */}
<div className='flex flex-col gap-1'>
<label htmlFor='replacement-input' className='text-xs text-gray-400'>
Replace with:
</label>
<input
ref={inputRef}
id='replacement-input'
type='text'
value={replacementText}
onChange={(e) => setReplacementText(e.target.value)}
placeholder='Enter replacement text...'
className='w-full rounded-md bg-gray-600 px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
</div>
{/* Case sensitivity checkbox */}
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={caseSensitive}
onChange={(e) => setCaseSensitive(e.target.checked)}
className='h-4 w-4 rounded border-gray-500 bg-gray-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-gray-700'
/>
<span className='text-sm text-white'>Case Sensitive</span>
</label>
{/* Scope buttons */}
<div className='mt-1 flex flex-col gap-1'>
<button
onClick={() => handleScopeClick('once')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix this once
</button>
<button
onClick={() => handleScopeClick('book')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix in this book
</button>
<button
onClick={() => handleScopeClick('library')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix in library
</button>
</div>
{/* Cancel button */}
<button
onClick={handleCancel}
className='mt-2 rounded-md bg-gray-600 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-500'
>
Cancel
</button>
</div>
);
};
export default ReplacementOptions;
@@ -201,7 +201,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
</div>
)}
</div>
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between bg-gray-600 px-4'>
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between px-4'>
<div className='line-clamp-1 text-xs opacity-60'>
{provider &&
!loading &&
@@ -41,6 +41,9 @@ const FooterBar: React.FC<FooterBarProps> = ({
const actionTab = hoveredBookKey === bookKey ? userSelectedTab : '';
const isVisible = hoveredBookKey === bookKey;
const docs = view?.renderer.getContents() ?? [];
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
const progressInfo = useMemo(
() => (bookFormat === 'PDF' ? section : pageinfo),
[bookFormat, section, pageinfo],
@@ -224,7 +227,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
className={clsx(
'absolute bottom-0 left-0 z-10 flex h-[52px] w-full',
needHorizontalScroll && 'sm:!bottom-3 sm:!h-7',
isMobile ? 'pointer-events-none' : '',
isMobile || pointerInDoc ? 'pointer-events-none' : '',
)}
onMouseEnter={() => !isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !isMobile && setHoveredBookKey(bookKey)}
@@ -99,7 +99,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ onSave, onEdit }) => {
const canSave = Boolean(note.trim());
return (
<div className='content note-editor-container bg-base-100 mt-2 rounded-md p-2'>
<div className='content booknote-item note-editor-container bg-base-100 mt-2 rounded-md p-2'>
<div className='flex w-full'>
<TextEditor
ref={editorRef}
@@ -17,6 +17,7 @@ import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { Overlay } from '@/components/Overlay';
import { saveSysSettings } from '@/helpers/settings';
import { NOTE_PREFIX } from '@/types/view';
import useShortcuts from '@/hooks/useShortcuts';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
@@ -118,6 +119,7 @@ const Notebook: React.FC = ({}) => {
createdAt: Date.now(),
updatedAt: Date.now(),
};
view?.addAnnotation({ ...annotation, value: `${NOTE_PREFIX}${annotation.cfi}` });
annotations.push(annotation);
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
if (updatedConfig) {
@@ -128,6 +130,7 @@ const Notebook: React.FC = ({}) => {
const handleEditNote = (note: BookNote, isDelete: boolean) => {
if (!sideBarBookKey) return;
const view = getView(sideBarBookKey);
const config = getConfig(sideBarBookKey)!;
const { booknotes: annotations = [] } = config;
const existingIndex = annotations.findIndex((item) => item.id === note.id);
@@ -138,6 +141,7 @@ const Notebook: React.FC = ({}) => {
note.updatedAt = Date.now();
}
annotations[existingIndex] = note;
view?.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true);
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
@@ -309,7 +313,7 @@ const Notebook: React.FC = ({}) => {
handleEditNote(item, true);
}
}}
className='collapse-arrow border-base-300 bg-base-100 collapse border'
className='booknote-item collapse-arrow border-base-300 bg-base-100 collapse border'
>
<div
className={clsx(
@@ -14,7 +14,7 @@ import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
import { setReplacementRulesWindowVisible } from '@/app/reader/components/ReplacementRulesWindow';
import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import useBooksManager from '../../hooks/useBooksManager';
import MenuItem from '@/components/MenuItem';
@@ -79,8 +79,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const showReplacementRulesWindow = () => {
setReplacementRulesWindowVisible(true);
const showProofreadRulesWindow = () => {
setProofreadRulesVisibility(true);
setIsDropdownOpen?.(false);
};
const handlePullKOSync = () => {
@@ -94,7 +94,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
return (
<Menu
className={clsx('book-menu dropdown-content border-base-100 z-20 shadow-2xl', menuClassName)}
className={clsx('book-menu dropdown-content z-20 shadow-2xl', menuClassName)}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
@@ -145,7 +145,6 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
))}
<hr className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
{false && <MenuItem label={_('Replacement Rules')} onClick={showReplacementRulesWindow} />}
{settings.kosync.enabled && (
<>
<MenuItem label={_('Push Progress')} onClick={handlePushKOSync} />
@@ -153,6 +152,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
</>
)}
<hr className='border-base-200 my-1' />
<MenuItem label={_('Proofread')} onClick={showProofreadRulesWindow} />
<hr className='border-base-200 my-1' />
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
<MenuItem
label={_('Sort TOC by Page')}
@@ -12,6 +12,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { eventDispatcher } from '@/utils/event';
import { NOTE_PREFIX } from '@/types/view';
import useScrollToItem from '../../hooks/useScrollToItem';
import TextButton from '@/components/TextButton';
import TextEditor, { TextEditorRef } from '@/components/TextEditor';
@@ -19,9 +20,10 @@ import TextEditor, { TextEditorRef } from '@/components/TextEditor';
interface BooknoteItemProps {
bookKey: string;
item: BookNote;
onClick?: () => void;
}
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, onClick }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
@@ -42,6 +44,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
event.preventDefault();
eventDispatcher.dispatch('navigate', { bookKey, cfi });
onClick?.();
getView(bookKey)?.goTo(cfi);
if (note) {
setNotebookVisible(true);
@@ -57,7 +60,9 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
if (item.id === note.id) {
item.deletedAt = Date.now();
const views = getViewsById(bookKey.split('-')[0]!);
views.forEach((view) => view?.addAnnotation(item, true));
views.forEach((view) =>
view?.addAnnotation({ ...item, value: `${NOTE_PREFIX}${item.cfi}` }, true),
);
}
});
const updatedConfig = updateBooknotes(bookKey, booknotes);
@@ -128,7 +133,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
role='button'
ref={viewRef}
className={clsx(
'border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
'booknote-item border-base-300 content group relative my-2 cursor-pointer rounded-lg p-2',
isCurrent
? 'bg-base-300/85 hover:bg-base-300 focus:bg-base-300'
: 'hover:bg-base-300/55 focus:bg-base-300/55 bg-base-100',
@@ -172,7 +177,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
<div className={clsx('content font-size-sm line-clamp-3', item.note && 'mt-2')}>
<span
className={clsx(
'inline leading-normal',
'booknote-text inline leading-normal',
item.note && 'content font-size-xs text-gray-500',
(item.style === 'underline' || item.style === 'squiggly') &&
'underline decoration-2',
@@ -1,7 +1,7 @@
import React from 'react';
import * as CFI from 'foliate-js/epubcfi.js';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { findTocItemBS } from '@/utils/toc';
import { TOCItem } from '@/libs/document';
import { BooknoteGroup, BookNoteType } from '@/types/book';
@@ -13,7 +13,9 @@ const BooknoteView: React.FC<{
toc: TOCItem[];
}> = ({ type, bookKey, toc }) => {
const { getConfig } = useBookDataStore();
const { setActiveBooknoteType, setBooknoteResults } = useSidebarStore();
const config = getConfig(bookKey)!;
const { booknotes: allNotes = [] } = config;
const booknotes = allNotes.filter((note) => note.type === type && !note.deletedAt);
@@ -39,6 +41,14 @@ const BooknoteView: React.FC<{
return a.id - b.id;
});
const handleBrowseBookNotes = () => {
if (booknotes.length === 0) return;
const sorted = [...booknotes].sort((a, b) => CFI.compare(a.cfi, b.cfi));
setActiveBooknoteType(bookKey, type);
setBooknoteResults(bookKey, sorted);
};
return (
<div className='rounded pt-2'>
<ul role='tree' className='px-2'>
@@ -47,7 +57,12 @@ const BooknoteView: React.FC<{
<h3 className='content font-size-base line-clamp-1 font-normal'>{group.label}</h3>
<ul>
{group.booknotes.map((item, index) => (
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={bookKey} item={item} />
<BooknoteItem
key={`${index}-${item.cfi}`}
bookKey={bookKey}
item={item}
onClick={handleBrowseBookNotes}
/>
))}
</ul>
</li>
@@ -0,0 +1,67 @@
import React from 'react';
import { Insets } from '@/types/misc';
import { TOCItem } from '@/libs/document';
import { useTranslation } from '@/hooks/useTranslation';
import { useBooknotesNav } from '../../hooks/useBooknotesNav';
import ContentNavBar from './ContentNavBar';
interface BooknotesNavProps {
bookKey: string;
gridInsets: Insets;
toc: TOCItem[];
}
const BooknotesNav: React.FC<BooknotesNavProps> = ({ bookKey, gridInsets, toc }) => {
const {
activeBooknoteType,
currentSection,
showBooknotesNav,
hasPreviousPage,
hasNextPage,
handleShowResults,
handleClose,
handlePrevious,
handleNext,
} = useBooknotesNav(bookKey, toc);
const _ = useTranslation();
if (!showBooknotesNav) {
return null;
}
const getShowResultsTitle = () => {
switch (activeBooknoteType) {
case 'bookmark':
return _('Bookmarks');
case 'annotation':
return _('Annotations');
case 'excerpt':
return _('Excerpts');
default:
return '';
}
};
return (
<ContentNavBar
bookKey={bookKey}
gridInsets={gridInsets}
title={getShowResultsTitle()}
section={currentSection}
hasPrevious={hasPreviousPage}
hasNext={hasNextPage}
progress={1}
previousTitle={_('Previous')}
nextTitle={_('Next')}
showResultsTitle={getShowResultsTitle()}
closeTitle={_('Close')}
onShowResults={handleShowResults}
onClose={handleClose}
onPrevious={handlePrevious}
onNext={handleNext}
/>
);
};
export default BooknotesNav;
@@ -0,0 +1,142 @@
import clsx from 'clsx';
import React from 'react';
import { IoIosList, IoMdCloseCircle } from 'react-icons/io';
import { HiArrowLongLeft, HiArrowLongRight } from 'react-icons/hi2';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useReaderStore } from '@/store/readerStore';
interface ContentNavBarProps {
bookKey: string;
gridInsets: Insets;
title: string;
section?: string;
progress?: number; // 0 to 1, where 1 means complete
showListButton?: boolean;
hasPrevious: boolean;
hasNext: boolean;
previousLabel?: string;
nextLabel?: string;
previousTitle?: string;
nextTitle?: string;
showResultsTitle?: string;
closeTitle?: string;
onShowResults?: () => void;
onClose: () => void;
onPrevious: () => void;
onNext: () => void;
}
const ContentNavBar: React.FC<ContentNavBarProps> = ({
bookKey,
gridInsets,
title,
section,
progress,
showListButton = true,
hasPrevious,
hasNext,
previousLabel,
nextLabel,
previousTitle,
nextTitle,
showResultsTitle,
closeTitle,
onShowResults,
onClose,
onPrevious,
onNext,
}) => {
const { appService } = useEnv();
const _ = useTranslation();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const iconSize16 = useResponsiveSize(16);
const iconSize18 = useResponsiveSize(18);
const iconSize20 = useResponsiveSize(20);
const showSection = appService?.isMobile || !viewSettings?.showHeader;
return (
<div
className='results-nav-bar pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-between px-4 py-1'
style={{
top: gridInsets.top,
right: gridInsets.right,
bottom: gridInsets.bottom / 4,
left: gridInsets.left,
}}
>
{/* Top bar: Info */}
<div className='bg-base-100 pointer-events-auto relative flex items-center justify-between overflow-hidden rounded-xl px-2 py-1 shadow-lg sm:gap-6'>
{progress !== undefined && progress < 1 && (
<div
className='bg-base-200 absolute inset-y-0 left-0 transition-all duration-300'
style={{ width: `${progress * 100}%` }}
/>
)}
{progress === 1 && <div className='bg-base-200 absolute inset-0' />}
{showListButton && onShowResults ? (
<button
title={showResultsTitle || _('Show Results')}
onClick={onShowResults}
className='btn btn-ghost relative z-10 h-8 min-h-8 w-8 p-0 hover:bg-transparent'
>
<IoIosList size={iconSize20} className='text-base-content' />
</button>
) : (
<div className='relative z-10 w-8' />
)}
<div className='relative z-10 flex flex-1 flex-col items-center px-2'>
<span className='line-clamp-1 text-sm font-medium'>{title}</span>
{section && showSection && (
<span className='text-base-content/70 line-clamp-1 text-xs'>{section}</span>
)}
</div>
<button
title={closeTitle || _('Close')}
onClick={onClose}
className='btn btn-ghost relative z-10 h-8 min-h-8 w-8 p-0 hover:bg-transparent'
>
<IoMdCloseCircle size={iconSize16} />
</button>
</div>
{/* Bottom bar: Navigation buttons */}
<div className='bg-base-200 pointer-events-auto flex items-center justify-between gap-6 rounded-xl px-4 py-0 shadow-lg'>
<button
title={previousTitle || _('Previous')}
onClick={onPrevious}
disabled={!hasPrevious}
className={clsx(
'btn btn-ghost flex h-auto min-h-0 flex-1 flex-col items-center gap-0 p-1 hover:bg-transparent',
!hasPrevious && 'opacity-40 disabled:bg-transparent',
)}
>
<HiArrowLongLeft size={iconSize18} className='text-base-content' />
<span className='text-sm font-medium'>{previousLabel || _('Previous')}</span>
</button>
<button
title={nextTitle || _('Next')}
onClick={onNext}
disabled={!hasNext}
className={clsx(
'btn btn-ghost flex h-auto min-h-0 flex-1 flex-col items-center gap-0 p-1 hover:bg-transparent',
!hasNext && 'opacity-40 disabled:bg-transparent',
)}
>
<HiArrowLongRight size={iconSize18} className='text-base-content' />
<span className='text-sm font-medium'>{nextLabel || _('Next')}</span>
</button>
</div>
</div>
);
};
export default ContentNavBar;
@@ -65,7 +65,7 @@ const SidebarHeader: React.FC<{
window.innerWidth < 640 && 'dropdown-end',
'dropdown-bottom flex justify-center',
)}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-3'}
menuClassName={window.innerWidth < 640 ? 'no-triangle mt-1' : 'dropdown-center mt-1'}
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu className='fill-base-content' />}
>

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