forked from akai/readest
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f53cee9616 | |||
| 30385ee5ec | |||
| b30dfb3e23 | |||
| c0d6102857 | |||
| 4537c55e84 | |||
| aff94c0ab8 | |||
| fd70836308 | |||
| 1b0c94b9a5 | |||
| 7cb523eefc | |||
| 941be80cc4 | |||
| 5eecc735aa | |||
| cfe51d01ee | |||
| 20ae09c52d | |||
| b868146129 | |||
| a312080f7c | |||
| 462ca46fee | |||
| 93228c4b2a | |||
| 2ff10f781f | |||
| 9614c62360 | |||
| 5ffaac5e67 | |||
| 71af91608f | |||
| 9603b61776 | |||
| eed84a059a | |||
| 604ef65719 | |||
| 483d536ca4 | |||
| 69a51c5880 | |||
| ca8d25341e | |||
| 8a263235ed | |||
| fb41ff5d0c | |||
| a5e09e8454 | |||
| 730ee21651 | |||
| dea43445c3 | |||
| 4f0ae78d43 | |||
| d9116d619a | |||
| c080e6fdd3 | |||
| d3752dadc6 | |||
| e21ef53a3d | |||
| 71e97998b6 | |||
| 22f9c45232 | |||
| a42897ec5c | |||
| 58a5c1625c | |||
| d53fc09e1e | |||
| 11fecb5dc0 | |||
| f4587663b5 | |||
| b76a2adf61 | |||
| 988fbc8c85 | |||
| f944ad9b9f | |||
| 335d91b9c9 | |||
| 173404eaad | |||
| 674fed5230 | |||
| 12d284cd22 | |||
| 77037c8adb | |||
| 6984393ed1 | |||
| 3abef6ea75 | |||
| 4ae1ab7dd0 | |||
| 69fb22119b | |||
| 3a6c18c6d5 | |||
| 7db1bc460d | |||
| a460e609fa | |||
| 4b4ebdbdaa | |||
| 9358a06839 | |||
| a7baf6cc9f | |||
| 15dc669f35 |
@@ -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({
|
||||
|
||||
@@ -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 l’action rapide",
|
||||
"Enable Quick Action on Selection": "Activer l’action rapide lors de la sélection",
|
||||
"None": "Aucune",
|
||||
"Annotation Tools": "Outils d’annotation",
|
||||
"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": "點擊切換頁尾"
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+43
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+16
-44
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:')} <span className='font-medium'>Single Instance</span>
|
||||
| {_('Case sensitive:')}
|
||||
<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:')}
|
||||
<span className='font-medium'>
|
||||
{getRuleScope(r) === 'book' ? _('Book') : _('Global')}
|
||||
</span>
|
||||
| {_('Case sensitive:')}
|
||||
<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;
|
||||
+7
-5
@@ -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'>
|
||||
"{selection?.text || ''}"
|
||||
</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'>"{selectedText}"</span>
|
||||
</p>
|
||||
<p className='mb-1 text-gray-300'>
|
||||
With: <span className='text-green-300'>"{replacementText}"</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'>
|
||||
"{selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText}
|
||||
"
|
||||
</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
Reference in New Issue
Block a user