forked from akai/readest
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c853957512 | |||
| 8a4e22e423 | |||
| 8a43c58fd4 | |||
| 54fdf5f1fd | |||
| fe50b513b3 | |||
| 17c7fa8f41 | |||
| 2533560d11 | |||
| 5850a16afd | |||
| 7063d62b13 | |||
| 0bd6a217ae | |||
| b7df294d78 | |||
| 5aa78f2554 | |||
| e740571c33 | |||
| e6d9913f4e | |||
| 1869a863a3 | |||
| 524de92f5e | |||
| c1530cc5c4 | |||
| 730fadb834 | |||
| 383e5c61b1 | |||
| 6d42086fa7 | |||
| 5a20fae204 | |||
| 34fd64c5c4 | |||
| 0874fb0764 | |||
| e03ed5b604 | |||
| 41edc89ac7 | |||
| f0a470398d | |||
| 51008d81fb | |||
| 9828904674 | |||
| 2670d835b3 | |||
| 8b7bafc4b6 | |||
| ca759e0246 | |||
| 5141be1c3f | |||
| 669d3950e2 | |||
| b95895cecf | |||
| 80e11bb0ce | |||
| de3a539621 | |||
| b425bfdc89 | |||
| 1d1fbdffdb | |||
| 50cd7f80c6 |
@@ -55,7 +55,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: cache Next.js build
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/readest-app/.next/cache
|
||||
key: nextjs-${{ matrix.config.platform }}-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
|
||||
const notes = process.env.release_note.split(/\d+\.\s/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
@@ -95,10 +95,12 @@ jobs:
|
||||
run: |
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
plugin_zip="Readest-${version}-1.koplugin.zip"
|
||||
meta_file="apps/readest.koplugin/_meta.lua"
|
||||
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
|
||||
|
||||
cd apps/readest.koplugin
|
||||
zip -r ../../${plugin_zip} .
|
||||
cd ../..
|
||||
cd apps
|
||||
zip -r ../${plugin_zip} readest.koplugin
|
||||
cd ..
|
||||
|
||||
echo "Uploading ${plugin_zip} to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
|
||||
|
||||
@@ -43,3 +43,5 @@ fastlane/report.xml
|
||||
|
||||
*.koplugin.zip
|
||||
|
||||
# nix
|
||||
result*
|
||||
|
||||
Generated
+256
-175
File diff suppressed because it is too large
Load Diff
@@ -64,39 +64,93 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const withPWA = withPWAInit({
|
||||
dest: 'public',
|
||||
disable: isDev || appPlatform !== 'web',
|
||||
cacheStartUrl: false,
|
||||
dynamicStartUrl: false,
|
||||
cacheOnFrontEndNav: true,
|
||||
aggressiveFrontEndNavCaching: true,
|
||||
reloadOnOnline: true,
|
||||
swcMinify: true,
|
||||
fallbacks: {
|
||||
document: '/offline',
|
||||
},
|
||||
workboxOptions: {
|
||||
disableDevLogs: 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 };
|
||||
const pwaDisabled = isDev || appPlatform !== 'web';
|
||||
|
||||
const withPWA = pwaDisabled
|
||||
? (config) => config
|
||||
: withPWAInit({
|
||||
dest: 'public',
|
||||
cacheStartUrl: false,
|
||||
dynamicStartUrl: false,
|
||||
cacheOnFrontEndNav: true,
|
||||
aggressiveFrontEndNavCaching: 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 };
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const withAnalyzer = withBundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.95",
|
||||
"version": "0.9.96",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
@@ -37,7 +37,7 @@
|
||||
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
|
||||
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
|
||||
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
|
||||
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0",
|
||||
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0 --port 3001",
|
||||
"deploy": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare deploy",
|
||||
"upload": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare upload",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.76.1",
|
||||
"@tauri-apps/api": "2.9.0",
|
||||
"@tauri-apps/api": "2.9.1",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.5",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
@@ -74,6 +74,7 @@
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "~2.3.3",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-websocket": "~2.4.1",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"app-store-server-api": "^0.17.1",
|
||||
@@ -92,10 +93,11 @@
|
||||
"i18next-http-backend": "^3.0.1",
|
||||
"iso-639-2": "^3.0.2",
|
||||
"iso-639-3": "^3.0.1",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"next": "16.0.7",
|
||||
"next": "16.0.10",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"posthog-js": "^1.246.0",
|
||||
@@ -105,19 +107,21 @@
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"react-window": "^1.8.11",
|
||||
"semver": "^7.7.1",
|
||||
"stripe": "^18.2.1",
|
||||
"styled-jsx": "^5.1.7",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.8",
|
||||
"zustand": "5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^15.4.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.9.4",
|
||||
"@tauri-apps/cli": "2.9.6",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -130,6 +134,7 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
|
||||
@@ -715,9 +715,7 @@
|
||||
"Validating...": "جارٍ التحقق...",
|
||||
"View All": "عرض الكل",
|
||||
"Forward": "إلى الأمام",
|
||||
"OPDS Catalog": "كتالوج OPDS",
|
||||
"Home": "الصفحة الرئيسية",
|
||||
"Library": "المكتبة",
|
||||
"{{count}} items_zero": "{{count}} عناصر",
|
||||
"{{count}} items_one": "{{count}} عنصر",
|
||||
"{{count}} items_two": "{{count}} عنصران",
|
||||
@@ -803,5 +801,9 @@
|
||||
"Successfully imported {{count}} book(s)_two": "تم استيراد كتابين",
|
||||
"Successfully imported {{count}} book(s)_few": "تم استيراد {{count}} كتب",
|
||||
"Successfully imported {{count}} book(s)_many": "تم استيراد {{count}} كتابًا",
|
||||
"Successfully imported {{count}} book(s)_other": "تم استيراد {{count}} كتاب"
|
||||
"Successfully imported {{count}} book(s)_other": "تم استيراد {{count}} كتاب",
|
||||
"Count": "العدد",
|
||||
"Start Page": "الصفحة الأولى",
|
||||
"Search in OPDS Catalog...": "البحث في كتالوج OPDS...",
|
||||
"Please log in to use advanced TTS features.": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة."
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "যাচাই করা হচ্ছে...",
|
||||
"View All": "সব দেখুন",
|
||||
"Forward": "ফরোয়ার্ড",
|
||||
"OPDS Catalog": "OPDS ক্যাটালগ",
|
||||
"Home": "হোম",
|
||||
"Library": "লাইব্রেরি",
|
||||
"{{count}} items_one": "{{count}} আইটেম",
|
||||
"{{count}} items_other": "{{count}} আইটেম",
|
||||
"Download completed": "ডাউনলোড সম্পন্ন হয়েছে",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "গ্রুপের নাম পরিবর্তন করুন",
|
||||
"From Directory": "ডিরেক্টরি থেকে",
|
||||
"Successfully imported {{count}} book(s)_one": "সফলভাবে ১টি বই আমদানি করা হয়েছে",
|
||||
"Successfully imported {{count}} book(s)_other": "সফলভাবে {{count}}টি বই আমদানি করা হয়েছে"
|
||||
"Successfully imported {{count}} book(s)_other": "সফলভাবে {{count}}টি বই আমদানি করা হয়েছে",
|
||||
"Count": "গণনা",
|
||||
"Start Page": "শুরু পৃষ্ঠা",
|
||||
"Search in OPDS Catalog...": "OPDS ক্যাটালগে অনুসন্ধান করুন...",
|
||||
"Please log in to use advanced TTS features.": "উন্নত TTS বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।"
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "བདེན་སྦྱོར་བཞིན་...",
|
||||
"View All": "ཡོངས་ལྟ་བ་",
|
||||
"Forward": "མདུན་དུ་",
|
||||
"OPDS Catalog": "OPDS དཀར་ཆག",
|
||||
"Home": "གཙོ་ངོས་",
|
||||
"Library": "དེབ་མཛོད་",
|
||||
"{{count}} items_other": "{{count}} རྣམ་གྲངས་",
|
||||
"Download completed": "ཕབ་ལེན་རྫོགས་སོང་།",
|
||||
"Download failed": "ཕབ་ལེན་ཕམ་པ་",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "སྤྲིན་གནས་སྣོད་གསོག་ལུས་སྐོར།",
|
||||
"Rename Group": "ཚོགས་མིང་བསྒྱུར་བ།",
|
||||
"From Directory": "སྐོར་འདེམས་པ་ནས།",
|
||||
"Successfully imported {{count}} book(s)_other": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་ {{count}} དེབ་འདེམས་སོང་"
|
||||
"Successfully imported {{count}} book(s)_other": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་ {{count}} དེབ་འདེམས་སོང་",
|
||||
"Count": "ཨང་",
|
||||
"Start Page": "ཤོག་ངོས་དང་པོ",
|
||||
"Search in OPDS Catalog...": "OPDS དཀར་ཆག་ནང་འཚོལ།...",
|
||||
"Please log in to use advanced TTS features.": "དབང་བསྐྱོད་ཀྱི TTS རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།"
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "Wird überprüft...",
|
||||
"View All": "Alle anzeigen",
|
||||
"Forward": "Weiter",
|
||||
"OPDS Catalog": "OPDS-Katalog",
|
||||
"Home": "Start",
|
||||
"Library": "Bibliothek",
|
||||
"{{count}} items_one": "{{count}} Element",
|
||||
"{{count}} items_other": "{{count}} Elemente",
|
||||
"Download completed": "Download abgeschlossen",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "Gruppe umbenennen",
|
||||
"From Directory": "Aus Verzeichnis",
|
||||
"Successfully imported {{count}} book(s)_one": "Erfolgreich 1 Buch importiert",
|
||||
"Successfully imported {{count}} book(s)_other": "Erfolgreich {{count}} Bücher importiert"
|
||||
"Successfully imported {{count}} book(s)_other": "Erfolgreich {{count}} Bücher importiert",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "Γίνεται έλεγχος...",
|
||||
"View All": "Προβολή όλων",
|
||||
"Forward": "Μπροστά",
|
||||
"OPDS Catalog": "Κατάλογος OPDS",
|
||||
"Home": "Αρχική",
|
||||
"Library": "Βιβλιοθήκη",
|
||||
"{{count}} items_one": "{{count}} στοιχείο",
|
||||
"{{count}} items_other": "{{count}} στοιχεία",
|
||||
"Download completed": "Η λήψη ολοκληρώθηκε",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "Μετονομασία ομάδας",
|
||||
"From Directory": "Από κατάλογο",
|
||||
"Successfully imported {{count}} book(s)_one": "Επιτυχής εισαγωγή 1 βιβλίου",
|
||||
"Successfully imported {{count}} book(s)_other": "Επιτυχής εισαγωγή {{count}} βιβλίων"
|
||||
"Successfully imported {{count}} book(s)_other": "Επιτυχής εισαγωγή {{count}} βιβλίων",
|
||||
"Count": "Πλήθος",
|
||||
"Start Page": "Αρχική Σελίδα",
|
||||
"Search in OPDS Catalog...": "Αναζήτηση στον κατάλογο OPDS...",
|
||||
"Please log in to use advanced TTS features.": "Παρακαλώ συνδεθείτε για να χρησιμοποιήσετε προηγμένες λειτουργίες TTS."
|
||||
}
|
||||
|
||||
@@ -703,9 +703,7 @@
|
||||
"Validating...": "Validando...",
|
||||
"View All": "Ver todo",
|
||||
"Forward": "Adelante",
|
||||
"OPDS Catalog": "Catálogo OPDS",
|
||||
"Home": "Inicio",
|
||||
"Library": "Biblioteca",
|
||||
"{{count}} items_one": "{{count}} elemento",
|
||||
"{{count}} items_many": "{{count}} elementos",
|
||||
"{{count}} items_other": "{{count}} elementos",
|
||||
@@ -773,5 +771,9 @@
|
||||
"From Directory": "Desde el directorio",
|
||||
"Successfully imported {{count}} book(s)_one": "Se importó correctamente 1 libro",
|
||||
"Successfully imported {{count}} book(s)_many": "Se importaron correctamente {{count}} libros",
|
||||
"Successfully imported {{count}} book(s)_other": "Se importaron correctamente {{count}} libros"
|
||||
"Successfully imported {{count}} book(s)_other": "Se importaron correctamente {{count}} libros",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "در حال بررسی...",
|
||||
"View All": "مشاهده همه",
|
||||
"Forward": "بعدی",
|
||||
"OPDS Catalog": "فهرست OPDS",
|
||||
"Home": "خانه",
|
||||
"Library": "کتابخانه",
|
||||
"{{count}} items_one": "{{count}} مورد",
|
||||
"{{count}} items_other": "{{count}} مورد",
|
||||
"Download completed": "دانلود کامل شد",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "تغییر نام گروه",
|
||||
"From Directory": "از مسیر",
|
||||
"Successfully imported {{count}} book(s)_one": "با موفقیت 1 کتاب وارد شد",
|
||||
"Successfully imported {{count}} book(s)_other": "با موفقیت {{count}} کتاب وارد شد"
|
||||
"Successfully imported {{count}} book(s)_other": "با موفقیت {{count}} کتاب وارد شد",
|
||||
"Count": "تعداد",
|
||||
"Start Page": "صفحه شروع",
|
||||
"Search in OPDS Catalog...": "جستجو در فهرست OPDS...",
|
||||
"Please log in to use advanced TTS features.": "لطفاً برای استفاده از ویژگیهای پیشرفته TTS وارد شوید."
|
||||
}
|
||||
|
||||
@@ -703,9 +703,7 @@
|
||||
"Validating...": "Validation...",
|
||||
"View All": "Tout afficher",
|
||||
"Forward": "Suivant",
|
||||
"OPDS Catalog": "Catalogue OPDS",
|
||||
"Home": "Accueil",
|
||||
"Library": "Bibliothèque",
|
||||
"{{count}} items_one": "{{count}} élément",
|
||||
"{{count}} items_many": "{{count}} éléments",
|
||||
"{{count}} items_other": "{{count}} éléments",
|
||||
@@ -773,5 +771,9 @@
|
||||
"From Directory": "Depuis le répertoire",
|
||||
"Successfully imported {{count}} book(s)_one": "Importation réussie de 1 livre",
|
||||
"Successfully imported {{count}} book(s)_many": "Importation réussie de {{count}} livres",
|
||||
"Successfully imported {{count}} book(s)_other": "Importation réussie de {{count}} livres"
|
||||
"Successfully imported {{count}} book(s)_other": "Importation réussie de {{count}} livres",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "मान्य किया जा रहा है...",
|
||||
"View All": "सभी देखें",
|
||||
"Forward": "आगे",
|
||||
"OPDS Catalog": "OPDS कैटलॉग",
|
||||
"Home": "होम",
|
||||
"Library": "लाइब्रेरी",
|
||||
"{{count}} items_one": "{{count}} आइटम",
|
||||
"{{count}} items_other": "{{count}} आइटम",
|
||||
"Download completed": "डाउनलोड पूरा हुआ",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "समूह का नाम बदलें",
|
||||
"From Directory": "निर्देशिका से",
|
||||
"Successfully imported {{count}} book(s)_one": "सफलतापूर्वक 1 पुस्तक आयात की गई",
|
||||
"Successfully imported {{count}} book(s)_other": "सफलतापूर्वक {{count}} पुस्तकों का आयात किया गया"
|
||||
"Successfully imported {{count}} book(s)_other": "सफलतापूर्वक {{count}} पुस्तकों का आयात किया गया",
|
||||
"Count": "गणना",
|
||||
"Start Page": "प्रारंभ पृष्ठ",
|
||||
"Search in OPDS Catalog...": "OPDS कैटलॉग में खोजें...",
|
||||
"Please log in to use advanced TTS features.": "उन्नत TTS सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।"
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "Memvalidasi...",
|
||||
"View All": "Lihat Semua",
|
||||
"Forward": "Maju",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Beranda",
|
||||
"Library": "Perpustakaan",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
"Download completed": "Unduhan selesai",
|
||||
"Download failed": "Unduhan gagal",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "Penggunaan Penyimpanan Cloud",
|
||||
"Rename Group": "Ganti Nama Grup",
|
||||
"From Directory": "Dari Direktori",
|
||||
"Successfully imported {{count}} book(s)_other": "Berhasil mengimpor {{count}} buku"
|
||||
"Successfully imported {{count}} book(s)_other": "Berhasil mengimpor {{count}} buku",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -703,9 +703,7 @@
|
||||
"Validating...": "Convalida in corso...",
|
||||
"View All": "Vedi tutto",
|
||||
"Forward": "Avanti",
|
||||
"OPDS Catalog": "Catalogo OPDS",
|
||||
"Home": "Home",
|
||||
"Library": "Libreria",
|
||||
"{{count}} items_one": "{{count}} elemento",
|
||||
"{{count}} items_many": "{{count}} elementi",
|
||||
"{{count}} items_other": "{{count}} elementi",
|
||||
@@ -773,5 +771,9 @@
|
||||
"From Directory": "Da directory",
|
||||
"Successfully imported {{count}} book(s)_one": "Importato con successo 1 libro",
|
||||
"Successfully imported {{count}} book(s)_many": "Importati con successo {{count}} libri",
|
||||
"Successfully imported {{count}} book(s)_other": "Importati con successo {{count}} libri"
|
||||
"Successfully imported {{count}} book(s)_other": "Importati con successo {{count}} libri",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "検証中...",
|
||||
"View All": "すべて表示",
|
||||
"Forward": "進む",
|
||||
"OPDS Catalog": "OPDSカタログ",
|
||||
"Home": "ホーム",
|
||||
"Library": "ライブラリ",
|
||||
"{{count}} items_other": "{{count}} 件",
|
||||
"Download completed": "ダウンロード完了",
|
||||
"Download failed": "ダウンロード失敗",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "クラウドストレージ使用量",
|
||||
"Rename Group": "グループ名を変更",
|
||||
"From Directory": "ディレクトリから",
|
||||
"Successfully imported {{count}} book(s)_other": "成功裏に{{count}}冊の本をインポートしました"
|
||||
"Successfully imported {{count}} book(s)_other": "成功裏に{{count}}冊の本をインポートしました",
|
||||
"Count": "件数",
|
||||
"Start Page": "開始ページ",
|
||||
"Search in OPDS Catalog...": "OPDSカタログ内を検索...",
|
||||
"Please log in to use advanced TTS features.": "高度なTTS機能を使用するにはログインしてください。"
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "검증 중...",
|
||||
"View All": "모두 보기",
|
||||
"Forward": "다음",
|
||||
"OPDS Catalog": "OPDS 카탈로그",
|
||||
"Home": "홈",
|
||||
"Library": "라이브러리",
|
||||
"{{count}} items_other": "{{count}}개 항목",
|
||||
"Download completed": "다운로드 완료",
|
||||
"Download failed": "다운로드 실패",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "클라우드 저장소 사용량",
|
||||
"Rename Group": "그룹 이름 바꾸기",
|
||||
"From Directory": "디렉토리에서",
|
||||
"Successfully imported {{count}} book(s)_other": "성공적으로 {{count}} 권의 책이 가져와졌습니다"
|
||||
"Successfully imported {{count}} book(s)_other": "성공적으로 {{count}} 권의 책이 가져와졌습니다",
|
||||
"Count": "개수",
|
||||
"Start Page": "시작 페이지",
|
||||
"Search in OPDS Catalog...": "OPDS 카탈로그에서 검색...",
|
||||
"Please log in to use advanced TTS features.": "고급 TTS 기능을 사용하려면 로그인하세요."
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "Mengesahkan...",
|
||||
"View All": "Lihat Semua",
|
||||
"Forward": "Maju",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Laman Utama",
|
||||
"Library": "Perpustakaan",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
"Download completed": "Muat turun selesai",
|
||||
"Download failed": "Muat turun gagal",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "Penggunaan Storan Awan",
|
||||
"Rename Group": "Namakan Semula Kumpulan",
|
||||
"From Directory": "Dari Direktori",
|
||||
"Successfully imported {{count}} book(s)_other": "Berjaya mengimport {{count}} buku"
|
||||
"Successfully imported {{count}} book(s)_other": "Berjaya mengimport {{count}} buku",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "Valideren...",
|
||||
"View All": "Alles bekijken",
|
||||
"Forward": "Verder",
|
||||
"OPDS Catalog": "OPDS-catalogus",
|
||||
"Home": "Startpagina",
|
||||
"Library": "Bibliotheek",
|
||||
"{{count}} items_one": "{{count}} item",
|
||||
"{{count}} items_other": "{{count}} items",
|
||||
"Download completed": "Download voltooid",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "Groep hernoemen",
|
||||
"From Directory": "Vanuit map",
|
||||
"Successfully imported {{count}} book(s)_one": "Succesvol 1 boek geïmporteerd",
|
||||
"Successfully imported {{count}} book(s)_other": "Succesvol {{count}} boeken geïmporteerd"
|
||||
"Successfully imported {{count}} book(s)_other": "Succesvol {{count}} boeken geïmporteerd",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -707,9 +707,7 @@
|
||||
"Validating...": "Weryfikacja...",
|
||||
"View All": "Pokaż wszystkie",
|
||||
"Forward": "Dalej",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Strona główna",
|
||||
"Library": "Biblioteka",
|
||||
"{{count}} items_one": "{{count}} element",
|
||||
"{{count}} items_few": "{{count}} elementy",
|
||||
"{{count}} items_many": "{{count}} elementów",
|
||||
@@ -783,5 +781,9 @@
|
||||
"Successfully imported {{count}} book(s)_one": "Pomyślnie zaimportowano 1 książkę",
|
||||
"Successfully imported {{count}} book(s)_few": "Pomyślnie zaimportowano {{count}} książki",
|
||||
"Successfully imported {{count}} book(s)_many": "Pomyślnie zaimportowano {{count}} książek",
|
||||
"Successfully imported {{count}} book(s)_other": "Pomyślnie zaimportowano {{count}} książek"
|
||||
"Successfully imported {{count}} book(s)_other": "Pomyślnie zaimportowano {{count}} książek",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -703,9 +703,7 @@
|
||||
"Validating...": "Validando...",
|
||||
"View All": "Ver todos",
|
||||
"Forward": "Avançar",
|
||||
"OPDS Catalog": "Catálogo OPDS",
|
||||
"Home": "Início",
|
||||
"Library": "Biblioteca",
|
||||
"{{count}} items_one": "{{count}} item",
|
||||
"{{count}} items_many": "{{count}} itens",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
@@ -773,5 +771,9 @@
|
||||
"From Directory": "Do Diretório",
|
||||
"Successfully imported {{count}} book(s)_one": "Importado com sucesso 1 livro",
|
||||
"Successfully imported {{count}} book(s)_many": "Importados com sucesso {{count}} livros",
|
||||
"Successfully imported {{count}} book(s)_other": "Importados com sucesso {{count}} livros"
|
||||
"Successfully imported {{count}} book(s)_other": "Importados com sucesso {{count}} livros",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -707,9 +707,7 @@
|
||||
"Validating...": "Проверка...",
|
||||
"View All": "Посмотреть все",
|
||||
"Forward": "Вперёд",
|
||||
"OPDS Catalog": "Каталог OPDS",
|
||||
"Home": "Главная",
|
||||
"Library": "Библиотека",
|
||||
"{{count}} items_one": "{{count}} элемент",
|
||||
"{{count}} items_few": "{{count}} элемента",
|
||||
"{{count}} items_many": "{{count}} элементов",
|
||||
@@ -783,5 +781,9 @@
|
||||
"Successfully imported {{count}} book(s)_one": "Успешно импортирован 1 книга",
|
||||
"Successfully imported {{count}} book(s)_few": "Успешно импортировано {{count}} книги",
|
||||
"Successfully imported {{count}} book(s)_many": "Успешно импортировано {{count}} книг",
|
||||
"Successfully imported {{count}} book(s)_other": "Успешно импортировано {{count}} книг"
|
||||
"Successfully imported {{count}} book(s)_other": "Успешно импортировано {{count}} книг",
|
||||
"Count": "Количество",
|
||||
"Start Page": "Начальная страница",
|
||||
"Search in OPDS Catalog...": "Поиск в каталоге OPDS...",
|
||||
"Please log in to use advanced TTS features.": "Пожалуйста, войдите в систему, чтобы использовать расширенные функции TTS."
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "තහවුරු කරමින්...",
|
||||
"View All": "සියල්ල බලන්න",
|
||||
"Forward": "ඉදිරියට",
|
||||
"OPDS Catalog": "OPDS දත්තසමුදා",
|
||||
"Home": "මුල් පිටුව",
|
||||
"Library": "පුස්තකාලය",
|
||||
"{{count}} items_one": "{{count}} අයිතමය",
|
||||
"{{count}} items_other": "{{count}} අයිතම",
|
||||
"Download completed": "බාගත කිරීම සම්පූර්ණයි",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "කණ්ඩායම නැවත නම් කරන්න",
|
||||
"From Directory": "ෆෝල්ඩරයෙන්",
|
||||
"Successfully imported {{count}} book(s)_one": "සාර්ථකව ආයාත කළ 1 පොත",
|
||||
"Successfully imported {{count}} book(s)_other": "සාර්ථකව ආයාත කළ {{count}} පොත්"
|
||||
"Successfully imported {{count}} book(s)_other": "සාර්ථකව ආයාත කළ {{count}} පොත්",
|
||||
"Count": "ගණන",
|
||||
"Start Page": "ආරම්භක පිටුව",
|
||||
"Search in OPDS Catalog...": "OPDS දත්තසමුදා තුළ සෙවීම...",
|
||||
"Please log in to use advanced TTS features.": "උසස් TTS විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න."
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "Verifierar...",
|
||||
"View All": "Visa alla",
|
||||
"Forward": "Framåt",
|
||||
"OPDS Catalog": "OPDS-katalog",
|
||||
"Home": "Start",
|
||||
"Library": "Bibliotek",
|
||||
"{{count}} items_one": "{{count}} objekt",
|
||||
"{{count}} items_other": "{{count}} objekt",
|
||||
"Download completed": "Nedladdning klar",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "Byt namn på grupp",
|
||||
"From Directory": "Från katalog",
|
||||
"Successfully imported {{count}} book(s)_one": "Importerat 1 bok",
|
||||
"Successfully imported {{count}} book(s)_other": "Importerat {{count}} böcker"
|
||||
"Successfully imported {{count}} book(s)_other": "Importerat {{count}} böcker",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "சரிபார்க்கப்படுகிறது...",
|
||||
"View All": "அனைத்தையும் பார்க்கவும்",
|
||||
"Forward": "முன்னேற்று",
|
||||
"OPDS Catalog": "OPDS பட்டியல்",
|
||||
"Home": "முகப்பு",
|
||||
"Library": "நூலகம்",
|
||||
"{{count}} items_one": "{{count}} பொருள்",
|
||||
"{{count}} items_other": "{{count}} பொருட்கள்",
|
||||
"Download completed": "பதிவிறக்கம் முடிந்தது",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "குழுவை மறுபெயரிடவும்",
|
||||
"From Directory": "கோப்புறையிலிருந்து",
|
||||
"Successfully imported {{count}} book(s)_one": "வெற்றிகரமாக 1 புத்தகம் இறக்குமதி செய்யப்பட்டது",
|
||||
"Successfully imported {{count}} book(s)_other": "வெற்றிகரமாக {{count}} புத்தகங்கள் இறக்குமதி செய்யப்பட்டது"
|
||||
"Successfully imported {{count}} book(s)_other": "வெற்றிகரமாக {{count}} புத்தகங்கள் இறக்குமதி செய்யப்பட்டது",
|
||||
"Count": "எண்ணிக்கை",
|
||||
"Start Page": "தொடக்கப் பக்கம்",
|
||||
"Search in OPDS Catalog...": "OPDS பட்டியலில் தேடவும்...",
|
||||
"Please log in to use advanced TTS features.": "மேம்பட்ட TTS அம்சங்களை பயன்படுத்த உள்நுழையவும்."
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "กำลังตรวจสอบ...",
|
||||
"View All": "ดูทั้งหมด",
|
||||
"Forward": "ไปข้างหน้า",
|
||||
"OPDS Catalog": "แคตตาล็อก OPDS",
|
||||
"Home": "หน้าแรก",
|
||||
"Library": "ห้องสมุด",
|
||||
"{{count}} items_other": "{{count}} รายการ",
|
||||
"Download completed": "ดาวน์โหลดเสร็จสิ้น",
|
||||
"Download failed": "ดาวน์โหลดล้มเหลว",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "การใช้งานพื้นที่เก็บข้อมูลคลาวด์",
|
||||
"Rename Group": "เปลี่ยนชื่อกลุ่ม",
|
||||
"From Directory": "จากไดเรกทอรี",
|
||||
"Successfully imported {{count}} book(s)_other": "นำเข้า {{count}} หนังสือเรียบร้อยแล้ว"
|
||||
"Successfully imported {{count}} book(s)_other": "นำเข้า {{count}} หนังสือเรียบร้อยแล้ว",
|
||||
"Count": "นับ",
|
||||
"Start Page": "หน้าเริ่มต้น",
|
||||
"Search in OPDS Catalog...": "ค้นหาในแคตตาล็อก OPDS...",
|
||||
"Please log in to use advanced TTS features.": "กรุณาเข้าสู่ระบบเพื่อใช้ฟีเจอร์ TTS ขั้นสูง"
|
||||
}
|
||||
|
||||
@@ -699,9 +699,7 @@
|
||||
"Validating...": "Doğrulanıyor...",
|
||||
"View All": "Tümünü Görüntüle",
|
||||
"Forward": "İleri",
|
||||
"OPDS Catalog": "OPDS Kataloğu",
|
||||
"Home": "Ana Sayfa",
|
||||
"Library": "Kütüphane",
|
||||
"{{count}} items_one": "{{count}} öğe",
|
||||
"{{count}} items_other": "{{count}} öğe",
|
||||
"Download completed": "İndirme tamamlandı",
|
||||
@@ -763,5 +761,9 @@
|
||||
"Rename Group": "Grubu Yeniden Adlandır",
|
||||
"From Directory": "Dizinden",
|
||||
"Successfully imported {{count}} book(s)_one": "Başarıyla 1 kitap içe aktarıldı",
|
||||
"Successfully imported {{count}} book(s)_other": "Başarıyla {{count}} kitap içe aktarıldı"
|
||||
"Successfully imported {{count}} book(s)_other": "Başarıyla {{count}} kitap içe aktarıldı",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -707,9 +707,7 @@
|
||||
"Validating...": "Перевірка...",
|
||||
"View All": "Переглянути все",
|
||||
"Forward": "Вперед",
|
||||
"OPDS Catalog": "Каталог OPDS",
|
||||
"Home": "Головна",
|
||||
"Library": "Бібліотека",
|
||||
"{{count}} items_one": "{{count}} елемент",
|
||||
"{{count}} items_few": "{{count}} елементи",
|
||||
"{{count}} items_many": "{{count}} елементів",
|
||||
@@ -783,5 +781,9 @@
|
||||
"Successfully imported {{count}} book(s)_one": "Успішно імпортовано 1 книгу",
|
||||
"Successfully imported {{count}} book(s)_few": "Успішно імпортовано {{count}} книги",
|
||||
"Successfully imported {{count}} book(s)_many": "Успішно імпортовано {{count}} книг",
|
||||
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книг"
|
||||
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книг",
|
||||
"Count": "Кількість",
|
||||
"Start Page": "Початкова сторінка",
|
||||
"Search in OPDS Catalog...": "Пошук у каталозі OPDS...",
|
||||
"Please log in to use advanced TTS features.": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS."
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "Đang xác thực...",
|
||||
"View All": "Xem tất cả",
|
||||
"Forward": "Tiếp",
|
||||
"OPDS Catalog": "Danh mục OPDS",
|
||||
"Home": "Trang chủ",
|
||||
"Library": "Thư viện",
|
||||
"{{count}} items_other": "{{count}} mục",
|
||||
"Download completed": "Tải xuống hoàn tất",
|
||||
"Download failed": "Tải xuống thất bại",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "Sử dụng lưu trữ đám mây",
|
||||
"Rename Group": "Đổi tên nhóm",
|
||||
"From Directory": "Từ thư mục",
|
||||
"Successfully imported {{count}} book(s)_other": "Đã nhập thành công {{count}} sách"
|
||||
"Successfully imported {{count}} book(s)_other": "Đã nhập thành công {{count}} sách",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "验证中...",
|
||||
"View All": "查看全部",
|
||||
"Forward": "前进",
|
||||
"OPDS Catalog": "OPDS 目录",
|
||||
"Home": "首页",
|
||||
"Library": "图书馆",
|
||||
"{{count}} items_other": "{{count}} 项",
|
||||
"Download completed": "下载完成",
|
||||
"Download failed": "下载失败",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "云存储使用情况",
|
||||
"Rename Group": "重命名分组",
|
||||
"From Directory": "从文件夹导入",
|
||||
"Successfully imported {{count}} book(s)_other": "成功导入 {{count}} 本书"
|
||||
"Successfully imported {{count}} book(s)_other": "成功导入 {{count}} 本书",
|
||||
"Count": "数量",
|
||||
"Start Page": "起始页",
|
||||
"Search in OPDS Catalog...": "在 OPDS 目录中搜索...",
|
||||
"Please log in to use advanced TTS features.": "请登录以使用高级 TTS 功能"
|
||||
}
|
||||
|
||||
@@ -695,9 +695,7 @@
|
||||
"Validating...": "驗證中...",
|
||||
"View All": "檢視全部",
|
||||
"Forward": "前往",
|
||||
"OPDS Catalog": "OPDS 目錄",
|
||||
"Home": "首頁",
|
||||
"Library": "圖書館",
|
||||
"{{count}} items_other": "{{count}} 項",
|
||||
"Download completed": "下載完成",
|
||||
"Download failed": "下載失敗",
|
||||
@@ -753,5 +751,9 @@
|
||||
"Cloud Storage Usage": "雲端儲存使用情況",
|
||||
"Rename Group": "重新命名",
|
||||
"From Directory": "從目錄導入",
|
||||
"Successfully imported {{count}} book(s)_other": "成功導入 {{count}} 本書"
|
||||
"Successfully imported {{count}} book(s)_other": "成功導入 {{count}} 本書",
|
||||
"Count": "數量",
|
||||
"Start Page": "起始頁",
|
||||
"Search in OPDS Catalog...": "在 OPDS 目錄中搜尋...",
|
||||
"Please log in to use advanced TTS features.": "請登入以使用進階 TTS 功能"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.96": {
|
||||
"date": "2025-12-19",
|
||||
"notes": [
|
||||
"TTS: Resolved an issue where Edge TTS was blocked in certain regions",
|
||||
"OPDS: Improved compatibility with older versions of Calibre Web",
|
||||
"OPDS: Added an instant search bar for faster browsing in OPDS catalogs",
|
||||
"OPDS: Added a curated book catalog from Standard Ebooks",
|
||||
"Bookshelf: Added a “Group Books” action to the context menu",
|
||||
"Comics: Fixed layout issues to improve comic book reading",
|
||||
"Layout: Footnote popups now adapt correctly to different screen sizes",
|
||||
"Layout: Fixed an issue where the maximum inline width was not applied to EPUBs",
|
||||
"Sync: Improved file downloading by correctly handling special characters in filenames",
|
||||
"UI: Added an option to temporarily dismiss the reading progress bar",
|
||||
"Settings: Screen brightness adjustments now apply only to the reader view",
|
||||
"iOS: You can now open files directly in Readest from the Files app",
|
||||
"macOS: Added a global menu option to open files with Readest"
|
||||
]
|
||||
},
|
||||
"0.9.95": {
|
||||
"date": "2025-12-08",
|
||||
"notes": [
|
||||
|
||||
@@ -52,6 +52,7 @@ tauri-plugin-haptics = "2"
|
||||
tauri-plugin-persisted-scope = "2"
|
||||
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
|
||||
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
|
||||
tauri-plugin-websocket = "2"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
rand = "0.8"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -19,6 +19,8 @@
|
||||
<string>EPUB Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.idpf.epub-container</string>
|
||||
@@ -30,6 +32,8 @@
|
||||
<string>PDF Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.adobe.pdf</string>
|
||||
@@ -41,6 +45,8 @@
|
||||
<string>FB2 Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.readest.fb2</string>
|
||||
@@ -52,6 +58,8 @@
|
||||
<string>CBZ Archive</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.readest.cbz</string>
|
||||
@@ -63,6 +71,8 @@
|
||||
<string>MOBI Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>org.mobipocket.mobi</string>
|
||||
@@ -74,6 +84,8 @@
|
||||
<string>AZW Document</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.amazon.azw</string>
|
||||
@@ -86,6 +98,8 @@
|
||||
<string>Text File</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.plain-text</string>
|
||||
@@ -93,6 +107,153 @@
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>org.idpf.epub-container</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>EPUB Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.composite-content</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>epub</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/epub+zip</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.adobe.pdf</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>PDF Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.composite-content</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>pdf</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/pdf</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.readest.fb2</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>FB2 Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.xml</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>fb2</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/xml</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.readest.cbz</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>CBZ Archive</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.archive</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>cbz</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/x-cbz</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>org.mobipocket.mobi</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>MOBI Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>mobi</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/x-mobipocket-ebook</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.amazon.azw</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>AZW Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>azw</string>
|
||||
<string>azw3</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/vnd.amazon.ebook</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>public.plain-text</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Text File</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.content</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>txt</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>text/plain</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"websocket:default",
|
||||
"dialog:default",
|
||||
"os:default",
|
||||
"core:window:default",
|
||||
|
||||
+17
@@ -8,6 +8,8 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@@ -16,12 +18,27 @@ class BillingManager(private val activity: Activity) : PurchasesUpdatedListener
|
||||
private val productsCache = mutableMapOf<String, ProductDetails>()
|
||||
private var purchaseCallback: ((PurchaseData?) -> Unit)? = null
|
||||
private val scope = CoroutineScope(Dispatchers.Main)
|
||||
private val isGooglePlayAvailable: Boolean by lazy {
|
||||
val availability = GoogleApiAvailability.getInstance()
|
||||
val resultCode = availability.isGooglePlayServicesAvailable(activity)
|
||||
resultCode == ConnectionResult.SUCCESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingManager"
|
||||
}
|
||||
|
||||
fun isBillingAvailable(): Boolean {
|
||||
return isGooglePlayAvailable
|
||||
}
|
||||
|
||||
fun initialize(callback: (Boolean) -> Unit) {
|
||||
if (!isGooglePlayAvailable) {
|
||||
Log.d(TAG, "Google Play Services not available, skipping billing setup")
|
||||
callback(false)
|
||||
return
|
||||
}
|
||||
|
||||
billingClient = BillingClient.newBuilder(activity)
|
||||
.setListener(this)
|
||||
.enablePendingPurchases()
|
||||
|
||||
+20
-6
@@ -495,13 +495,19 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
val args = invoke.parseArgs(SetScreenBrightnessRequestArgs::class.java)
|
||||
val ret = JSObject()
|
||||
try {
|
||||
val brightness = (args.brightness ?: 0.5).toFloat()
|
||||
if (brightness < 0.0 || brightness > 1.0) {
|
||||
invoke.reject("Brightness must be between 0.0 and 1.0")
|
||||
return
|
||||
}
|
||||
val brightness = args.brightness?.toFloat()
|
||||
val layoutParams = activity.window.attributes
|
||||
layoutParams.screenBrightness = brightness
|
||||
|
||||
if (brightness == null || brightness < 0.0) {
|
||||
layoutParams.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
} else {
|
||||
if (brightness > 1.0) {
|
||||
invoke.reject("Brightness must be between 0.0 and 1.0, or null to use system brightness")
|
||||
return
|
||||
}
|
||||
layoutParams.screenBrightness = brightness
|
||||
}
|
||||
|
||||
activity.window.attributes = layoutParams
|
||||
ret.put("success", true)
|
||||
} catch (e: Exception) {
|
||||
@@ -511,6 +517,14 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
invoke.resolve(ret)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun iap_is_available(invoke: Invoke) {
|
||||
val isAvailable = billingManager.isBillingAvailable()
|
||||
val result = JSObject()
|
||||
result.put("available", isAvailable)
|
||||
invoke.resolve(result)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun iap_initialize(invoke: Invoke) {
|
||||
billingManager.initialize { success ->
|
||||
|
||||
@@ -9,6 +9,7 @@ const COMMANDS: &[&str] = &[
|
||||
"get_sys_fonts_list",
|
||||
"intercept_keys",
|
||||
"lock_screen_orientation",
|
||||
"iap_is_available",
|
||||
"iap_initialize",
|
||||
"iap_fetch_products",
|
||||
"iap_purchase_product",
|
||||
|
||||
+69
-1
@@ -53,6 +53,11 @@ class SetScreenBrightnessRequestArgs: Decodable {
|
||||
let brightness: Float?
|
||||
}
|
||||
|
||||
class CopyUriToPathRequestArgs: Decodable {
|
||||
let uri: String?
|
||||
let dst: String?
|
||||
}
|
||||
|
||||
struct InitializeRequest: Decodable {
|
||||
let publicKey: String?
|
||||
}
|
||||
@@ -706,6 +711,10 @@ class NativeBridgePlugin: Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func iap_is_available(_ invoke: Invoke) {
|
||||
invoke.resolve(["available": true])
|
||||
}
|
||||
|
||||
@objc public func iap_initialize(_ invoke: Invoke) {
|
||||
StoreKitManager.shared.initialize()
|
||||
invoke.resolve(["success": true])
|
||||
@@ -803,7 +812,13 @@ class NativeBridgePlugin: Plugin {
|
||||
|
||||
let brightness = args.brightness ?? 0.5
|
||||
|
||||
if brightness < 0.0 || brightness > 1.0 {
|
||||
if brightness < 0.0 {
|
||||
// Revert to system brightness - iOS doesn't have a direct "system brightness" setting
|
||||
// We will restore the brightness that was set before the app modified it
|
||||
return invoke.resolve(["success": true])
|
||||
}
|
||||
|
||||
if brightness > 1.0 {
|
||||
return invoke.reject("Brightness must be between 0.0 and 1.0")
|
||||
}
|
||||
|
||||
@@ -812,6 +827,59 @@ class NativeBridgePlugin: Plugin {
|
||||
}
|
||||
invoke.resolve(["success": true])
|
||||
}
|
||||
|
||||
@objc public func copy_uri_to_path(_ invoke: Invoke) {
|
||||
guard let args = try? invoke.parseArgs(CopyUriToPathRequestArgs.self) else {
|
||||
return invoke.reject("Failed to parse arguments")
|
||||
}
|
||||
|
||||
guard let uriString = args.uri, let dstPath = args.dst else {
|
||||
return invoke.reject("URI and destination path must be provided")
|
||||
}
|
||||
|
||||
guard let uri = URL(string: uriString) else {
|
||||
return invoke.reject("Invalid URI")
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let dstURL = URL(fileURLWithPath: dstPath)
|
||||
|
||||
do {
|
||||
let didStartAccessing = uri.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didStartAccessing {
|
||||
uri.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
var shouldCopy = false
|
||||
|
||||
if fileManager.fileExists(atPath: dstURL.path) {
|
||||
let srcAttributes = try fileManager.attributesOfItem(atPath: uri.path)
|
||||
let dstAttributes = try fileManager.attributesOfItem(atPath: dstURL.path)
|
||||
|
||||
let srcModDate = srcAttributes[.modificationDate] as? Date ?? Date.distantPast
|
||||
let dstModDate = dstAttributes[.modificationDate] as? Date ?? Date.distantPast
|
||||
|
||||
if srcModDate > dstModDate {
|
||||
try fileManager.removeItem(at: dstURL)
|
||||
shouldCopy = true
|
||||
} else {
|
||||
shouldCopy = false
|
||||
}
|
||||
} else {
|
||||
shouldCopy = true
|
||||
}
|
||||
|
||||
if shouldCopy {
|
||||
try fileManager.copyItem(at: uri, to: dstURL)
|
||||
}
|
||||
|
||||
invoke.resolve(["success": true])
|
||||
} catch {
|
||||
invoke.reject("Failed to copy file: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_cdecl("init_plugin_native_bridge")
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-iap-is-available"
|
||||
description = "Enables the iap_is_available command without any pre-configured scope."
|
||||
commands.allow = ["iap_is_available"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-iap-is-available"
|
||||
description = "Denies the iap_is_available command without any pre-configured scope."
|
||||
commands.deny = ["iap_is_available"]
|
||||
+27
@@ -14,6 +14,7 @@ Default permissions for the plugin
|
||||
- `allow-get-sys-fonts-list`
|
||||
- `allow-intercept-keys`
|
||||
- `allow-lock-screen-orientation`
|
||||
- `allow-iap-is-available`
|
||||
- `allow-iap-initialize`
|
||||
- `allow-iap-fetch-products`
|
||||
- `allow-iap-purchase-product`
|
||||
@@ -409,6 +410,32 @@ Denies the iap_initialize command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-iap-is-available`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the iap_is_available command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-iap-is-available`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the iap_is_available command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-iap-purchase-product`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -11,6 +11,7 @@ permissions = [
|
||||
"allow-get-sys-fonts-list",
|
||||
"allow-intercept-keys",
|
||||
"allow-lock-screen-orientation",
|
||||
"allow-iap-is-available",
|
||||
"allow-iap-initialize",
|
||||
"allow-iap-fetch-products",
|
||||
"allow-iap-purchase-product",
|
||||
|
||||
+14
-2
@@ -462,6 +462,18 @@
|
||||
"const": "deny-iap-initialize",
|
||||
"markdownDescription": "Denies the iap_initialize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the iap_is_available command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-iap-is-available",
|
||||
"markdownDescription": "Enables the iap_is_available command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the iap_is_available command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-iap-is-available",
|
||||
"markdownDescription": "Denies the iap_is_available command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the iap_purchase_product command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -655,10 +667,10 @@
|
||||
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -84,6 +84,13 @@ pub(crate) async fn lock_screen_orientation<R: Runtime>(
|
||||
app.native_bridge().lock_screen_orientation(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn iap_is_available<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
) -> Result<IAPIsAvailableResponse> {
|
||||
app.native_bridge().iap_is_available()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn iap_initialize<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
|
||||
@@ -74,6 +74,10 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn iap_initialize(
|
||||
&self,
|
||||
_payload: IAPInitializeRequest,
|
||||
|
||||
@@ -65,6 +65,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
commands::get_sys_fonts_list,
|
||||
commands::intercept_keys,
|
||||
commands::lock_screen_orientation,
|
||||
commands::iap_is_available,
|
||||
commands::iap_initialize,
|
||||
commands::iap_fetch_products,
|
||||
commands::iap_purchase_product,
|
||||
|
||||
@@ -113,6 +113,14 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("iap_is_available", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn iap_initialize(
|
||||
&self,
|
||||
|
||||
@@ -113,6 +113,12 @@ pub struct Purchase {
|
||||
pub purchase_state: String, // "purchased", "pending", "cancelled", "restored"
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPIsAvailableResponse {
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IAPInitializeRequest {
|
||||
|
||||
@@ -136,7 +136,7 @@ fn get_executable_dir() -> String {
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Payload {
|
||||
struct SingleInstancePayload {
|
||||
args: Vec<String>,
|
||||
cwd: String,
|
||||
}
|
||||
@@ -144,6 +144,7 @@ struct Payload {
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_oauth::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -179,7 +180,7 @@ pub fn run() {
|
||||
if !files.is_empty() {
|
||||
allow_file_in_scopes(app, files.clone());
|
||||
}
|
||||
app.emit("single-instance", Payload { args: argv, cwd })
|
||||
app.emit("single-instance", SingleInstancePayload { args: argv, cwd })
|
||||
.unwrap();
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
use crate::allow_file_in_scopes;
|
||||
use std::path::PathBuf;
|
||||
use tauri::menu::MenuEvent;
|
||||
use tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID};
|
||||
use tauri::menu::{MenuItemBuilder, SubmenuBuilder, HELP_SUBMENU_ID};
|
||||
use tauri::AppHandle;
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct OpenFilesPayload {
|
||||
files: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let global_menu = app.menu().unwrap();
|
||||
|
||||
@@ -10,6 +19,23 @@ pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
global_menu.remove(&item)?;
|
||||
}
|
||||
|
||||
let open_item = MenuItemBuilder::new("Open...")
|
||||
.id("open_file")
|
||||
.accelerator("Cmd+O")
|
||||
.build(app)?;
|
||||
|
||||
if let Some(file_menu) = global_menu.items()?.iter().find(|item| {
|
||||
if let Some(submenu) = item.as_submenu() {
|
||||
submenu.text().ok().as_deref() == Some("File")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
if let Some(file_submenu) = file_menu.as_submenu() {
|
||||
file_submenu.insert(&open_item, 0)?;
|
||||
}
|
||||
}
|
||||
|
||||
global_menu.append(
|
||||
&SubmenuBuilder::new(app, "Help")
|
||||
.text("privacy_policy", "Privacy Policy")
|
||||
@@ -28,7 +54,9 @@ pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
|
||||
pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) {
|
||||
let opener = app.opener();
|
||||
if event.id() == "privacy_policy" {
|
||||
if event.id() == "open_file" {
|
||||
handle_open_file(app);
|
||||
} else if event.id() == "privacy_policy" {
|
||||
let _ = opener.open_url("https://readest.com/privacy-policy", None::<&str>);
|
||||
} else if event.id() == "report_issue" {
|
||||
let _ = opener.open_url("https://github.com/readest/readest/issues", None::<&str>);
|
||||
@@ -36,3 +64,25 @@ pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) {
|
||||
let _ = opener.open_url("https://readest.com/support", None::<&str>);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_open_file(app: &AppHandle) {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let app_handle = app.clone();
|
||||
|
||||
app.dialog()
|
||||
.file()
|
||||
.add_filter(
|
||||
"Files",
|
||||
&["epub", "pdf", "mobi", "azw", "azw3", "fb2", "cbz", "txt"],
|
||||
)
|
||||
.pick_file(move |file_path| {
|
||||
if let Some(path) = file_path {
|
||||
let payload = OpenFilesPayload {
|
||||
files: vec![path.to_string()],
|
||||
};
|
||||
allow_file_in_scopes(&app_handle, vec![PathBuf::from(path.to_string())]);
|
||||
let _ = app_handle.emit("open-files", payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
},
|
||||
"iOS": {
|
||||
"developmentTeam": "J5W48D69VR",
|
||||
"infoPlist": "./Info-ios.plist",
|
||||
"minimumSystemVersion": "14.0"
|
||||
},
|
||||
"fileAssociations": [
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
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
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getWordCount, isWordLimitExceeded } from '../../utils/wordLimit';
|
||||
|
||||
describe('Word Limit Feature', () => {
|
||||
describe('getWordCount', () => {
|
||||
it('should count single word correctly', () => {
|
||||
expect(getWordCount('hello')).toBe(1);
|
||||
});
|
||||
|
||||
it('should count multiple words correctly', () => {
|
||||
expect(getWordCount('hello world')).toBe(2);
|
||||
expect(getWordCount('the quick brown fox')).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle multiple spaces between words', () => {
|
||||
expect(getWordCount('hello world')).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle leading and trailing spaces', () => {
|
||||
expect(getWordCount(' hello world ')).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle newlines and tabs', () => {
|
||||
expect(getWordCount('hello\nworld')).toBe(2);
|
||||
expect(getWordCount('hello\tworld')).toBe(2);
|
||||
expect(getWordCount('hello\n\t world')).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 for empty string', () => {
|
||||
expect(getWordCount('')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for whitespace only', () => {
|
||||
expect(getWordCount(' ')).toBe(0);
|
||||
expect(getWordCount('\n\t ')).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle punctuation as part of words', () => {
|
||||
expect(getWordCount("don't")).toBe(1);
|
||||
expect(getWordCount('hello, world!')).toBe(2);
|
||||
});
|
||||
|
||||
it('should count exactly 30 words', () => {
|
||||
const thirtyWords = Array(30).fill('word').join(' ');
|
||||
expect(getWordCount(thirtyWords)).toBe(30);
|
||||
});
|
||||
|
||||
it('should count more than 30 words', () => {
|
||||
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);
|
||||
expect(getWordCount('café résumé')).toBe(2);
|
||||
expect(getWordCount('🎉 hello 🎊 world')).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle numbers as words', () => {
|
||||
expect(getWordCount('1 2 3 4 5')).toBe(5);
|
||||
expect(getWordCount('chapter 1 section 2')).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Case Sensitivity Matching', () => {
|
||||
// Helper function to simulate matching logic
|
||||
const matchText = (text: string, pattern: string, caseSensitive: boolean): boolean => {
|
||||
if (caseSensitive) {
|
||||
return text === pattern;
|
||||
}
|
||||
return text.toLowerCase() === pattern.toLowerCase();
|
||||
};
|
||||
|
||||
describe('Case-Sensitive Mode', () => {
|
||||
it('should match exact case only', () => {
|
||||
expect(matchText('Where', 'Where', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match different case', () => {
|
||||
expect(matchText('where', 'Where', true)).toBe(false);
|
||||
expect(matchText('WHERE', 'Where', true)).toBe(false);
|
||||
expect(matchText('wHeRe', 'Where', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle all uppercase pattern', () => {
|
||||
expect(matchText('HELLO', 'HELLO', true)).toBe(true);
|
||||
expect(matchText('hello', 'HELLO', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle all lowercase pattern', () => {
|
||||
expect(matchText('hello', 'hello', true)).toBe(true);
|
||||
expect(matchText('Hello', 'hello', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Case-Insensitive Mode', () => {
|
||||
it('should match same case', () => {
|
||||
expect(matchText('where', 'where', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match different cases', () => {
|
||||
expect(matchText('where', 'Where', false)).toBe(true);
|
||||
expect(matchText('Where', 'where', false)).toBe(true);
|
||||
expect(matchText('WHERE', 'where', false)).toBe(true);
|
||||
expect(matchText('wHeRe', 'where', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match title case to lowercase', () => {
|
||||
expect(matchText('The', 'the', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match with mixed input', () => {
|
||||
expect(matchText('HeLLo', 'HELLO', false)).toBe(true);
|
||||
expect(matchText('HeLLo', 'hello', false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world examples', () => {
|
||||
it('should handle "the" in different cases', () => {
|
||||
const pattern = 'the';
|
||||
|
||||
// Case-insensitive (default behavior)
|
||||
expect(matchText('The', pattern, false)).toBe(true);
|
||||
expect(matchText('the', pattern, false)).toBe(true);
|
||||
expect(matchText('THE', pattern, false)).toBe(true);
|
||||
|
||||
// Case-sensitive
|
||||
expect(matchText('The', pattern, true)).toBe(false);
|
||||
expect(matchText('the', pattern, true)).toBe(true);
|
||||
expect(matchText('THE', pattern, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle proper nouns correctly when case-sensitive', () => {
|
||||
const pattern = 'John';
|
||||
|
||||
// Case-sensitive - only exact match
|
||||
expect(matchText('John', pattern, true)).toBe(true);
|
||||
expect(matchText('john', pattern, true)).toBe(false);
|
||||
expect(matchText('JOHN', pattern, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
@@ -35,7 +36,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 20000);
|
||||
const headers: HeadersInit = {
|
||||
'User-Agent': 'Readest/1.0 (OPDS Browser)',
|
||||
'User-Agent': READEST_OPDS_USER_AGENT,
|
||||
Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*',
|
||||
};
|
||||
|
||||
@@ -124,6 +125,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Expose-Headers': 'X-Content-Length',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
|
||||
const getLangFromVoice = (voiceId: string): string => {
|
||||
const match = voiceId.match(/^([a-z]{2}-[A-Z]{2})/);
|
||||
return match ? match[1]! : 'en-US';
|
||||
};
|
||||
|
||||
const isValidVoice = (voiceId: string): boolean => {
|
||||
return EdgeSpeechTTS.voices.some((v) => v.id === voiceId);
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { input: text, voice, speed = 1.0 } = body;
|
||||
let { rate, lang } = body;
|
||||
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: { message: 'Missing or invalid "input" field', type: 'invalid_request_error' } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!voice || typeof voice !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: { message: 'Missing or invalid "voice" field', type: 'invalid_request_error' } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidVoice(voice)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: `Invalid voice "${voice}". Use GET /api/tts/edge to list available voices.`,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
lang = lang || getLangFromVoice(voice);
|
||||
|
||||
// Calculate rate (OpenAI speed ranges from 0.25 to 4.0, Edge TTS rate is 0.5 to 2.0)
|
||||
const clampedSpeed = Math.max(0.25, Math.min(4.0, speed));
|
||||
let mappedSpeed: number;
|
||||
if (clampedSpeed <= 1.0) {
|
||||
mappedSpeed = 0.5 + ((clampedSpeed - 0.25) / (1.0 - 0.25)) * (1.0 - 0.5);
|
||||
} else {
|
||||
mappedSpeed = 1.0 + ((clampedSpeed - 1.0) / (4.0 - 1.0)) * (2.0 - 1.0);
|
||||
}
|
||||
rate = rate || mappedSpeed;
|
||||
|
||||
const payload: EdgeTTSPayload = {
|
||||
lang,
|
||||
text,
|
||||
voice,
|
||||
rate,
|
||||
pitch: 1.0,
|
||||
};
|
||||
|
||||
const tts = new EdgeSpeechTTS();
|
||||
const response = await tts.create(payload);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
return new NextResponse(arrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Content-Length': arrayBuffer.byteLength.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Edge TTS API error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Internal server error',
|
||||
type: 'internal_error',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const query = request.nextUrl.searchParams;
|
||||
const lang = query.get('lang') || '';
|
||||
let voices = EdgeSpeechTTS.voices;
|
||||
if (lang) {
|
||||
voices = voices.filter((v) => v.lang.toLowerCase().includes(lang.toLowerCase()));
|
||||
}
|
||||
|
||||
const formattedVoices = voices.map((voice) => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
language: voice.lang,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
voices: formattedVoices,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing voices:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: 'Failed to list voices',
|
||||
type: 'internal_error',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}
|
||||
setLoading={setLoading}
|
||||
toggleSelection={toggleSelection}
|
||||
handleGroupBooks={groupSelectedBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
|
||||
@@ -81,6 +81,7 @@ interface BookshelfItemProps {
|
||||
transferProgress: number | null;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
toggleSelection: (hash: string) => void;
|
||||
handleGroupBooks: () => void;
|
||||
handleBookDownload: (book: Book) => Promise<boolean>;
|
||||
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
@@ -97,6 +98,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
transferProgress,
|
||||
setLoading,
|
||||
toggleSelection,
|
||||
handleGroupBooks,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
handleSetSelectMode,
|
||||
@@ -188,6 +190,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
toggleSelection(book.hash);
|
||||
},
|
||||
});
|
||||
const groupBooksMenuItem = await MenuItem.new({
|
||||
text: _('Group Books'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
if (!itemSelected) {
|
||||
toggleSelection(book.hash);
|
||||
}
|
||||
handleGroupBooks();
|
||||
},
|
||||
});
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
@@ -221,6 +233,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(selectBookMenuItem);
|
||||
menu.append(groupBooksMenuItem);
|
||||
menu.append(showBookDetailsMenuItem);
|
||||
menu.append(showBookInFinderMenuItem);
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
@@ -242,6 +255,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
toggleSelection(group.id);
|
||||
},
|
||||
});
|
||||
const groupBooksMenuItem = await MenuItem.new({
|
||||
text: _('Group Books'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
if (!itemSelected) {
|
||||
toggleSelection(group.id);
|
||||
}
|
||||
handleGroupBooks();
|
||||
},
|
||||
});
|
||||
const deleteGroupMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
@@ -250,6 +273,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(selectGroupMenuItem);
|
||||
menu.append(groupBooksMenuItem);
|
||||
menu.append(deleteGroupMenuItem);
|
||||
menu.popup();
|
||||
};
|
||||
|
||||
@@ -93,7 +93,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const { clearBookData } = useBookDataStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
|
||||
const [showCatalogManager, setShowCatalogManager] = useState(false);
|
||||
const [showCatalogManager, setShowCatalogManager] = useState(
|
||||
searchParams?.get('opds') === 'true',
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
@@ -270,6 +272,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleShowOPDSDialog = () => {
|
||||
setShowCatalogManager(true);
|
||||
};
|
||||
|
||||
const handleDismissOPDSDialog = () => {
|
||||
setShowCatalogManager(false);
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.delete('opds');
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingNavigationBookIds) {
|
||||
const bookIds = pendingNavigationBookIds;
|
||||
@@ -323,7 +336,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
};
|
||||
|
||||
const handleOpenWithBooks = async (appService: AppService, library: Book[]) => {
|
||||
const openWithFiles = (await parseOpenWithFiles()) || [];
|
||||
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
|
||||
|
||||
if (openWithFiles.length > 0) {
|
||||
return await processOpenWithFiles(appService, openWithFiles, library);
|
||||
@@ -716,7 +729,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onOpenCatalogManager={() => setShowCatalogManager(true)}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
@@ -847,7 +860,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<UpdaterWindow />
|
||||
<MigrateDataWindow />
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
|
||||
{showCatalogManager && <CatalogDialog onClose={() => setShowCatalogManager(false)} />}
|
||||
{showCatalogManager && <CatalogDialog onClose={handleDismissOPDSDialog} />}
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,13 @@ const POPULAR_CATALOGS: OPDSCatalog[] = [
|
||||
description: "World's largest collection of free ebooks",
|
||||
icon: '🏛️',
|
||||
},
|
||||
{
|
||||
id: 'standardebooks',
|
||||
name: 'Standard Ebooks',
|
||||
url: 'https://standardebooks.org/feeds/opds',
|
||||
description: 'Free and liberated ebooks, carefully produced for the true book lover',
|
||||
icon: '📚',
|
||||
},
|
||||
{
|
||||
id: 'manybooks',
|
||||
name: 'ManyBooks',
|
||||
@@ -28,20 +35,12 @@ const POPULAR_CATALOGS: OPDSCatalog[] = [
|
||||
description: 'Over 50,000 free ebooks',
|
||||
icon: '📖',
|
||||
},
|
||||
{
|
||||
id: 'standardebooks',
|
||||
name: 'Standard Ebooks',
|
||||
url: 'https://standardebooks.org/feeds/opds',
|
||||
description: 'Carefully formatted and lovingly produced free ebooks',
|
||||
icon: '📚',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'unglue.it',
|
||||
name: 'Unglue.it',
|
||||
url: 'https://unglue.it/api/opds/',
|
||||
description: 'Free ebooks from authors who have "unglued" their books',
|
||||
icon: '📚',
|
||||
icon: '🔓',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -192,7 +191,7 @@ export function CatalogManager() {
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<h3 className='card-title truncate text-sm'>
|
||||
<h3 className='card-title line-clamp-1 text-sm'>
|
||||
{catalog.icon && <span className=''>{catalog.icon}</span>}
|
||||
{catalog.name}
|
||||
</h3>
|
||||
@@ -209,7 +208,7 @@ export function CatalogManager() {
|
||||
{catalog.description}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-base-content/50 truncate text-xs'>{catalog.url}</p>
|
||||
<p className='text-base-content/50 line-clamp-1 text-xs'>{catalog.url}</p>
|
||||
{catalog.username && (
|
||||
<p className='text-base-content/50 mt-1 text-xs'>
|
||||
{_('Username')}: {catalog.username}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { OPDSFeed, OPDSLink } from '@/types/opds';
|
||||
@@ -18,9 +19,9 @@ interface FeedViewProps {
|
||||
isOPDSCatalog: (type?: string) => boolean;
|
||||
}
|
||||
|
||||
const gridClassName = 'grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
|
||||
const gridClassName = 'grid grid-cols-3 gap-4 px-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
|
||||
const navigationClassName =
|
||||
'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
|
||||
'grid grid-cols-2 gap-4 px-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
|
||||
|
||||
export function FeedView({
|
||||
feed,
|
||||
@@ -51,21 +52,34 @@ export function FeedView({
|
||||
onNavigate(url);
|
||||
};
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: number) => (
|
||||
<PublicationCard
|
||||
publication={feed.publications![index]!}
|
||||
baseURL={baseURL}
|
||||
onClick={() => onPublicationSelect(-1, index)}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
),
|
||||
[feed.publications, baseURL, onPublicationSelect, resolveURL, onGenerateCachedImageUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='container mx-auto max-w-7xl px-4 py-6'>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Header */}
|
||||
<div className='opds-header mb-6'>
|
||||
<div className='opds-header flex-shrink-0 px-4 py-6'>
|
||||
{feed.metadata?.title && <h1 className='mb-2 text-xl font-bold'>{feed.metadata.title}</h1>}
|
||||
{feed.metadata?.subtitle && (
|
||||
<p className='text-base-content/70 text-sm'>{feed.metadata.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-6'>
|
||||
<div className='flex min-h-0 flex-1 gap-6'>
|
||||
{/* Facets Sidebar */}
|
||||
{hasFacets && (
|
||||
<aside className='hidden w-64 flex-shrink-0 lg:block'>
|
||||
<div className='sticky top-6'>
|
||||
<aside className='hidden w-64 flex-shrink-0 overflow-y-auto lg:block'>
|
||||
<div className='px-4'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<IoFilter className='h-5 w-5' />
|
||||
<h2 className='text-lg font-semibold'>Filters</h2>
|
||||
@@ -113,10 +127,10 @@ export function FeedView({
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
{/* Navigation Items */}
|
||||
{feed.navigation && feed.navigation.length > 0 && (
|
||||
<section className='opds-navigation mb-8'>
|
||||
<section className='opds-navigation flex-shrink-0 pb-6'>
|
||||
<div className={navigationClassName}>
|
||||
{feed.navigation.map((item, index: number) => (
|
||||
<NavigationCard
|
||||
@@ -131,29 +145,23 @@ export function FeedView({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Publications */}
|
||||
{/* Publications Grid - Takes remaining space */}
|
||||
{feed.publications && feed.publications.length > 0 && (
|
||||
<section className='opds-publications mb-8'>
|
||||
<div className={gridClassName}>
|
||||
{feed.publications.map((pub, index: number) => (
|
||||
<PublicationCard
|
||||
key={index}
|
||||
publication={pub}
|
||||
baseURL={baseURL}
|
||||
onClick={() => onPublicationSelect(-1, index)}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<section className='opds-publications min-h-0 flex-1'>
|
||||
<VirtuosoGrid
|
||||
style={{ height: '100%' }}
|
||||
totalCount={feed.publications.length}
|
||||
listClassName={gridClassName}
|
||||
itemContent={itemContent}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Groups */}
|
||||
{feed.groups?.map((group, groupIndex: number) => (
|
||||
<section key={groupIndex} className='mb-12'>
|
||||
<section key={groupIndex} className='mb-12 flex-shrink-0'>
|
||||
{group.metadata && (
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='mb-4 flex items-center justify-between px-4'>
|
||||
<h2 className='text-2xl font-bold'>{group.metadata.title}</h2>
|
||||
{group.links && group.links.length > 0 && (
|
||||
<button
|
||||
@@ -203,7 +211,7 @@ export function FeedView({
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.some((links) => links && links.length > 0) && (
|
||||
<nav className='mt-8 flex justify-center gap-2'>
|
||||
<nav className='flex flex-shrink-0 justify-center gap-2 py-4'>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[0])}
|
||||
disabled={!pagination[0]}
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { GiBookshelf } from 'react-icons/gi';
|
||||
import { IoChevronBack, IoChevronForward, IoHome, IoSearch } from 'react-icons/io5';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { IoMdCloseCircle } from 'react-icons/io';
|
||||
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
|
||||
interface NavigationProps {
|
||||
currentURL: string;
|
||||
startURL?: string;
|
||||
onNavigate: (url: string) => void;
|
||||
searchTerm?: string;
|
||||
onBack?: () => void;
|
||||
onForward?: () => void;
|
||||
onSearch: () => void;
|
||||
onGoStart: () => void;
|
||||
onSearch: (queryTerm: string) => void;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
hasSearch: boolean;
|
||||
}
|
||||
|
||||
export function Navigation({
|
||||
startURL,
|
||||
onNavigate,
|
||||
searchTerm,
|
||||
onBack,
|
||||
onForward,
|
||||
onGoStart,
|
||||
onSearch,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
@@ -35,70 +38,127 @@ export function Navigation({
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const viewSettings = settings.globalViewSettings;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
if (startURL) {
|
||||
onNavigate(startURL);
|
||||
useEffect(() => {
|
||||
setSearchQuery(searchTerm || '');
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSearch && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [startURL, onNavigate]);
|
||||
}, [hasSearch]);
|
||||
|
||||
const handleGoLibrary = useCallback(() => {
|
||||
navigateToLibrary(router, '', {}, true);
|
||||
navigateToLibrary(router, 'opds=true', {}, true);
|
||||
}, [router]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
onSearch();
|
||||
}, [onSearch]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdateQueryParam = useCallback(
|
||||
debounce((value: string) => {
|
||||
if (value) {
|
||||
onSearch(value);
|
||||
}
|
||||
}, 1000),
|
||||
[onSearch],
|
||||
);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newQuery = e.target.value;
|
||||
setSearchQuery(newQuery);
|
||||
debouncedUpdateQueryParam(newQuery);
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={clsx(
|
||||
'navbar min-h-0 px-2',
|
||||
'flex h-[48px] w-full items-center',
|
||||
appService?.isMobile ? '' : 'border-base-300 bg-base-200 border-b',
|
||||
appService?.isMobile ? '' : 'bg-base-100',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('navbar-start gap-1', isTrafficLightVisible && '!pl-16')}>
|
||||
{onBack && (
|
||||
<button
|
||||
className='btn btn-ghost btn-sm px-3 disabled:bg-transparent'
|
||||
onClick={onBack}
|
||||
disabled={!canGoBack}
|
||||
title={_('Back')}
|
||||
>
|
||||
<IoChevronBack className='h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
{onForward && (
|
||||
<button
|
||||
className='btn btn-ghost btn-sm disabled:bg-transparent'
|
||||
onClick={onForward}
|
||||
disabled={!canGoForward}
|
||||
title={_('Forward')}
|
||||
>
|
||||
<IoChevronForward className='h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='navbar-center'>
|
||||
<h1 className='max-w-md truncate text-base font-semibold'>{_('OPDS Catalog')}</h1>
|
||||
</div>
|
||||
|
||||
<div className='navbar-end gap-2'>
|
||||
{hasSearch && (
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleSearch} title={_('Search')}>
|
||||
<IoSearch className='h-5 w-5' />
|
||||
</button>
|
||||
)}
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleGoHome} title={_('Home')}>
|
||||
<div className={clsx('justify-start gap-1 sm:gap-3', isTrafficLightVisible && '!pl-16')}>
|
||||
<div className='flex gap-1'>
|
||||
{onBack && (
|
||||
<button
|
||||
className='btn btn-ghost btn-sm px-1 disabled:bg-transparent'
|
||||
onClick={onBack}
|
||||
disabled={!canGoBack}
|
||||
title={_('Back')}
|
||||
>
|
||||
<IoChevronBack className='h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
{onForward && (
|
||||
<button
|
||||
className='btn btn-ghost btn-sm px-1 disabled:bg-transparent'
|
||||
onClick={onForward}
|
||||
disabled={!canGoForward}
|
||||
title={_('Forward')}
|
||||
>
|
||||
<IoChevronForward className='h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className='btn btn-ghost btn-sm px-1' onClick={onGoStart} title={_('Home')}>
|
||||
<IoHome className='h-5 w-5' />
|
||||
</button>
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleGoLibrary} title={_('Library')}>
|
||||
<GiBookshelf className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='flex-grow px-3 sm:px-5'>
|
||||
<div className='relative flex w-full items-center'>
|
||||
<span className='text-base-content/50 absolute left-3'>
|
||||
<FaSearch className='h-4 w-4' />
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
ref={inputRef}
|
||||
value={searchQuery}
|
||||
placeholder={_('Search in OPDS Catalog...')}
|
||||
disabled={!hasSearch}
|
||||
onChange={handleSearchChange}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input rounded-badge h-9 w-full pl-10 pr-4 sm:h-7',
|
||||
viewSettings?.isEink
|
||||
? 'border-1 border-base-content focus:border-base-content'
|
||||
: 'bg-base-300/45 border-none',
|
||||
'font-sans text-sm font-light',
|
||||
'placeholder:text-base-content/50 truncate',
|
||||
'focus:outline-none focus:ring-0',
|
||||
)}
|
||||
/>
|
||||
<div className='text-base-content/50 absolute right-2 flex items-center space-x-2 sm:space-x-4'>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
onGoStart();
|
||||
}}
|
||||
className='text-base-content/40 hover:text-base-content/60 pe-1'
|
||||
aria-label={_('Clear Search')}
|
||||
>
|
||||
<IoMdCloseCircle className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='justify-end gap-2 px-1'>
|
||||
<WindowButtons
|
||||
className='window-buttons flex h-full items-center'
|
||||
onClose={() => {
|
||||
handleGoLibrary();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,6 @@ export function PublicationView({
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
const [downloadedBook, setDownloadedBook] = useState<Book | null>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
|
||||
@@ -79,7 +78,6 @@ export function PublicationView({
|
||||
}
|
||||
|
||||
setDownloading(true);
|
||||
setDownloaded(false);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
@@ -150,13 +148,13 @@ export function PublicationView({
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{acquisitionLinks.map(({ rel, links }) => (
|
||||
<div key={rel} className='flex gap-1'>
|
||||
{links.length === 1 ? (
|
||||
{links.length === 1 || downloadedBook ? (
|
||||
<button
|
||||
onClick={() => handleActionButton(links[0]!.href, links[0]!.type)}
|
||||
disabled={downloading}
|
||||
className={clsx(
|
||||
'btn btn-primary min-w-20 rounded-3xl',
|
||||
downloaded && 'btn-success',
|
||||
downloadedBook && 'btn-success',
|
||||
)}
|
||||
>
|
||||
{downloadedBook ? _('Open & Read') : getAcquisitionLabel(rel)}
|
||||
@@ -173,7 +171,7 @@ export function PublicationView({
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
`btn btn-primary min-w-20 rounded-3xl ${downloading ? 'btn-disabled' : ''}`,
|
||||
downloaded && 'btn-success',
|
||||
downloadedBook && 'btn-success',
|
||||
)}
|
||||
>
|
||||
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
|
||||
|
||||
@@ -17,7 +17,13 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
search.params?.forEach((param) => {
|
||||
initial[param.name] = param.value || '';
|
||||
if (param.name === 'count') {
|
||||
initial[param.name] = '20';
|
||||
} else if (param.name === 'startPage') {
|
||||
initial[param.name] = '1';
|
||||
} else {
|
||||
initial[param.name] = param.value || '';
|
||||
}
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
@@ -56,6 +62,8 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
|
||||
publisher: _('Publisher'),
|
||||
language: _('Language'),
|
||||
subject: _('Subject'),
|
||||
count: _('Count'),
|
||||
startPage: _('Start Page'),
|
||||
};
|
||||
return labels[name] || name;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getFileExtFromMimeType } from '@/libs/document';
|
||||
import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
|
||||
import { 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';
|
||||
import { PublicationView } from './components/PublicationView';
|
||||
import { SearchView } from './components/SearchView';
|
||||
@@ -66,12 +67,15 @@ export default function BrowserPage() {
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const catalogUrl = searchParams?.get('url') || '';
|
||||
const catalogId = searchParams?.get('id') || '';
|
||||
const usernameRef = useRef<string | null | undefined>(undefined);
|
||||
const passwordRef = useRef<string | null | undefined>(undefined);
|
||||
const startURLRef = useRef<string | null | undefined>(undefined);
|
||||
const loadingOPDSRef = useRef(false);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const isNavigatingHistoryRef = useRef(false);
|
||||
const searchTermRef = useRef('');
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
@@ -102,6 +106,40 @@ export default function BrowserPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const quickSearch = useCallback((search: OPDSSearch, baseURL: string, searchTerms: string) => {
|
||||
if (searchTerms) {
|
||||
const formData: Record<string, string> = {};
|
||||
search.params?.forEach((param) => {
|
||||
if (param.name === 'count') {
|
||||
formData[param.name] = '20';
|
||||
} else if (param.name === 'startPage') {
|
||||
formData[param.name] = '1';
|
||||
} else if (param.name === 'searchTerms') {
|
||||
formData[param.name] = searchTerms;
|
||||
} else {
|
||||
formData[param.name] = param.value || '';
|
||||
}
|
||||
});
|
||||
const map = new Map<string | null, Map<string | null, string>>();
|
||||
|
||||
for (const param of search.params || []) {
|
||||
const value = formData[param.name] || '';
|
||||
const ns = param.ns ?? null;
|
||||
|
||||
if (map.has(ns)) {
|
||||
map.get(ns)!.set(param.name, value);
|
||||
} else {
|
||||
map.set(ns, new Map([[param.name, value]]));
|
||||
}
|
||||
}
|
||||
|
||||
const searchURL = search.search(map);
|
||||
const resolvedURL = resolveURL(searchURL, baseURL);
|
||||
handleNavigate(resolvedURL, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadOPDS = useCallback(
|
||||
async (url: string, options: { skipHistory?: boolean; isSearch?: boolean } = {}) => {
|
||||
const { skipHistory = false, isSearch = false } = options;
|
||||
@@ -193,9 +231,12 @@ export default function BrowserPage() {
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('search');
|
||||
setSelectedPublication(null);
|
||||
|
||||
if (searchTermRef.current) {
|
||||
quickSearch(search, responseURL, searchTermRef.current);
|
||||
} else {
|
||||
setViewMode('search');
|
||||
setSelectedPublication(null);
|
||||
}
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'search', null);
|
||||
}
|
||||
@@ -248,13 +289,12 @@ export default function BrowserPage() {
|
||||
loadingOPDSRef.current = false;
|
||||
}
|
||||
},
|
||||
[_, router, addToHistory],
|
||||
[_, router, quickSearch, addToHistory],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const url = searchParams?.get('url');
|
||||
const url = catalogUrl;
|
||||
if (url && !isNavigatingHistoryRef.current) {
|
||||
const catalogId = searchParams?.get('id') || '';
|
||||
const catalog = settings.opdsCatalogs?.find((cat) => cat.id === catalogId);
|
||||
const { username, password } = catalog || {};
|
||||
if (username || password) {
|
||||
@@ -273,7 +313,7 @@ export default function BrowserPage() {
|
||||
setViewMode('error');
|
||||
setError(new Error('No OPDS URL provided'));
|
||||
}
|
||||
}, [searchParams, settings, libraryLoaded, loadOPDS]);
|
||||
}, [catalogUrl, catalogId, settings, libraryLoaded, loadOPDS]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(url: string, isSearch = false) => {
|
||||
@@ -289,50 +329,61 @@ export default function BrowserPage() {
|
||||
return !!state.feed?.links?.find(isSearchLink);
|
||||
}, [state.feed]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!state.feed) return;
|
||||
|
||||
const searchLink = state.feed.links?.find(isSearchLink);
|
||||
|
||||
if (searchLink && searchLink.href) {
|
||||
const searchURL = resolveURL(searchLink.href, state.baseURL);
|
||||
if (searchLink.type === MIME.OPENSEARCH) {
|
||||
handleNavigate(searchURL, true);
|
||||
} else if (searchLink.type === MIME.ATOM) {
|
||||
const search: OPDSSearch = {
|
||||
metadata: {
|
||||
title: _('Search'),
|
||||
description: state.feed.metadata?.title
|
||||
? _('Search in {{title}}', { title: state.feed.metadata.title })
|
||||
: undefined,
|
||||
},
|
||||
params: [
|
||||
{
|
||||
name: 'searchTerms',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
search: (map: Map<string | null, Map<string | null, string>>) => {
|
||||
const defaultParams = map.get(null);
|
||||
const searchTerms = defaultParams?.get('searchTerms') || '';
|
||||
const decodedURL = decodeURIComponent(searchURL);
|
||||
return decodedURL.replace('{searchTerms}', encodeURIComponent(searchTerms));
|
||||
},
|
||||
};
|
||||
const newState: OPDSState = {
|
||||
feed: state.feed,
|
||||
search,
|
||||
baseURL: state.baseURL,
|
||||
currentURL: state.currentURL,
|
||||
startURL: state.startURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('search');
|
||||
setSelectedPublication(null);
|
||||
addToHistory(state.currentURL, newState, 'search', null);
|
||||
}
|
||||
const handleGoStart = useCallback(() => {
|
||||
if (startURLRef.current) {
|
||||
handleNavigate(startURLRef.current);
|
||||
}
|
||||
}, [_, state, handleNavigate, addToHistory]);
|
||||
searchTermRef.current = '';
|
||||
}, [startURLRef, handleNavigate]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(queryTerm: string) => {
|
||||
if (!state.feed) return;
|
||||
|
||||
searchTermRef.current = queryTerm;
|
||||
|
||||
const searchLink = state.feed.links?.find(isSearchLink);
|
||||
if (searchLink && searchLink.href) {
|
||||
const searchURL = resolveURL(searchLink.href, state.baseURL);
|
||||
if (searchLink.type === MIME.OPENSEARCH) {
|
||||
handleNavigate(searchURL, true);
|
||||
} else if (searchLink.type === MIME.ATOM) {
|
||||
const search: OPDSSearch = {
|
||||
metadata: {
|
||||
title: _('Search'),
|
||||
description: state.feed.metadata?.title
|
||||
? _('Search in {{title}}', { title: state.feed.metadata.title })
|
||||
: undefined,
|
||||
},
|
||||
params: [
|
||||
{
|
||||
name: 'searchTerms',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
search: (map: Map<string | null, Map<string | null, string>>) => {
|
||||
const defaultParams = map.get(null);
|
||||
const searchTerms = defaultParams?.get('searchTerms') || '';
|
||||
const decodedURL = decodeURIComponent(searchURL);
|
||||
return decodedURL.replace('{searchTerms}', encodeURIComponent(searchTerms));
|
||||
},
|
||||
};
|
||||
const newState: OPDSState = {
|
||||
feed: state.feed,
|
||||
search,
|
||||
baseURL: state.baseURL,
|
||||
currentURL: state.currentURL,
|
||||
startURL: state.startURL,
|
||||
};
|
||||
setState(newState);
|
||||
setSelectedPublication(null);
|
||||
setViewMode('search');
|
||||
addToHistory(state.currentURL, newState, 'search', null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[_, state, handleNavigate, addToHistory],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (
|
||||
@@ -362,7 +413,7 @@ export default function BrowserPage() {
|
||||
const useProxy = needsProxy(url);
|
||||
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Readest/1.0 (OPDS Browser)',
|
||||
'User-Agent': READEST_OPDS_USER_AGENT,
|
||||
};
|
||||
if (username || password) {
|
||||
const authHeader = await probeAuth(url, username, password, useProxy);
|
||||
@@ -517,11 +568,10 @@ export default function BrowserPage() {
|
||||
}}
|
||||
>
|
||||
<Navigation
|
||||
currentURL={state.currentURL}
|
||||
startURL={state.startURL}
|
||||
onNavigate={handleNavigate}
|
||||
searchTerm={searchTermRef.current}
|
||||
onBack={handleBack}
|
||||
onForward={handleForward}
|
||||
onGoStart={handleGoStart}
|
||||
onSearch={handleSearch}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { md5 } from 'js-md5';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import {
|
||||
getAPIBaseUrl,
|
||||
getNodeAPIBaseUrl,
|
||||
isTauriAppPlatform,
|
||||
isWebAppPlatform,
|
||||
} from '@/services/environment';
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
|
||||
|
||||
const OPDS_PROXY_URL = `${getAPIBaseUrl()}/opds/proxy`;
|
||||
const NODE_OPDS_PROXY_URL = `${getNodeAPIBaseUrl()}/opds/proxy`;
|
||||
/**
|
||||
* Extract username and password from URL credentials
|
||||
*/
|
||||
@@ -33,6 +41,19 @@ export const needsProxy = (url: string): boolean => {
|
||||
return isWebAppPlatform() && url.startsWith('http');
|
||||
};
|
||||
|
||||
const PROXY_OVERRIDES: Record<string, string> = {
|
||||
standardebooks: NODE_OPDS_PROXY_URL,
|
||||
};
|
||||
|
||||
const getProxyBaseUrl = (url: string): string => {
|
||||
for (const [domain, proxyUrl] of Object.entries(PROXY_OVERRIDES)) {
|
||||
if (url.includes(domain)) {
|
||||
return proxyUrl;
|
||||
}
|
||||
}
|
||||
return OPDS_PROXY_URL;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate proxied URL for OPDS requests
|
||||
*/
|
||||
@@ -45,7 +66,8 @@ export const getProxiedURL = (url: string, auth: string = '', stream = false): s
|
||||
if (auth) {
|
||||
params.append('auth', auth);
|
||||
}
|
||||
const proxyUrl = `/api/opds/proxy?${params.toString()}`;
|
||||
const baseUrl = getProxyBaseUrl(url);
|
||||
const proxyUrl = `${baseUrl}?${params.toString()}`;
|
||||
return proxyUrl;
|
||||
}
|
||||
return url;
|
||||
@@ -184,6 +206,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',
|
||||
};
|
||||
|
||||
@@ -210,6 +233,10 @@ export const probeAuth = async (
|
||||
} else if (wwwAuthenticate.toLowerCase().startsWith('basic')) {
|
||||
return createBasicAuth(finalUsername, finalPassword);
|
||||
}
|
||||
} else {
|
||||
// Fallback to Basic auth if no WWW-Authenticate header
|
||||
// some older Calibre-Web versions behave this way, see issue #2656
|
||||
return createBasicAuth(finalUsername, finalPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +264,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',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
@@ -131,7 +131,7 @@ const FoliateViewer: React.FC<{
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (viewSettings && detail.type === 'text/css')
|
||||
return transformStylesheet(width, height, data);
|
||||
return transformStylesheet(data, width, height, viewSettings.vertical);
|
||||
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
|
||||
const ctx: TransformContext = {
|
||||
bookKey,
|
||||
@@ -149,7 +149,9 @@ const FoliateViewer: React.FC<{
|
||||
'language',
|
||||
'sanitizer',
|
||||
'simplecc',
|
||||
'replacement',
|
||||
],
|
||||
sectionHref: detail.name, // Pass section href for single-instance replacements
|
||||
};
|
||||
return Promise.resolve(transformContent(ctx));
|
||||
}
|
||||
|
||||
@@ -107,11 +107,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const { renderer } = view as FoliateView;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
if (viewSettings.vertical) {
|
||||
setResponsiveWidth(getResponsivePopupSize(renderer.viewSize, true));
|
||||
setResponsiveHeight(clipPopupHeight(popupHeight));
|
||||
setResponsiveWidth(clipPopupWith(getResponsivePopupSize(renderer.viewSize, true)));
|
||||
} else {
|
||||
setResponsiveWidth(clipPopupWith(popupWidth));
|
||||
setResponsiveHeight(getResponsivePopupSize(renderer.viewSize, false));
|
||||
setResponsiveHeight(clipPopupHeight(getResponsivePopupSize(renderer.viewSize, false)));
|
||||
}
|
||||
setShowPopup(true);
|
||||
});
|
||||
@@ -135,9 +133,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
useEffect(() => {
|
||||
if (viewSettings.vertical) {
|
||||
setResponsiveWidth(clipPopupWith(popupHeight));
|
||||
setResponsiveHeight(Math.max(popupWidth, window.innerHeight / 4));
|
||||
setResponsiveHeight(clipPopupHeight(Math.max(popupWidth, window.innerHeight / 4)));
|
||||
} else {
|
||||
setResponsiveWidth(Math.max(popupWidth, window.innerWidth / 4));
|
||||
setResponsiveWidth(clipPopupWith(Math.max(popupWidth, window.innerWidth / 4)));
|
||||
setResponsiveHeight(clipPopupHeight(popupHeight));
|
||||
}
|
||||
}, [viewSettings]);
|
||||
|
||||
@@ -90,11 +90,11 @@ const HintInfo: React.FC<SectionInfoProps> = ({
|
||||
right: showDoubleBorder
|
||||
? `calc(${contentInsets.right}px)`
|
||||
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
|
||||
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
|
||||
width: showDoubleBorder ? '30px' : `${contentInsets.right}px`,
|
||||
}
|
||||
: {
|
||||
top: `${topInset}px`,
|
||||
insetInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
|
||||
insetInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right / 2}px)`,
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Insets } from '@/types/misc';
|
||||
import { PageInfo, TimeInfo } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -7,6 +7,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { formatNumber, formatProgress } from '@/utils/progress';
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
|
||||
interface PageInfoProps {
|
||||
bookKey: string;
|
||||
@@ -28,7 +29,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
gridInsets,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView, getViewSettings } = useReaderStore();
|
||||
const view = getView(bookKey);
|
||||
@@ -70,15 +71,59 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
})
|
||||
: '';
|
||||
|
||||
const [progressInfoMode, setProgressInfoMode] = useState(viewSettings.progressInfoMode);
|
||||
|
||||
const cycleProgressInfoModes = () => {
|
||||
const hasRemainingInfo = viewSettings.showRemainingTime || viewSettings.showRemainingPages;
|
||||
const hasProgressInfo = viewSettings.showProgressInfo;
|
||||
const modeSequence: (typeof progressInfoMode)[] = ['all', 'remaining', 'progress', 'none'];
|
||||
const currentIndex = modeSequence.indexOf(progressInfoMode);
|
||||
for (let i = 1; i <= modeSequence.length; i++) {
|
||||
const nextIndex = (currentIndex + i) % modeSequence.length;
|
||||
const nextMode = modeSequence[nextIndex]!;
|
||||
|
||||
const currentRenders = {
|
||||
remaining:
|
||||
progressInfoMode === 'all' || progressInfoMode === 'remaining' ? hasRemainingInfo : false,
|
||||
progress:
|
||||
progressInfoMode === 'all' || progressInfoMode === 'progress' ? hasProgressInfo : false,
|
||||
};
|
||||
|
||||
const nextRenders = {
|
||||
remaining: nextMode === 'all' || nextMode === 'remaining' ? hasRemainingInfo : false,
|
||||
progress: nextMode === 'all' || nextMode === 'progress' ? hasProgressInfo : false,
|
||||
};
|
||||
|
||||
const isDifferent =
|
||||
currentRenders.remaining !== nextRenders.remaining ||
|
||||
currentRenders.progress !== nextRenders.progress;
|
||||
|
||||
if (isDifferent) {
|
||||
setProgressInfoMode(nextMode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextIndex = (currentIndex + 1) % modeSequence.length;
|
||||
setProgressInfoMode(modeSequence[nextIndex]!);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'progressInfoMode', progressInfoMode);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progressInfoMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='presentation'
|
||||
className={clsx(
|
||||
'progressinfo absolute flex items-center justify-between font-sans',
|
||||
'pointer-events-none bottom-0',
|
||||
'pointer-events-auto bottom-0',
|
||||
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
|
||||
isVertical ? 'writing-vertical-rl' : 'w-full',
|
||||
isScrolled && !isVertical && 'bg-base-100',
|
||||
)}
|
||||
onClick={() => cycleProgressInfoModes()}
|
||||
aria-label={[
|
||||
progress
|
||||
? _('On {{current}} of {{total}} page', {
|
||||
@@ -98,12 +143,12 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
left: showDoubleBorder
|
||||
? `calc(${contentInsets.left}px)`
|
||||
: `calc(${Math.max(0, contentInsets.left - 32)}px)`,
|
||||
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
|
||||
width: showDoubleBorder ? '32px' : `${contentInsets.left}px`,
|
||||
height: `calc(100% - ${((contentInsets.top + contentInsets.bottom) / 2) * 3}px)`,
|
||||
}
|
||||
: {
|
||||
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
|
||||
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
|
||||
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left / 2}px)`,
|
||||
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right / 2}px)`,
|
||||
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
|
||||
}
|
||||
}
|
||||
@@ -115,15 +160,24 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
|
||||
isVertical ? 'h-full' : 'h-[52px] w-full',
|
||||
)}
|
||||
>
|
||||
{viewSettings.showRemainingTime ? (
|
||||
<span className='text-start'>{timeLeft}</span>
|
||||
) : viewSettings.showRemainingPages ? (
|
||||
<span className='text-start'>{pageLeft}</span>
|
||||
) : null}
|
||||
{viewSettings.showProgressInfo && (
|
||||
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
|
||||
{progressInfo}
|
||||
</span>
|
||||
{(progressInfoMode === 'all' || progressInfoMode === 'remaining') && (
|
||||
<>
|
||||
{viewSettings.showRemainingTime ? (
|
||||
<span className='text-start'>{timeLeft}</span>
|
||||
) : viewSettings.showRemainingPages ? (
|
||||
<span className='text-start'>{pageLeft}</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(progressInfoMode === 'all' || progressInfoMode === 'progress') && (
|
||||
<>
|
||||
{viewSettings.showProgressInfo && (
|
||||
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
|
||||
{progressInfo}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,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 { Toast } from '@/components/Toast';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { initDayjs } from '@/utils/time';
|
||||
@@ -33,8 +34,8 @@ Z-Index Layering Guide:
|
||||
---------------------------------
|
||||
99 – Window Border (Linux only)
|
||||
• Ensures the border stays on top of all UI elements.
|
||||
50 – Loading Progress / Toast Notifications / Dialogs
|
||||
• Includes Settings, About, Updater, and KOSync dialogs.
|
||||
50 – Loading Progress / Toast Notifications / Dialogs / Popups
|
||||
• Includes Settings, About, Updater, KOSync dialogs and Annotation popups.
|
||||
45 – Sidebar / Notebook (Unpinned)
|
||||
• Floats above the content but below global dialogs.
|
||||
40 – TTS Bar
|
||||
@@ -52,9 +53,10 @@ Z-Index Layering Guide:
|
||||
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { hoveredBookKey, getView } = useReaderStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { hoveredBookKey, getView } = useReaderStore();
|
||||
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
|
||||
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
|
||||
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
|
||||
const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore();
|
||||
@@ -74,6 +76,27 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
initDayjs(getLocale());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const brightness = settings.screenBrightness;
|
||||
const autoBrightness = settings.autoScreenBrightness;
|
||||
if (appService?.hasScreenBrightness && !autoBrightness && brightness >= 0) {
|
||||
setScreenBrightness(brightness / 100);
|
||||
}
|
||||
let previousBrightness = -1;
|
||||
if (appService?.isIOSApp) {
|
||||
getScreenBrightness().then((b) => {
|
||||
previousBrightness = b;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (appService?.hasScreenBrightness && !autoBrightness) {
|
||||
setScreenBrightness(previousBrightness);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService]);
|
||||
|
||||
const handleKeyDown = (event: CustomEvent) => {
|
||||
const view = getView(sideBarBookKey!);
|
||||
if (event.detail.keyName === 'Back') {
|
||||
@@ -138,6 +161,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
|
||||
<AboutWindow />
|
||||
<UpdaterWindow />
|
||||
<KOSyncSettingsWindow />
|
||||
<ReplacementRulesWindow />
|
||||
<Toast />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,8 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
|
||||
const bookIds = ids || searchParams?.get('ids') || '';
|
||||
const pathname = window.location.pathname;
|
||||
const bookIds = ids || searchParams?.get('ids') || pathname.split('/reader/')[1] || '';
|
||||
const initialIds = bookIds.split(BOOK_IDS_SEPARATOR).filter(Boolean);
|
||||
const initialBookKeys = initialIds.map((id) => `${id}-${uniqueId()}`);
|
||||
setBookKeys(initialBookKeys);
|
||||
@@ -177,7 +178,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
}
|
||||
dismissBook(bookKey);
|
||||
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
|
||||
const openWithFiles = (await parseOpenWithFiles()) || [];
|
||||
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
|
||||
if (appService?.hasWindow) {
|
||||
if (openWithFiles.length > 0) {
|
||||
tauriHandleOnCloseWindow(handleCloseBooks);
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
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;
|
||||
@@ -65,12 +65,12 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
|
||||
right: showDoubleBorder
|
||||
? `calc(${contentInsets.right}px)`
|
||||
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
|
||||
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
|
||||
width: showDoubleBorder ? '32px' : `${contentInsets.right}px`,
|
||||
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
|
||||
}
|
||||
: {
|
||||
top: `${topInset}px`,
|
||||
paddingInline: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
|
||||
paddingInline: `calc(${horizontalGap / 2}% + ${contentInsets.left / 2}px)`,
|
||||
width: '100%',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ interface AnnotationPopupProps {
|
||||
Icon: React.ElementType;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
visible?: boolean;
|
||||
}>;
|
||||
position: Position;
|
||||
trianglePosition: Position;
|
||||
@@ -51,6 +52,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
<Popup
|
||||
width={isVertical ? popupHeight : popupWidth}
|
||||
height={isVertical ? popupWidth : popupHeight}
|
||||
minHeight={isVertical ? popupWidth : popupHeight}
|
||||
position={position}
|
||||
trianglePosition={trianglePosition}
|
||||
className='selection-popup bg-gray-600 text-white'
|
||||
@@ -59,23 +61,24 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'selection-buttons flex items-center justify-between p-2',
|
||||
isVertical ? 'flex-col' : 'flex-row',
|
||||
'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={{
|
||||
height: isVertical ? popupWidth : popupHeight,
|
||||
}}
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{buttons.map((button, index) => (
|
||||
<PopupButton
|
||||
key={index}
|
||||
showTooltip={!highlightOptionsVisible}
|
||||
tooltipText={button.tooltipText}
|
||||
Icon={button.Icon}
|
||||
onClick={button.onClick}
|
||||
disabled={button.disabled}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
</Popup>
|
||||
{highlightOptionsVisible && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RiDeleteBinLine } from 'react-icons/ri';
|
||||
import { BsTranslate } from 'react-icons/bs';
|
||||
import { TbHexagonLetterD } from 'react-icons/tb';
|
||||
import { FaHeadphones } from 'react-icons/fa6';
|
||||
import { MdBuildCircle } from 'react-icons/md';
|
||||
|
||||
import * as CFI from 'foliate-js/epubcfi.js';
|
||||
import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
@@ -29,11 +30,15 @@ import { findTocItemBS } from '@/utils/toc';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { runSimpleCC } from '@/utils/simplecc';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import { addReplacementRule } from '@/services/transformers/replacement';
|
||||
import AnnotationPopup from './AnnotationPopup';
|
||||
import WiktionaryPopup from './WiktionaryPopup';
|
||||
import WikipediaPopup from './WikipediaPopup';
|
||||
import TranslatorPopup from './TranslatorPopup';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import ReplacementOptions from './ReplacementOptions';
|
||||
|
||||
import { isWordLimitExceeded } from '@/utils/wordLimit';
|
||||
|
||||
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
@@ -59,6 +64,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
|
||||
const [showWikipediaPopup, setShowWikipediaPopup] = useState(false);
|
||||
const [showDeepLPopup, setShowDeepLPopup] = useState(false);
|
||||
const [showReplacementOptions, setShowReplacementOptions] = useState(false);
|
||||
const [trianglePosition, setTrianglePosition] = useState<Position>();
|
||||
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
|
||||
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
|
||||
@@ -150,6 +156,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
setShowWiktionaryPopup(false);
|
||||
setShowWikipediaPopup(false);
|
||||
setShowDeepLPopup(false);
|
||||
setShowReplacementOptions(false);
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
@@ -384,7 +391,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
const handleCopy = (copyToNotebook = true) => {
|
||||
if (!selection || !selection.text) return;
|
||||
navigator.clipboard?.writeText(selection.text);
|
||||
setTimeout(() => {
|
||||
// Delay to ensure it won't be overridden by system clipboard actions
|
||||
navigator.clipboard?.writeText(selection.text);
|
||||
}, 100);
|
||||
handleDismissPopupAndSelection();
|
||||
|
||||
if (!copyToNotebook) return;
|
||||
@@ -519,6 +529,288 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
|
||||
};
|
||||
|
||||
// Import type for ReplacementConfig
|
||||
type ReplacementConfig = {
|
||||
replacementText: string;
|
||||
caseSensitive: boolean;
|
||||
scope: 'once' | 'book' | 'library';
|
||||
};
|
||||
|
||||
// Helper to check if selected text is a whole word (has word boundaries on both sides)
|
||||
// Updated to be more lenient: allows phrases and lines, only prevents partial word matches
|
||||
const isWholeWord = (range: Range, selectedText: string): boolean => {
|
||||
try {
|
||||
if (!selectedText || selectedText.trim().length === 0) return false;
|
||||
|
||||
// Verify the selection contains word characters
|
||||
const hasWordCharInSelection = /[a-zA-Z0-9_]/.test(selectedText);
|
||||
if (!hasWordCharInSelection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the selection contains spaces, punctuation, or multiple words, it's a phrase
|
||||
// Phrases (including lines with quotes) are always allowed for single-instance replacements
|
||||
const hasSpaces = /\s/.test(selectedText);
|
||||
const hasPunctuation = /[^\w\s]/.test(selectedText);
|
||||
const isPhrase = hasSpaces || hasPunctuation;
|
||||
|
||||
// Also allow selections that start or end with punctuation (e.g., "'tis", "off;", "look,")
|
||||
// These are valid selections where the user intentionally includes punctuation
|
||||
const startsWithPunctuation = /^[^\w\s]/.test(selectedText);
|
||||
const endsWithPunctuation = /[^\w\s]$/.test(selectedText);
|
||||
const hasBoundaryPunctuation = startsWithPunctuation || endsWithPunctuation;
|
||||
|
||||
if (isPhrase || hasBoundaryPunctuation) {
|
||||
// For phrases or selections with boundary punctuation, we allow them
|
||||
// The only thing we want to prevent is selecting "and" inside "England"
|
||||
return true;
|
||||
}
|
||||
|
||||
// For single words, check boundaries to prevent partial word matches
|
||||
// Get characters immediately before and after the selection
|
||||
let charBefore = '';
|
||||
let charAfter = '';
|
||||
|
||||
try {
|
||||
// Get character before
|
||||
const startNode = range.startContainer;
|
||||
if (startNode.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
||||
const textNode = startNode as Text;
|
||||
charBefore = textNode.textContent?.charAt(range.startOffset - 1) || '';
|
||||
} else if (startNode.nodeType === Node.TEXT_NODE && range.startOffset === 0) {
|
||||
// Check previous sibling text node
|
||||
let prevSibling = startNode.previousSibling;
|
||||
while (prevSibling && prevSibling.nodeType !== Node.TEXT_NODE) {
|
||||
prevSibling = prevSibling.previousSibling;
|
||||
}
|
||||
if (prevSibling && prevSibling.nodeType === Node.TEXT_NODE) {
|
||||
const prevText = (prevSibling as Text).textContent || '';
|
||||
charBefore = prevText.charAt(prevText.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Get character after
|
||||
const endNode = range.endContainer;
|
||||
if (endNode.nodeType === Node.TEXT_NODE) {
|
||||
const textNode = endNode as Text;
|
||||
const textContent = textNode.textContent || '';
|
||||
if (range.endOffset < textContent.length) {
|
||||
charAfter = textContent.charAt(range.endOffset);
|
||||
} else {
|
||||
// Check next sibling text node
|
||||
let nextSibling = textNode.nextSibling;
|
||||
while (nextSibling && nextSibling.nodeType !== Node.TEXT_NODE) {
|
||||
nextSibling = nextSibling.nextSibling;
|
||||
}
|
||||
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) {
|
||||
const nextText = (nextSibling as Text).textContent || '';
|
||||
charAfter = nextText.charAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If we can't determine boundaries for a single word, be lenient
|
||||
// This handles edge cases with complex HTML
|
||||
console.warn('[isWholeWord] Error checking boundaries:', e);
|
||||
return true; // Allow if we can't verify (better to allow than reject valid selections)
|
||||
}
|
||||
|
||||
// Word characters are: letters, digits, and underscore [a-zA-Z0-9_]
|
||||
const isWordChar = (char: string) => /[a-zA-Z0-9_]/.test(char);
|
||||
|
||||
// Check boundaries for single words
|
||||
// Empty means we're at start/end of text (valid boundary)
|
||||
const hasBoundaryBefore = !charBefore || !isWordChar(charBefore);
|
||||
const hasBoundaryAfter = !charAfter || !isWordChar(charAfter);
|
||||
|
||||
const isValid = hasBoundaryBefore && hasBoundaryAfter;
|
||||
|
||||
if (!isValid) {
|
||||
console.log('[isWholeWord] Not a whole word:', {
|
||||
selectedText,
|
||||
charBefore: charBefore || '(start)',
|
||||
charAfter: charAfter || '(end)',
|
||||
hasBoundaryBefore,
|
||||
hasBoundaryAfter,
|
||||
});
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (e) {
|
||||
console.warn('Failed to check whole word:', e);
|
||||
// On error, be lenient - allow selections with word characters
|
||||
// This prevents false rejections for complex selections (quotes, multi-node, etc.)
|
||||
return /[a-zA-Z0-9_]/.test(selectedText);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to count which occurrence of a pattern was selected (using whole-word matching)
|
||||
const getOccurrenceIndex = (range: Range, pattern: string): number => {
|
||||
try {
|
||||
const doc = range.startContainer.ownerDocument;
|
||||
if (!doc || !doc.body) return 0;
|
||||
|
||||
// Create a range from start of body to start of selection
|
||||
const beforeRange = doc.createRange();
|
||||
beforeRange.setStart(doc.body, 0);
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
// Get text before selection and count occurrences using whole-word matching
|
||||
const textBefore = beforeRange.toString();
|
||||
// Escape pattern and add word boundaries for whole-word matching
|
||||
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const wholeWordPattern = `\\b${escapedPattern}\\b`;
|
||||
const regex = new RegExp(wholeWordPattern, 'g');
|
||||
const matches = textBefore.match(regex);
|
||||
|
||||
return matches ? matches.length : 0;
|
||||
} catch (e) {
|
||||
console.warn('Failed to get occurrence index:', e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplacementConfirm = async (config: ReplacementConfig) => {
|
||||
if (!selection || !selection.text) return;
|
||||
|
||||
const { replacementText, caseSensitive, scope } = config;
|
||||
|
||||
console.log('Replacement confirmed:', {
|
||||
originalText: selection.text,
|
||||
replacementText,
|
||||
caseSensitive,
|
||||
scope,
|
||||
});
|
||||
|
||||
try {
|
||||
if (scope === 'once') {
|
||||
// For single-instance: direct DOM modification + persistent rule
|
||||
const range = selection.range;
|
||||
if (range) {
|
||||
// Validate that the selection is a whole word
|
||||
// Single-instance replacements only work on whole words to prevent
|
||||
// replacing substrings inside larger words (e.g., "and" in "England")
|
||||
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;
|
||||
}
|
||||
|
||||
// Get which occurrence this is BEFORE modifying the DOM
|
||||
// Use whole-word matching to count occurrences correctly
|
||||
const occurrenceIndex = getOccurrenceIndex(range, selection.text);
|
||||
const sectionHref = progress?.sectionHref;
|
||||
|
||||
// Directly modify DOM for immediate effect
|
||||
// Note: createTextNode automatically escapes HTML entities, so angle brackets will be preserved
|
||||
range.deleteContents();
|
||||
const textNode = document.createTextNode(replacementText);
|
||||
range.insertNode(textNode);
|
||||
|
||||
// Create rule with occurrence tracking for persistence
|
||||
await addReplacementRule(
|
||||
envConfig,
|
||||
bookKey,
|
||||
{
|
||||
pattern: selection.text,
|
||||
replacement: replacementText,
|
||||
isRegex: false,
|
||||
enabled: true,
|
||||
caseSensitive,
|
||||
singleInstance: true,
|
||||
sectionHref,
|
||||
occurrenceIndex,
|
||||
},
|
||||
'single',
|
||||
);
|
||||
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: 'Replacement applied! Will persist on refresh.',
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
setShowReplacementOptions(false);
|
||||
handleDismissPopupAndSelection();
|
||||
}
|
||||
} else {
|
||||
// For book-wide and global: use the transformer approach
|
||||
const backendScope = scope === 'book' ? 'book' : 'global';
|
||||
const range = selection.range;
|
||||
const isValidWholeWord = range ? isWholeWord(range, selection.text) : false;
|
||||
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;
|
||||
}
|
||||
await addReplacementRule(
|
||||
envConfig,
|
||||
bookKey,
|
||||
{
|
||||
pattern: selection.text,
|
||||
replacement: replacementText,
|
||||
isRegex: false,
|
||||
enabled: true,
|
||||
caseSensitive,
|
||||
singleInstance: false,
|
||||
wholeWord: true,
|
||||
},
|
||||
backendScope as 'book' | 'global',
|
||||
);
|
||||
|
||||
const scopeLabels = {
|
||||
book: 'this book',
|
||||
library: 'your library',
|
||||
};
|
||||
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: `Replacement applied to ${scopeLabels[scope]}! Reloading...`,
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
setShowReplacementOptions(false);
|
||||
handleDismissPopupAndSelection();
|
||||
|
||||
// Reload the book view to apply the replacement
|
||||
const { recreateViewer } = useReaderStore.getState();
|
||||
await recreateViewer(envConfig, bookKey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply replacement:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: 'Failed to apply replacement. Please try again.',
|
||||
timeout: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowReplacementOptions = () => {
|
||||
if (!selection || !selection.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWordLimitExceeded(selection.text)) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
message: 'Word limit exceeded. Please select 30 words or fewer.',
|
||||
timeout: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setShowReplacementOptions(!showReplacementOptions);
|
||||
};
|
||||
|
||||
// Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden
|
||||
useShortcuts(
|
||||
{
|
||||
@@ -668,6 +960,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onClick: handleSpeakText,
|
||||
disabled: bookData.book?.format === 'PDF',
|
||||
},
|
||||
{
|
||||
tooltipText: 'Text Replacement',
|
||||
Icon: MdBuildCircle,
|
||||
onClick: handleShowReplacementOptions,
|
||||
disabled: bookData.book?.format !== 'EPUB',
|
||||
visible: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -720,6 +1019,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
onDismiss={handleDismissPopupAndSelection}
|
||||
/>
|
||||
)}
|
||||
{showReplacementOptions && trianglePosition && annotPopupPosition && (
|
||||
<ReplacementOptions
|
||||
isVertical={viewSettings.vertical}
|
||||
style={{
|
||||
height: 'auto',
|
||||
left: `${annotPopupPosition.point.x}px`,
|
||||
top: `${
|
||||
annotPopupPosition.point.y +
|
||||
(annotPopupHeight + 16) * (trianglePosition.dir === 'up' ? -1 : 1)
|
||||
}px`,
|
||||
}}
|
||||
selectedText={selection?.text || ''}
|
||||
onConfirm={handleReplacementConfirm}
|
||||
onClose={() => setShowReplacementOptions(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
'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;
|
||||
@@ -202,23 +202,20 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between bg-gray-600 px-4'>
|
||||
{provider && !loading && (
|
||||
<div className='line-clamp-1 text-xs opacity-60'>
|
||||
{error
|
||||
? ''
|
||||
: _('Translated by {{provider}}.', {
|
||||
provider: providers.find((p) => p.name === provider)?.label,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className='ml-auto'>
|
||||
<Select
|
||||
className='bg-gray-600 text-white/75'
|
||||
value={provider}
|
||||
onChange={handleProviderChange}
|
||||
options={providers.map(({ name: value, label }) => ({ value, label }))}
|
||||
/>
|
||||
<div className='line-clamp-1 text-xs opacity-60'>
|
||||
{provider &&
|
||||
!loading &&
|
||||
!error &&
|
||||
_('Translated by {{provider}}.', {
|
||||
provider: providers.find((p) => p.name === provider)?.label,
|
||||
})}
|
||||
</div>
|
||||
<Select
|
||||
className='bg-gray-600 text-white/75'
|
||||
value={provider}
|
||||
onChange={handleProviderChange}
|
||||
options={providers.map(({ name: value, label }) => ({ value, label }))}
|
||||
/>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
'sm:h-[52px] sm:justify-center',
|
||||
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
|
||||
'transition-[opacity,transform] duration-300',
|
||||
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
|
||||
window.innerWidth < 640 ? 'fixed' : 'absolute',
|
||||
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
|
||||
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
|
||||
isHoveredAnim && 'hover-bar-anim',
|
||||
@@ -214,6 +214,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
|
||||
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
|
||||
);
|
||||
|
||||
const isMobile = appService?.isMobile || window.innerWidth < 640;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hover trigger area */}
|
||||
@@ -222,10 +224,10 @@ 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' : '',
|
||||
)}
|
||||
onClick={() => setHoveredBookKey(bookKey)}
|
||||
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
|
||||
onMouseEnter={() => !isMobile && setHoveredBookKey(bookKey)}
|
||||
onTouchStart={() => !isMobile && setHoveredBookKey(bookKey)}
|
||||
/>
|
||||
|
||||
{/* Main footer container */}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { uniqueId } from '@/utils/misc';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getBookDirFromLanguage } from '@/utils/book';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import BooknoteItem from '../sidebar/BooknoteItem';
|
||||
import NotebookHeader from './Header';
|
||||
@@ -88,7 +89,9 @@ const Notebook: React.FC = ({}) => {
|
||||
|
||||
const handleTogglePin = () => {
|
||||
toggleNotebookPin();
|
||||
settings.globalReadSettings.isNotebookPinned = !isNotebookPinned;
|
||||
const globalReadSettings = settings.globalReadSettings;
|
||||
const newGlobalReadSettings = { ...globalReadSettings, isNotebookPinned: !isNotebookPinned };
|
||||
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
|
||||
};
|
||||
|
||||
const handleClickOverlay = () => {
|
||||
|
||||
@@ -14,6 +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 { FIXED_LAYOUT_FORMATS } from '@/types/book';
|
||||
import useBooksManager from '../../hooks/useBooksManager';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
@@ -78,6 +79,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
||||
setKOSyncSettingsWindowVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const showReplacementRulesWindow = () => {
|
||||
setReplacementRulesWindowVisible(true);
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
const handlePullKOSync = () => {
|
||||
eventDispatcher.dispatch('pull-kosync', { bookKey: sideBarBookKey });
|
||||
setIsDropdownOpen?.(false);
|
||||
@@ -140,6 +145,7 @@ 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} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
@@ -36,6 +37,7 @@ interface TTSControlProps {
|
||||
const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
@@ -173,6 +175,14 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
if (!bookData || !bookData.book) return;
|
||||
const { title, author, coverImageUrl } = bookData.book;
|
||||
|
||||
const handleNeedAuth = () => {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('Please log in to use advanced TTS features.'),
|
||||
type: 'error',
|
||||
timeout: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSpeakMark = (e: Event) => {
|
||||
const progress = getProgress(bookKey);
|
||||
const { sectionLabel } = progress || {};
|
||||
@@ -243,9 +253,11 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
}
|
||||
};
|
||||
|
||||
ttsController.addEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
return () => {
|
||||
ttsController.removeEventListener('tts-need-auth', handleNeedAuth);
|
||||
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
|
||||
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
|
||||
};
|
||||
@@ -326,7 +338,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
setTtsClientsInitialized(false);
|
||||
|
||||
setShowIndicator(true);
|
||||
const ttsController = new TTSController(appService, view);
|
||||
const ttsController = new TTSController(appService, view, !!user?.id);
|
||||
await ttsController.init();
|
||||
await ttsController.initViewTTS(viewSettings.ttsHighlightOptions);
|
||||
const ssml = view.tts?.from(ttsFromRange);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useEffect } from 'react';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
|
||||
const useSidebar = (initialWidth: string, isPinned: boolean) => {
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const {
|
||||
sideBarWidth,
|
||||
@@ -30,8 +33,10 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
|
||||
|
||||
const handleSideBarTogglePin = () => {
|
||||
toggleSideBarPin();
|
||||
settings.globalReadSettings.isSideBarPinned = !isSideBarPinned;
|
||||
if (isSideBarPinned && isSideBarVisible) setSideBarVisible(false);
|
||||
const globalReadSettings = settings.globalReadSettings;
|
||||
const newGlobalReadSettings = { ...globalReadSettings, isSideBarPinned: !isSideBarPinned };
|
||||
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -176,27 +176,22 @@ export const handleClick = (
|
||||
lastClickTime = now;
|
||||
|
||||
const postSingleClick = () => {
|
||||
let element: HTMLElement | null = event.target as HTMLElement;
|
||||
while (element) {
|
||||
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
element.classList.contains('js_readerFooterNote') ||
|
||||
element.classList.contains('zhangyue-footnote')
|
||||
) {
|
||||
eventDispatcher.dispatch('footnote-popup', {
|
||||
bookKey,
|
||||
element,
|
||||
footnote:
|
||||
element.getAttribute('data-wr-footernote') ||
|
||||
element.getAttribute('zy-footnote') ||
|
||||
element.getAttribute('alt') ||
|
||||
'',
|
||||
});
|
||||
return;
|
||||
}
|
||||
element = element.parentElement;
|
||||
const element = event.target as HTMLElement | null;
|
||||
if (element?.closest('sup, a, audio, video')) {
|
||||
return;
|
||||
}
|
||||
const footnote = element?.closest('.js_readerFooterNote, .zhangyue-footnote');
|
||||
if (footnote) {
|
||||
eventDispatcher.dispatch('footnote-popup', {
|
||||
bookKey,
|
||||
element: footnote,
|
||||
footnote:
|
||||
footnote.getAttribute('data-wr-footernote') ||
|
||||
footnote.getAttribute('zy-footnote') ||
|
||||
footnote.getAttribute('alt') ||
|
||||
'',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// if long hold is detected, we don't want to send single click event
|
||||
|
||||
@@ -47,6 +47,7 @@ const DeleteConfirmationModal: React.FC<DeleteConfirmationModalProps> = ({
|
||||
|
||||
interface AccountActionsProps {
|
||||
userPlan: UserPlan;
|
||||
iapAvailable: boolean;
|
||||
onLogout: () => void;
|
||||
onResetPassword: () => void;
|
||||
onUpdateEmail: () => void;
|
||||
@@ -58,6 +59,7 @@ interface AccountActionsProps {
|
||||
|
||||
const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
userPlan,
|
||||
iapAvailable,
|
||||
onLogout,
|
||||
onResetPassword,
|
||||
onUpdateEmail,
|
||||
@@ -89,7 +91,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-col gap-4 md:grid md:grid-cols-2 lg:grid-cols-3'>
|
||||
{appService?.hasIAP ? (
|
||||
{appService?.hasIAP && iapAvailable ? (
|
||||
<button
|
||||
onClick={onRestorePurchase}
|
||||
className='w-full rounded-lg bg-blue-100 px-6 py-3 font-medium text-blue-600 transition-colors hover:bg-blue-200 md:w-auto'
|
||||
|
||||
@@ -52,7 +52,7 @@ const ProfilePage = () => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { token, user } = useAuth();
|
||||
const { token, user, refresh } = useAuth();
|
||||
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -73,7 +73,7 @@ const ProfilePage = () => {
|
||||
const { handleLogout, handleResetPassword, handleUpdateEmail, handleConfirmDelete } =
|
||||
useUserActions();
|
||||
|
||||
const { availablePlans } = useAvailablePlans({
|
||||
const { availablePlans, iapAvailable } = useAvailablePlans({
|
||||
hasIAP: appService?.hasIAP || false,
|
||||
onError: useCallback(
|
||||
(message: string) => {
|
||||
@@ -91,6 +91,7 @@ const ProfilePage = () => {
|
||||
setShowEmbeddedCheckout(false);
|
||||
} else if (showStorageManager) {
|
||||
setShowStorageManager(false);
|
||||
refresh();
|
||||
} else {
|
||||
navigateToLibrary(router);
|
||||
}
|
||||
@@ -283,12 +284,17 @@ const ProfilePage = () => {
|
||||
<PlansComparison
|
||||
availablePlans={availablePlans}
|
||||
userPlan={userProfilePlan}
|
||||
onSubscribe={appService.hasIAP ? handleIAPSubscribe : handleStripeSubscribe}
|
||||
onSubscribe={
|
||||
appService.hasIAP && iapAvailable
|
||||
? handleIAPSubscribe
|
||||
: handleStripeSubscribe
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<AccountActions
|
||||
userPlan={userProfilePlan}
|
||||
iapAvailable={iapAvailable}
|
||||
onLogout={handleLogout}
|
||||
onResetPassword={handleResetPassword}
|
||||
onUpdateEmail={handleUpdateEmail}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import Stripe from 'stripe';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { getAPIBaseUrl, getNodeAPIBaseUrl } from '@/services/environment';
|
||||
import { getAccessToken } from '@/utils/access';
|
||||
import { supabase } from '@/utils/supabase';
|
||||
import { PlanType } from '@/types/quota';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import { VerifiedIAP } from '@/libs/payment/iap/types';
|
||||
import Spinner from '@/components/Spinner';
|
||||
|
||||
const STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
|
||||
const APPLE_IAP_VERIFY_URL = `${getNodeAPIBaseUrl()}/apple/iap-verify`;
|
||||
@@ -34,6 +34,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { refresh } = useAuth();
|
||||
const payment = searchParams?.get('payment');
|
||||
const platform = searchParams?.get('platform');
|
||||
const sessionId = searchParams?.get('session_id');
|
||||
@@ -86,9 +87,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
currency: session.currency || undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch {}
|
||||
refresh();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch session status:', error);
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
@@ -138,9 +137,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
planType: purchase.planType,
|
||||
});
|
||||
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch {}
|
||||
refresh();
|
||||
} catch (error) {
|
||||
console.error('Failed to verify IAP transaction:', error);
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
@@ -198,9 +195,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
currency: purchase.currency,
|
||||
});
|
||||
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch {}
|
||||
refresh();
|
||||
} catch (error) {
|
||||
console.error('Failed to verify Android IAP transaction:', error);
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, memo } from 'react';
|
||||
|
||||
interface CachedImageProps {
|
||||
src: string | null;
|
||||
@@ -15,7 +15,10 @@ interface CachedImageProps {
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CachedImage({
|
||||
const imageUrlCache = new Map<string, string>();
|
||||
const loadingPromises = new Map<string, Promise<string>>();
|
||||
|
||||
const CachedImageComponent = ({
|
||||
src,
|
||||
alt,
|
||||
fill,
|
||||
@@ -25,9 +28,11 @@ export function CachedImage({
|
||||
height,
|
||||
onGenerateCachedImageUrl,
|
||||
fallback,
|
||||
}: CachedImageProps) {
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
}: CachedImageProps) => {
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(() => {
|
||||
return src ? imageUrlCache.get(src) || null : null;
|
||||
});
|
||||
const [loading, setLoading] = useState(() => !src || !imageUrlCache.has(src));
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,6 +43,15 @@ export function CachedImage({
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = imageUrlCache.get(src);
|
||||
if (cached) {
|
||||
setTimeout(() => {
|
||||
setCachedUrl(cached);
|
||||
setLoading(false);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadImage = async () => {
|
||||
@@ -45,9 +59,20 @@ export function CachedImage({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = await onGenerateCachedImageUrl(src);
|
||||
let loadPromise = loadingPromises.get(src);
|
||||
|
||||
if (!loadPromise) {
|
||||
loadPromise = onGenerateCachedImageUrl(src);
|
||||
loadingPromises.set(src, loadPromise);
|
||||
loadPromise.finally(() => {
|
||||
loadingPromises.delete(src);
|
||||
});
|
||||
}
|
||||
|
||||
const url = await loadPromise;
|
||||
|
||||
if (!cancelled) {
|
||||
imageUrlCache.set(src, url);
|
||||
setCachedUrl(url);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -78,7 +103,6 @@ export function CachedImage({
|
||||
if (fallback) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full items-center justify-center ${className || ''}`}>
|
||||
<div className='text-base-content/30'>
|
||||
@@ -109,4 +133,23 @@ export function CachedImage({
|
||||
sizes={sizes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const arePropsEqual = (prevProps: CachedImageProps, nextProps: CachedImageProps) => {
|
||||
return (
|
||||
prevProps.src === nextProps.src &&
|
||||
prevProps.alt === nextProps.alt &&
|
||||
prevProps.fill === nextProps.fill &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.sizes === nextProps.sizes &&
|
||||
prevProps.width === nextProps.width &&
|
||||
prevProps.height === nextProps.height
|
||||
);
|
||||
};
|
||||
|
||||
export const CachedImage = memo(CachedImageComponent, arePropsEqual);
|
||||
|
||||
export const clearImageCache = () => {
|
||||
imageUrlCache.clear();
|
||||
loadingPromises.clear();
|
||||
};
|
||||
|
||||
@@ -45,9 +45,6 @@ const Popup = ({
|
||||
availableHeight = window.innerHeight - trianglePosition.point.y - popupPadding;
|
||||
}
|
||||
maxHeight = Math.min(maxHeight || availableHeight, availableHeight);
|
||||
if (minHeight) {
|
||||
minHeight = Math.min(minHeight, availableHeight);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -79,11 +76,11 @@ const Popup = ({
|
||||
...position,
|
||||
point: {
|
||||
...position.point,
|
||||
y: trianglePosition.point.y - containerHeight,
|
||||
y: Math.max(popupPadding, trianglePosition.point.y - containerHeight),
|
||||
},
|
||||
};
|
||||
setAdjustedPosition(newPosition);
|
||||
}, [position, trianglePosition, childrenHeight]);
|
||||
}, [position, trianglePosition, popupPadding, childrenHeight]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -91,7 +88,7 @@ const Popup = ({
|
||||
id='popup-container'
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'bg-base-300 absolute rounded-lg font-sans',
|
||||
'bg-base-300 absolute z-50 rounded-lg font-sans',
|
||||
trianglePosition?.dir !== 'up' && 'shadow-xl',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { CSPostHogProvider } from '@/context/PHContext';
|
||||
import { SyncProvider } from '@/context/SyncContext';
|
||||
import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
||||
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
||||
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
|
||||
@@ -21,7 +20,6 @@ import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { applyUILanguage } = useSettingsStore();
|
||||
const { setScreenBrightness } = useDeviceControlStore();
|
||||
const { applyBackgroundTexture } = useBackgroundTexture();
|
||||
const { applyEinkMode } = useEinkMode();
|
||||
const iconSize = useDefaultIconSize();
|
||||
@@ -54,25 +52,13 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
appService.loadSettings().then((settings) => {
|
||||
const globalViewSettings = settings.globalViewSettings;
|
||||
applyUILanguage(globalViewSettings.uiLanguage);
|
||||
const brightness = settings.screenBrightness;
|
||||
const autoBrightness = settings.autoScreenBrightness;
|
||||
if (appService.hasScreenBrightness && !autoBrightness && brightness >= 0) {
|
||||
setScreenBrightness(brightness / 100);
|
||||
}
|
||||
applyBackgroundTexture(envConfig, globalViewSettings);
|
||||
if (globalViewSettings.isEink) {
|
||||
applyEinkMode(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [
|
||||
envConfig,
|
||||
appService,
|
||||
applyUILanguage,
|
||||
setScreenBrightness,
|
||||
applyBackgroundTexture,
|
||||
applyEinkMode,
|
||||
]);
|
||||
}, [envConfig, appService, applyUILanguage, applyBackgroundTexture, applyEinkMode]);
|
||||
|
||||
// Make sure appService is available in all children components
|
||||
if (!appService) return;
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
@@ -91,8 +92,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, user, login, logout }}>{children}</AuthContext.Provider>
|
||||
<AuthContext.Provider value={{ token, user, login, logout, refresh }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isWebAppPlatform, hasCli } from '@/services/environment';
|
||||
import { AppService } from '@/types/system';
|
||||
import { getCurrent } from '@tauri-apps/plugin-deep-link';
|
||||
|
||||
declare global {
|
||||
@@ -35,14 +36,18 @@ const parseCLIOpenWithFiles = async () => {
|
||||
return files;
|
||||
};
|
||||
|
||||
const parseIntentOpenWithFiles = async () => {
|
||||
const parseIntentOpenWithFiles = async (appService: AppService | null) => {
|
||||
const urls = await getCurrent();
|
||||
if (urls && urls.length > 0) {
|
||||
console.log('Intent Open with URL:', urls);
|
||||
return urls
|
||||
.map((url) => {
|
||||
if (url.startsWith('file://')) {
|
||||
return decodeURI(url.replace('file://', ''));
|
||||
if (appService?.isIOSApp) {
|
||||
return decodeURI(url);
|
||||
} else {
|
||||
return decodeURI(url.replace('file://', ''));
|
||||
}
|
||||
} else if (url.startsWith('content://')) {
|
||||
return url;
|
||||
} else {
|
||||
@@ -55,7 +60,7 @@ const parseIntentOpenWithFiles = async () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseOpenWithFiles = async () => {
|
||||
export const parseOpenWithFiles = async (appService: AppService | null) => {
|
||||
if (isWebAppPlatform()) return [];
|
||||
|
||||
let files = parseWindowOpenWithFiles();
|
||||
@@ -63,7 +68,7 @@ export const parseOpenWithFiles = async () => {
|
||||
files = await parseCLIOpenWithFiles();
|
||||
}
|
||||
if (!files || files.length === 0) {
|
||||
files = await parseIntentOpenWithFiles();
|
||||
files = await parseIntentOpenWithFiles(appService);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const DEFAULT_SHORTCUTS = {
|
||||
onSwitchSideBar: ['ctrl+Tab', 'opt+Tab', 'alt+Tab'],
|
||||
onToggleSideBar: ['s', 'F9'],
|
||||
onToggleSideBar: ['s'],
|
||||
onToggleNotebook: ['n'],
|
||||
onShowSearchBar: ['ctrl+f', 'cmd+f'],
|
||||
onToggleScrollMode: ['shift+j'],
|
||||
@@ -17,7 +17,7 @@ const DEFAULT_SHORTCUTS = {
|
||||
onWikipediaSelection: ['ctrl+w', 'cmd+w'],
|
||||
onReadAloudSelection: ['ctrl+r', 'cmd+r'],
|
||||
onOpenFontLayoutSettings: ['shift+f', 'ctrl+,', 'cmd+,'],
|
||||
onOpenBooks: ['ctrl+o', 'cmd+o'],
|
||||
onOpenBooks: ['ctrl+o'],
|
||||
onReloadPage: ['shift+r'],
|
||||
onToggleFullscreen: ['F11'],
|
||||
onCloseWindow: ['ctrl+w', 'cmd+w'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchAndTransformIAPPlans } from '@/libs/payment/iap/client';
|
||||
import { fetchAndTransformIAPPlans, isIAPAvailable } from '@/libs/payment/iap/client';
|
||||
import { fetchStripePlans } from '@/libs/payment/stripe/client';
|
||||
import { AvailablePlan } from '@/types/quota';
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
@@ -20,6 +20,7 @@ interface UseAvailablePlansParams {
|
||||
|
||||
export const useAvailablePlans = ({ hasIAP, onError }: UseAvailablePlansParams) => {
|
||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlan[]>([]);
|
||||
const [iapAvailable, setIapAvailable] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
@@ -29,9 +30,10 @@ export const useAvailablePlans = ({ hasIAP, onError }: UseAvailablePlansParams)
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (hasIAP) {
|
||||
if (hasIAP && (await isIAPAvailable())) {
|
||||
const plans = await fetchAndTransformIAPPlans(IAP_PRODUCT_IDS);
|
||||
setAvailablePlans(plans);
|
||||
setIapAvailable(true);
|
||||
} else {
|
||||
const plans = await fetchStripePlans();
|
||||
setAvailablePlans(plans);
|
||||
@@ -52,5 +54,5 @@ export const useAvailablePlans = ({ hasIAP, onError }: UseAvailablePlansParams)
|
||||
fetchPlans();
|
||||
}, [hasIAP, onError]);
|
||||
|
||||
return { availablePlans, loading, error };
|
||||
return { availablePlans, iapAvailable, loading, error };
|
||||
};
|
||||
|
||||
@@ -14,6 +14,10 @@ interface SingleInstancePayload {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface OpenFilesPayload {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
interface SharedIntentPayload {
|
||||
urls: string[];
|
||||
}
|
||||
@@ -36,7 +40,11 @@ export function useOpenWithBooks() {
|
||||
const filePaths = [];
|
||||
for (let url of urls) {
|
||||
if (url.startsWith('file://')) {
|
||||
url = decodeURI(url.replace('file://', ''));
|
||||
if (appService?.isIOSApp) {
|
||||
url = decodeURI(url);
|
||||
} else {
|
||||
url = decodeURI(url.replace('file://', ''));
|
||||
}
|
||||
}
|
||||
if (!/^(https?:|data:|blob:)/i.test(url)) {
|
||||
filePaths.push(url);
|
||||
@@ -73,20 +81,39 @@ export function useOpenWithBooks() {
|
||||
if (listenedOpenWithBooks.current) return;
|
||||
listenedOpenWithBooks.current = true;
|
||||
|
||||
const unlistenDeeplink = getCurrentWindow().listen('single-instance', ({ event, payload }) => {
|
||||
console.log('Received deep link:', event, payload);
|
||||
const { args } = payload as SingleInstancePayload;
|
||||
if (args?.[1]) {
|
||||
handleOpenWithFileUrl([args[1]]);
|
||||
}
|
||||
});
|
||||
// For Windows/Linux deep link and macOS open-file event
|
||||
const unlistenDeeplink = getCurrentWindow().listen<SingleInstancePayload>(
|
||||
'single-instance',
|
||||
({ payload }) => {
|
||||
console.log('Received deep link:', payload);
|
||||
const { args } = payload;
|
||||
if (args?.[1]) {
|
||||
handleOpenWithFileUrl([args[1]]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// macOS in-app open-files event
|
||||
const unlistenOpenFiles = getCurrentWindow().listen<OpenFilesPayload>(
|
||||
'open-files',
|
||||
({ payload }) => {
|
||||
console.log('Received open files:', payload);
|
||||
const { files } = payload;
|
||||
if (files && files.length > 0) {
|
||||
handleOpenWithFileUrl(files);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// For Android "Share to Readest" intent
|
||||
let unlistenSharedIntent: Promise<PluginListener> | null = null;
|
||||
// FIXME: register/unregister plugin listeniner on iOS might cause app freeze for unknown reason
|
||||
// so we only register it on Android for now to support "Shared to Readest" feature
|
||||
if (appService?.isAndroidApp) {
|
||||
unlistenSharedIntent = initializeListeners();
|
||||
}
|
||||
|
||||
// iOS Open with URL event
|
||||
const listenOpenWithFiles = async () => {
|
||||
return await onOpenUrl((urls) => {
|
||||
handleOpenWithFileUrl(urls);
|
||||
@@ -95,6 +122,7 @@ export function useOpenWithBooks() {
|
||||
const unlistenOpenUrl = listenOpenWithFiles();
|
||||
return () => {
|
||||
unlistenDeeplink.then((f) => f());
|
||||
unlistenOpenFiles.then((f) => f());
|
||||
unlistenOpenUrl.then((f) => f());
|
||||
unlistenSharedIntent?.then((f) => f.unregister());
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ErrorCodes, getTranslator, getTranslators, TranslatorName } from '@/ser
|
||||
import { getFromCache, storeInCache, UseTranslatorOptions } from '@/services/translators';
|
||||
import { polish, preprocess } from '@/services/translators';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import { useTranslation } from './useTranslation';
|
||||
|
||||
export function useTranslator({
|
||||
@@ -42,7 +43,7 @@ export function useTranslator({
|
||||
options?: { source?: string; target?: string; useCache?: boolean },
|
||||
): Promise<string[]> => {
|
||||
const sourceLanguage = options?.source || sourceLang;
|
||||
const targetLanguage = options?.target || targetLang;
|
||||
const targetLanguage = options?.target || targetLang || getLocale();
|
||||
const useCache = options?.useCache ?? false;
|
||||
const textsToTranslate = enablePreprocessing ? preprocess(input) : input;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user