Compare commits

...

101 Commits

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

* fix: implement security scope check in rust recursive scanner

* refactor and format code

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-01-20 06:22:04 +01:00
Huang Xin d9a6cffe78 feat(discord): support show reading status with Discord Rich Presence, closes #1538 (#2998) 2026-01-19 17:42:19 +01:00
Huang Xin 1704736bc8 feat(account): support social login with Discord account (#2995) 2026-01-19 05:22:17 +01:00
Huang Xin 6ce9a263ee fix(a11y): add missing aria labels for some buttons on TTS panel (#2994) 2026-01-19 04:29:02 +01:00
Huang Xin 038eca4267 fix(eink): resolved an issue where the dropdown menu would occasionally fail to expand on some Eink devices (#2991) 2026-01-18 19:57:09 +01:00
Huang Xin f0722ec0fe feat(bookshelf): show one line of book description in list view of the bookshelf, closes #2955 (#2990) 2026-01-18 17:16:54 +01:00
Huang Xin fd299a61a7 feat(notes): export notes with custom template, closes #1734 (#2989) 2026-01-18 16:56:32 +01:00
Huang Xin 36b7386e30 fix(portable): in-app update the portable version app, closes #2983 (#2988) 2026-01-18 11:20:15 +01:00
Huang Xin 2670b0dc0b fix(linux): use new AppImage format on Linux, closes #190 (#2985)
See https://github.com/tauri-apps/tauri/pull/12491
2026-01-18 09:50:51 +01:00
Huang Xin c44705e269 perf(pdf): pre-render next page for PDFs (#2984)
This should close #1911 and #13.
2026-01-18 08:58:00 +01:00
Huang Xin 7e618d456e chore: bump pdf.js to the latest version (#2982) 2026-01-17 13:45:34 +01:00
Huang Xin b28ac99a9e feat(pdf): support panning on PDFs (#2981)
This also closes #2978 and closes #2875.
2026-01-17 09:11:33 +01:00
Huang Xin 2d7d6b08a9 compat(opds): parse attachment filename from download requests, closes #2969 (#2976) 2026-01-16 17:13:30 +01:00
Huang Xin b1419f9c01 chore: bump tauri to latest dev (#2975) 2026-01-16 14:52:22 +01:00
Huang Xin 32ea42a835 fix: remove non-breaking space in book title (#2974) 2026-01-16 12:02:41 +01:00
Huang Xin 6083de3293 fix(import): read permissions of nested directories when importing books, closes #2954 (#2972) 2026-01-16 08:04:07 +01:00
Huang Xin 1c9cfa49b3 fix(flathub): use custom dbus id for single instance on Linux (#2971) 2026-01-16 05:59:25 +01:00
Huang Xin f85d6d4293 fix(eink): avoid scrolling if animation is turned off or in eink mode, closes #2957 (#2967) 2026-01-15 17:37:38 +01:00
Huang Xin 017d0b0b39 fix(tts): fix setting playback rate on Linux, closes #2950 (#2966) 2026-01-15 17:29:26 +01:00
Huang Xin 3146ae48a7 fix(proofread): support replace text with space (#2965) 2026-01-15 14:10:05 +01:00
Huang Xin 7f26e45ae7 feat(sync): cloud deletion in transfer queue (#2964) 2026-01-15 13:50:24 +01:00
Huang Xin 7d97826e4b feat(tts): replace words for TTS, closes #2057 (#2952) 2026-01-14 16:42:11 +01:00
Huang Xin 9c9c79176d feat(bookshelf): add an option to sort books by publish date, closes #2925 (#2949) 2026-01-14 13:40:07 +01:00
Huang Xin ed476a4fce fix(ios): fix wakelock on iOS, closes #2453 (#2948) 2026-01-14 13:06:16 +01:00
Huang Xin 44b4f7995b fix(android): fix annotator on Android, closes #2927 (#2946) 2026-01-14 11:56:38 +01:00
Huang Xin da5e3a0bd3 perf(fonts): cache first for font cache (#2945) 2026-01-14 07:34:00 +01:00
Huang Xin ba4678cc23 fix(fonts): fix CORS access error 403 of deno.dev with Origin: tauri://localhost (#2944) 2026-01-14 05:35:27 +01:00
Huang Xin e5eb3014b4 chore(unittest): test makeSafeFilename (#2943) 2026-01-14 04:08:30 +01:00
Huang Xin ed77b0bc7f fix(android): only dismiss unpinned sidebar and notebook with back key, closes #2920 (#2939) 2026-01-13 15:42:03 +01:00
Huang Xin 434a44e62c feat(proofread): add an option for whole word replacement, closes #2934 (#2938) 2026-01-13 15:35:57 +01:00
Huang Xin aaee04c290 fix(opds): probe filename when downloading PDFs and CBZ files, closes #2921 (#2932) 2026-01-12 17:55:50 +01:00
Huang Xin 48e2bfa82c fix(android): avoid occasional crash on start on Android (#2931) 2026-01-12 16:27:25 +01:00
Huang Xin c04f19ffb4 feat: add support for exporting book files in book details dialog, closes #2919 (#2930) 2026-01-12 16:26:32 +01:00
Huang Xin 4a624e3902 fix(settings): avoid stale viewSettings after saving, closes #2912 (#2916) 2026-01-11 16:47:36 +01:00
Huang Xin f53cee9616 release: version 0.9.97 (#2909) 2026-01-10 15:51:20 +01:00
Huang Xin 30385ee5ec fix(settings): correctly setting configs for selected book in parallel read, closes #2825 (#2908) 2026-01-10 15:01:04 +01:00
江夏尧 b30dfb3e23 fix(fonts): update font CDN links to use deno.dev for improved reliability (#2906) 2026-01-10 13:47:19 +01:00
Huang Xin c0d6102857 fix(css): primary btn style for e-ink mode (#2905) 2026-01-10 13:13:32 +01:00
Huang Xin 4537c55e84 fix(css): respect css for page break and minimum font size, closes #2895 (#2903) 2026-01-10 10:30:21 +01:00
Huang Xin aff94c0ab8 fix(windows): update shortcut to point to current installation, closes #2878 (#2902) 2026-01-10 10:26:32 +01:00
Huang Xin fd70836308 fix(windows): update shortcut to point to current installation, closes #2878 (#2901) 2026-01-10 09:53:40 +01:00
Huang Xin 1b0c94b9a5 fix(opds): temporary workaround for self-signed cert for OPDS server, closes #2871 (#2900)
Related to https://github.com/seanmonstar/reqwest/issues/1554
2026-01-10 08:47:12 +01:00
Huang Xin 7cb523eefc fix(metadata): no need to download book to display book details, closes #2857 (#2897) 2026-01-09 15:58:01 +01:00
Huang Xin 941be80cc4 fix(css): table and header layout, closes #2874 (#2896) 2026-01-09 15:06:37 +01:00
Huang Xin 5eecc735aa fix(proofread): support text replacement for text with no word boundaries, closes #2889 (#2894) 2026-01-09 10:27:38 +01:00
Huang Xin cfe51d01ee fix(sanitizer): normalize non-breaking spaces for WebView compatibility (#2893)
Convert &nbsp; to &#160; before sanitization and restore after XMLSerializer
serialization. This handles the platform difference where XMLSerializer
produces different representations of non-breaking spaces (&nbsp;, &#160;,
or \u00A0) across different WebView implementations.
2026-01-09 09:30:04 +01:00
Huang Xin 20ae09c52d perf(sync): persist partial library sync to prevent full retry on failure (#2892) 2026-01-09 09:23:02 +01:00
Huang Xin b868146129 feat(config): add an option to toggle footbar by tapping on the footbar (#2891) 2026-01-09 03:22:34 +01:00
Hermotimus a312080f7c fix(tts): footnotes anchors and superscript filtering (#2890) 2026-01-09 02:16:07 +01:00
Huang Xin 462ca46fee feat(eink): optimize color and layout for e-ink mode (#2887) 2026-01-08 17:29:33 +01:00
Huang Xin 93228c4b2a fix: avoid hydration mismatch for tauri app (#2884) 2026-01-08 04:05:10 +01:00
Huang Xin 2ff10f781f feat(annotator): support vertical layout for annotation editor (#2882) 2026-01-07 16:18:57 +01:00
Huang Xin 9614c62360 feat(annotator): instant highlighting with mouse, touch or pen (#2881) 2026-01-07 16:13:37 +01:00
lcd1232 5ffaac5e67 fix(login): fix login message field (#2879) 2026-01-07 12:55:11 +01:00
Huang Xin 71af91608f feat(annotator): support editing text range of highlights (#2870) 2026-01-05 16:25:58 +01:00
Huang Xin 9603b61776 refactor(annotatot): more cleaner text selector on Android (#2867) 2026-01-05 08:53:38 +01:00
Huang Xin eed84a059a fix(search): should be able to terminate search when sidebar is invisible (#2863) 2026-01-04 17:10:49 +01:00
Huang Xin 604ef65719 perf(search): use cache for search results (#2861) 2026-01-04 12:56:40 +01:00
Huang Xin 483d536ca4 feat(search): support search terms history, closes #2836 (#2859) 2026-01-04 07:47:43 +01:00
Huang Xin 69a51c5880 compat(footnote): support alt footnote inside placeholder anchor (#2858) 2026-01-04 06:06:24 +01:00
Huang Xin ca8d25341e compat(opds): support Komga OPDS v1.2 and v2, closes #2839 (#2851) 2026-01-03 18:04:39 +01:00
Huang Xin 8a263235ed feat(search): add annotations navigation bar, closes #2060 (#2849) 2026-01-03 14:57:10 +01:00
Huang Xin fb41ff5d0c fix(tts): reduce the pause duration between sentences in Edge TTS, closes #2837 (#2844) 2026-01-03 08:44:15 +01:00
Huang Xin a5e09e8454 feat(search): add search results navigation bar, closes #1183 (#2842)
Add a floating navigation component that appears when search results exist.
The component includes:
   - Top bar: displays search term and current section with TOC and close buttons
   - Bottom bar: search results, previous, and next navigation buttons
   - Page-based navigation using isCfiInLocation to skip between pages with results
2026-01-03 08:10:23 +01:00
Huang Xin 730ee21651 fix(android): don't navigate back with the back button for more predictable navigation (#2835)
This will revert the link navigation with back button in #2454.
This will close #2833 and more user reports that found back link navigation confusing.
2026-01-02 15:44:47 +01:00
Huang Xin dea43445c3 fix(layout): set image width for vertical inline images (#2832) 2026-01-02 14:54:02 +01:00
Huang Xin 4f0ae78d43 fix(ios): fixed error when importing file with urlencoded names (#2831) 2026-01-02 14:35:11 +01:00
Huang Xin d9116d619a feat(css): support overriding link color (#2830) 2026-01-02 14:15:02 +01:00
Huang Xin c080e6fdd3 fix(metadata): fixed sometimes svg book cover is rendered blank (#2829) 2026-01-02 14:13:24 +01:00
Huang Xin d3752dadc6 fix(library): upload files also for open with import and opds import, closes #2826 (#2827) 2026-01-02 13:32:52 +01:00
André Angelantoni e21ef53a3d fix(settings): ensure global settings sync across all open panes (#2818)
* fix(settings): ensure global settings sync across all open panes

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

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

* refactor to reuse some code

* fix: pointer in doc check

---------

Co-authored-by: André Angelantoni <andre.angelantoni@performantlabs.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-01-01 07:12:07 +01:00
Huang Xin 71e97998b6 feat(transfer): add a background transfer queue system for book uploads/downloads (#2821) 2025-12-31 08:40:28 +01:00
Huang Xin 22f9c45232 fix(annotator): delay highlight action to prevent immediate onShowAnnotation trigger (#2820) 2025-12-31 08:35:26 +01:00
Huang Xin a42897ec5c feat(annotator): support quick actions for text selection, closes #2505 (#2813) 2025-12-30 10:32:01 +01:00
Huang Xin 58a5c1625c fix(css): only override background color of hr when it has background image (#2808) 2025-12-30 10:24:37 +01:00
Huang Xin d53fc09e1e chore: bump opennext, wrangler and serwist to the latest versions (#2807) 2025-12-29 05:12:32 +01:00
Huang Xin 11fecb5dc0 chore: bump tauri to latest dev (#2805) 2025-12-29 04:07:58 +01:00
Huang Xin f4587663b5 chore(ios): drop support for iOS below 15.0 (#2804) 2025-12-29 03:41:12 +01:00
Huang Xin b76a2adf61 compat(epub): detect missing writing direction for some EPUBs (#2803) 2025-12-28 19:43:49 +01:00
Huang Xin 988fbc8c85 fix(settings): override book text align of left (#2802) 2025-12-28 18:02:56 +01:00
Huang Xin f944ad9b9f feat(annotator): support rendering vertical annotation bubbles (#2801) 2025-12-28 13:52:12 +01:00
Huang Xin 335d91b9c9 fix(layout): dismiss annotation popup on mobile when navigating (#2799) 2025-12-28 07:18:03 +01:00
Huang Xin 173404eaad feat(annotator): show notes popup when annotation bubble icon is clicked, closes #1845 (#2798) 2025-12-27 17:31:47 +01:00
Huang Xin 674fed5230 fix(annotator): adjust underline position to the center of two lines, closes #2772 (#2793) 2025-12-26 09:51:02 +01:00
Huang Xin 12d284cd22 fix(layout): fixed line clamp for config items on small screens (#2792) 2025-12-26 05:05:37 +01:00
Huang Xin 77037c8adb fix(sw): use NetworkFirst for navigation to prevent blank page on updates (#2784) 2025-12-24 15:19:14 +01:00
Huang Xin 6984393ed1 refactor(proofread): refactor UI and i18n for proofread tool (#2783) 2025-12-24 14:46:33 +01:00
Huang Xin 3abef6ea75 fix(opds): correctly parse file extension for CBZ files from OPDS servers, closes #2765 (#2771) 2025-12-23 08:53:50 +01:00
Huang Xin 4ae1ab7dd0 fix(layout): workaround for hardcoded image container (#2769) 2025-12-23 06:31:09 +01:00
Huang Xin 69fb22119b fix(layout): tweak insets for vertical layout (#2767) 2025-12-23 06:01:06 +01:00
Huang Xin 3a6c18c6d5 feat(font): support OpenType feature of vrt2 and vert for vertical replacement of some punctuations (#2764) 2025-12-22 18:39:09 +01:00
Huang Xin 7db1bc460d chore(pwa): migrate from next-pwa to serwist (#2762) 2025-12-22 16:10:09 +01:00
Huang Xin a460e609fa refactor(file): avoid eviction race of chunks cache (#2761) 2025-12-22 05:59:40 +01:00
Huang Xin 4b4ebdbdaa fix(pdf): avoid frequent eviction of chunk cache from worker thread, closes #2757 (#2759) 2025-12-21 17:47:41 +01:00
Huang Xin 9358a06839 compat(layout): grid layout with fit covers on older browsers, closes #2745 (#2756) 2025-12-21 08:52:57 +01:00
Huang Xin a7baf6cc9f fix(tts): prompt users to log in to use Edge TTS on the web version (#2749) 2025-12-19 17:54:17 +01:00
Huang Xin 15dc669f35 chore: update app metainfo (#2744) 2025-12-19 04:54:24 +01:00
225 changed files with 15670 additions and 7588 deletions
+43
View File
@@ -286,10 +286,15 @@ jobs:
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
- name: Override tauri-cli with custom AppImage format (Linux)
if: matrix.config.release == 'linux'
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
- uses: tauri-apps/tauri-action@v0
if: matrix.config.release != 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -348,6 +353,44 @@ jobs:
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
echo "Signing portable binary"
pnpm tauri signer sign $bin_file
echo "Uploading signature to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber
- name: download and update latest.json for Windows portable release
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
version=${{ needs.get-release.outputs.release_version }}
arch=${{ matrix.config.arch }}
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
platform_key="windows-x86_64-portable"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
platform_key="windows-aarch64-portable"
else
echo "Unknown architecture: $arch"
exit 1
fi
portable_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/$bin_file"
portable_sig=$(cat $bin_file.sig)
jq --arg url "$portable_url" \
--arg sig "$portable_sig" \
--arg key "$platform_key" \
'.platforms[$key] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
upload-to-r2:
needs: [get-release, build-tauri]
uses: ./.github/workflows/upload-to-r2.yml
Generated
+576 -557
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -4,6 +4,8 @@ members = [
"packages/tauri/crates/tauri",
"packages/tauri/crates/tauri-utils",
"packages/tauri/crates/tauri-build",
"packages/tauri-plugins/plugins/deep-link",
"packages/tauri-plugins/plugins/single-instance"
]
resolver = "2"
@@ -19,6 +21,7 @@ schemars = "0.8"
serde_json = "1"
thiserror = "2"
glob = "0.3"
zbus = "5.9"
dunce = "1"
url = "2"
@@ -35,3 +38,5 @@ rust-version = "1.77.2"
tauri = { path = "packages/tauri/crates/tauri" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
tauri-plugin-single-instance = { path = "packages/tauri-plugins/plugins/single-instance" }
+2 -2
View File
@@ -267,8 +267,8 @@ If Readest has been useful to you, consider supporting its development. You can
### Sponsors
<p align="center">
<a title="Browser testing via LambdaTest" href="https://www.lambdatest.com/?utm_source=readest&utm_medium=sponsor" target="_blank">
<img src="https://www.lambdatest.com/blue-logo.png" style="vertical-align: middle;" width="250" />
<a title="Browser testing via TestMu AI" href="https://www.testmu.ai?utm_source=readest&utm_medium=sponsor" target="_blank">
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
</a>
</p>
+2
View File
@@ -28,3 +28,5 @@ S3_ACCESS_KEY_ID=PLACE_HOLDER
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
S3_BUCKET_NAME=PLACE_HOLDER
S3_REGION=PLACE_HOLDER
TEMP_STORAGE_PUBLIC_BUCKET_NAME=PLACE_HOLDER
+3 -1
View File
@@ -1 +1,3 @@
NEXT_PUBLIC_APP_PLATFORM=tauri
NEXT_PUBLIC_APP_PLATFORM=tauri
DBUS_ID=com.bilingify.readest
+8 -83
View File
@@ -1,4 +1,4 @@
import withPWAInit from '@ducanh2912/next-pwa';
import withSerwistInit from '@serwist/next';
import withBundleAnalyzer from '@next/bundle-analyzer';
const isDev = process.env['NODE_ENV'] === 'development';
@@ -33,7 +33,6 @@ const nextConfig = {
'i18next-browser-languagedetector',
'react-i18next',
'i18next',
'@ducanh2912/next-pwa',
'@tauri-apps',
'highlight.js',
'foliate-js',
@@ -68,88 +67,14 @@ const pwaDisabled = isDev || appPlatform !== 'web';
const withPWA = pwaDisabled
? (config) => config
: withPWAInit({
dest: 'public',
cacheStartUrl: false,
dynamicStartUrl: false,
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
: withSerwistInit({
swSrc: 'src/sw.ts',
swDest: 'public/sw.js',
cacheOnNavigation: true,
reloadOnOnline: true,
swcMinify: true,
fallbacks: {
document: '/offline',
},
workboxOptions: {
disableDevLogs: true,
runtimeCaching: [
{
urlPattern: ({ url, request }) => {
const clientRoutes = ['/library', '/reader'];
const isClientRoute = clientRoutes.some((route) => url.pathname.startsWith(route));
return isClientRoute && request.mode === 'navigate';
},
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'pages-cache',
expiration: {
maxAgeSeconds: 365 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
const basePath = url.pathname.split('/')[1];
const cacheKey = `${url.origin}/${basePath}`;
return cacheKey;
},
},
],
},
},
{
urlPattern: ({ url }) => {
if (url.pathname.startsWith('/api/')) {
return false;
}
return /^https?.*/.test(url.href);
},
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'offlineCache',
expiration: {
maxEntries: 512,
maxAgeSeconds: 365 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
manifestTransforms: [
(manifestEntries) => {
const manifest = manifestEntries.filter((entry) => {
const url = entry.url;
return (
!url.includes('dynamic-css-manifest.json') &&
!url.includes('middleware-manifest.json') &&
!url.includes('react-loadable-manifest.json') &&
!url.includes('build-manifest.json') &&
!url.includes('_buildManifest.js') &&
!url.includes('_ssgManifest.js') &&
!url.includes('_headers')
);
});
return { manifest };
},
],
},
disable: false,
register: true,
scope: '/',
});
const withAnalyzer = withBundleAnalyzer({
+18 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.96",
"version": "0.9.98",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -14,13 +14,15 @@
"lint": "eslint .",
"test": "dotenv -e .env -e .env.test.local vitest",
"tauri": "tauri",
"clippy": "cargo clippy -p Readest --no-deps -- -D warnings",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc",
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/{openjpeg.wasm,qcms_bg.wasm}\" ./public/vendor/pdfjs",
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
"setup-pdfjs": "pnpm prepare-public-vendor && pnpm copy-pdfjs",
"setup-simplecc": "pnpm prepare-public-vendor && pnpm copy-simplecc",
@@ -53,9 +55,10 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.735.0",
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@choochmeque/tauri-plugin-sharekit-api": "^0.3.0",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.13.1",
"@opennextjs/cloudflare": "^1.14.7",
"@serwist/next": "^9.4.2",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@supabase/auth-ui-react": "^0.4.7",
@@ -63,18 +66,18 @@
"@supabase/supabase-js": "^2.76.1",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-cli": "^2.4.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-deep-link": "^2.4.6",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-haptics": "^2.3.2",
"@tauri-apps/plugin-http": "^2.5.4",
"@tauri-apps/plugin-log": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-http": "^2.5.6",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "~2.3.3",
"@tauri-apps/plugin-shell": "~2.3.4",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-websocket": "~2.4.1",
"@tauri-apps/plugin-websocket": "~2.4.2",
"@zip.js/zip.js": "^2.7.53",
"abortcontroller-polyfill": "^1.7.8",
"app-store-server-api": "^0.17.1",
@@ -154,10 +157,11 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"serwist": "^9.3.0",
"tailwindcss": "^3.4.18",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.15",
"wrangler": "^4.50.0"
"wrangler": "^4.56.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -172,9 +172,6 @@
"Token": "الرمز",
"Your OTP token": "رمز OTP الذي وصلك",
"Verify token": "التحقق من الرمز",
"Sign in with Google": "تسجيل الدخول عبر Google",
"Sign in with Apple": "تسجيل الدخول عبر Apple",
"Sign in with GitHub": "تسجيل الدخول عبر GitHub",
"Account": "الحساب",
"Failed to delete user. Please try again later.": "فشل حذف المستخدم. يرجى المحاولة مرة أخرى لاحقًا.",
"Community Support": "دعم المجتمع",
@@ -356,8 +353,8 @@
"{{count}} pages left in chapter_many": "تبقّت {{count}} صفحة في هذا الفصل",
"{{count}} pages left in chapter_other": "تبقّى {{count}} صفحة في هذا الفصل",
"Show Remaining Pages": "عرض الصفحات المتبقية",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "إدارة الاشتراك",
"Coming Soon": "قريبًا",
@@ -805,5 +802,133 @@
"Count": "العدد",
"Start Page": "الصفحة الأولى",
"Search in OPDS Catalog...": "البحث في كتالوج OPDS...",
"Please log in to use advanced TTS features.": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة."
"Please log in to use advanced TTS features": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة.",
"Word limit of 30 words exceeded.": "تم تجاوز حد الكلمات البالغ 30 كلمة.",
"Proofread": "التدقيق اللغوي",
"Current selection": "النص المحدد الحالي",
"All occurrences in this book": "جميع الحالات في هذا الكتاب",
"All occurrences in your library": "جميع الحالات في مكتبتك",
"Selected text:": "النص المحدد:",
"Replace with:": "استبدال بـ:",
"Enter text...": "أدخل النص...",
"Case sensitive:": "حساس لحالة الأحرف:",
"Scope:": "النطاق:",
"Selection": "التحديد",
"Library": "المكتبة",
"Yes": "نعم",
"No": "لا",
"Proofread Replacement Rules": "قواعد استبدال التدقيق اللغوي",
"Selected Text Rules": "قواعد النص المحدد",
"No selected text replacement rules": "لا توجد قواعد استبدال نص محدد",
"Book Specific Rules": "قواعد خاصة بالكتاب",
"No book-level replacement rules": "لا توجد قواعد استبدال على مستوى الكتاب",
"Disable Quick Action": "تعطيل الإجراء السريع",
"Enable Quick Action on Selection": "تمكين الإجراء السريع عند التحديد",
"None": "لا شيء",
"Annotation Tools": "أدوات التعليق",
"Enable Quick Actions": "تمكين الإجراءات السريعة",
"Quick Action": "الإجراء السريع",
"Copy to Notebook": "نسخ إلى الدفتر",
"Copy text after selection": "نسخ النص بعد التحديد",
"Highlight text after selection": "تمييز النص بعد التحديد",
"Annotate text after selection": "تعليق على النص بعد التحديد",
"Search text after selection": "البحث في النص بعد التحديد",
"Look up text in dictionary after selection": "البحث عن النص في القاموس بعد التحديد",
"Look up text in Wikipedia after selection": "البحث عن النص في ويكيبيديا بعد التحديد",
"Translate text after selection": "ترجمة النص بعد التحديد",
"Read text aloud after selection": "قراءة النص بصوت عالٍ بعد التحديد",
"Proofread text after selection": "تدقيق النص بعد التحديد",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} نشط، {{pendingCount}} قيد الانتظار",
"{{failedCount}} failed": "{{failedCount}} فشل",
"Waiting...": "جارٍ الانتظار...",
"Failed": "فشل",
"Completed": "مكتمل",
"Cancelled": "ملغى",
"Retry": "إعادة المحاولة",
"Active": "نشط",
"Transfer Queue": "قائمة انتظار النقل",
"Upload All": "رفع الكل",
"Download All": "تنزيل الكل",
"Resume Transfers": "استئناف النقل",
"Pause Transfers": "إيقاف النقل مؤقتًا",
"Pending": "قيد الانتظار",
"No transfers": "لا توجد عمليات نقل",
"Retry All": "إعادة محاولة الكل",
"Clear Completed": "مسح المكتمل",
"Clear Failed": "مسح الفاشل",
"Upload queued: {{title}}": "الرفع في قائمة الانتظار: {{title}}",
"Download queued: {{title}}": "التنزيل في قائمة الانتظار: {{title}}",
"Book not found in library": "الكتاب غير موجود في المكتبة",
"Unknown error": "خطأ غير معروف",
"Please log in to continue": "يرجى تسجيل الدخول للمتابعة",
"Cloud File Transfers": "نقل الملفات السحابية",
"Show Search Results": "عرض نتائج البحث",
"Search results for '{{term}}'": "نتائج البحث عن «{{term}}»",
"Close Search": "إغلاق البحث",
"Previous Result": "النتيجة السابقة",
"Next Result": "النتيجة التالية",
"Bookmarks": "الإشارات المرجعية",
"Annotations": "التعليقات التوضيحية",
"Show Results": "عرض النتائج",
"Clear search": "مسح البحث",
"Clear search history": "مسح سجل البحث",
"Quick action disabled": "الإجراء السريع معطل",
"Tap to Toggle Footer": "انقر لتبديل التذييل",
"Exported successfully": "تم التصدير بنجاح",
"Book exported successfully.": "تم تصدير الكتاب بنجاح.",
"Failed to export the book.": "فشل تصدير الكتاب.",
"Export Book": "تصدير الكتاب",
"Whole word:": "كلمة كاملة:",
"Error": "خطأ",
"Unable to load the article. Try searching directly on {{link}}.": "تعذر تحميل المقال. حاول البحث مباشرة على {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "تعذر تحميل الكلمة. حاول البحث مباشرة على {{link}}.",
"Date Published": "تاريخ النشر",
"Only for TTS:": "فقط لـ TTS:",
"Uploaded": "تم الرفع",
"Downloaded": "تم التنزيل",
"Deleted": "تم الحذف",
"Note:": "ملاحظة:",
"Time:": "الوقت:",
"Format Options": "خيارات التنسيق",
"Export Date": "تاريخ التصدير",
"Chapter Titles": "عناوين الفصول",
"Chapter Separator": "فاصل الفصول",
"Highlights": "التمييزات",
"Note Date": "تاريخ الملاحظة",
"Advanced": "متقدم",
"Hide": "إخفاء",
"Show": "إظهار",
"Use Custom Template": "استخدام قالب مخصص",
"Export Template": "قالب التصدير",
"Template Syntax:": "بناء جملة القالب:",
"Insert value": "إدراج قيمة",
"Format date (locale)": "تنسيق التاريخ (محلي)",
"Format date (custom)": "تنسيق التاريخ (مخصص)",
"Conditional": "شرطي",
"Loop": "حلقة",
"Available Variables:": "المتغيرات المتاحة:",
"Book title": "عنوان الكتاب",
"Book author": "مؤلف الكتاب",
"Export date": "تاريخ التصدير",
"Array of chapters": "مصفوفة الفصول",
"Chapter title": "عنوان الفصل",
"Array of annotations": "مصفوفة التعليقات",
"Highlighted text": "النص المميز",
"Annotation note": "ملاحظة التعليق",
"Update time": "وقت التحديث",
"Date Format Tokens:": "رموز تنسيق التاريخ:",
"Year (4 digits)": "السنة (4 أرقام)",
"Month (01-12)": "الشهر (01-12)",
"Day (01-31)": "اليوم (01-31)",
"Hour (00-23)": "الساعة (00-23)",
"Minute (00-59)": "الدقيقة (00-59)",
"Second (00-59)": "الثانية (00-59)",
"Show Source": "إظهار المصدر",
"No content to preview": "لا يوجد محتوى للمعاينة",
"Export": "تصدير",
"Set Timeout": "تعيين المهلة",
"Select Voice": "اختر الصوت",
"Toggle Sticky Bottom TTS Bar": "تبديل شريط TTS الثابت",
"Display what I'm reading on Discord": "عرض ما أقرأه على Discord",
"Show on Discord": "عرض على Discord"
}
@@ -24,9 +24,6 @@
"Token": "টোকেন",
"Your OTP token": "আপনার ওটিপি টোকেন",
"Verify token": "টোকেন যাচাই করুন",
"Sign in with Google": "গুগল দিয়ে সাইন ইন",
"Sign in with Apple": "অ্যাপল দিয়ে সাইন ইন",
"Sign in with GitHub": "গিটহাব দিয়ে সাইন ইন",
"New Password": "নতুন পাসওয়ার্ড",
"Your new password": "আপনার নতুন পাসওয়ার্ড",
"Update password": "পাসওয়ার্ড আপডেট করুন",
@@ -488,8 +485,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "গুগল বুকস",
"Open Library": "ওপেন লাইব্রেরি",
@@ -765,5 +762,133 @@
"Count": "গণনা",
"Start Page": "শুরু পৃষ্ঠা",
"Search in OPDS Catalog...": "OPDS ক্যাটালগে অনুসন্ধান করুন...",
"Please log in to use advanced TTS features.": "উন্নত TTS বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।"
"Please log in to use advanced TTS features": "উন্নত TTS বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।",
"Word limit of 30 words exceeded.": "৩০ শব্দের শব্দসীমা অতিক্রম হয়েছে।",
"Proofread": "প্রুফরিড",
"Current selection": "বর্তমান নির্বাচন",
"All occurrences in this book": "এই বইয়ে সমস্ত ঘটনা",
"All occurrences in your library": "আপনার লাইব্রেরির সমস্ত ঘটনা",
"Selected text:": "নির্বাচিত টেক্সট:",
"Replace with:": "এর সাথে প্রতিস্থাপন করুন:",
"Enter text...": "টেক্সট লিখুন...",
"Case sensitive:": "কেস সংবেদনশীল:",
"Scope:": "পরিসর:",
"Selection": "নির্বাচন",
"Library": "লাইব্রেরি",
"Yes": "হ্যাঁ",
"No": "না",
"Proofread Replacement Rules": "প্রুফরিড বিকল্প নিয়ম",
"Selected Text Rules": "নির্বাচিত টেক্সট নিয়ম",
"No selected text replacement rules": "কোনও নির্বাচিত টেক্সট বিকল্প নিয়ম নেই",
"Book Specific Rules": "বই নির্দিষ্ট নিয়ম",
"No book-level replacement rules": "কোনও বই-স্তরের বিকল্প নিয়ম নেই",
"Disable Quick Action": "দ্রুত ক্রিয়া নিষ্ক্রিয় করুন",
"Enable Quick Action on Selection": "নির্বাচনের উপর দ্রুত ক্রিয়া সক্ষম করুন",
"None": "কোনোটিই নয়",
"Annotation Tools": "অ্যনোটেশন সরঞ্জামসমূহ",
"Enable Quick Actions": "দ্রুত ক্রিয়া সক্ষম করুন",
"Quick Action": "দ্রুত ক্রিয়া",
"Copy to Notebook": "নোটবুকে কপি করুন",
"Copy text after selection": "নির্বাচনের পরে টেক্সট কপি করুন",
"Highlight text after selection": "নির্বাচনের পরে টেক্সট হাইলাইট করুন",
"Annotate text after selection": "নির্বাচনের পরে টেক্সট মন্তব্য করুন",
"Search text after selection": "নির্বাচনের পরে টেক্সট অনুসন্ধান করুন",
"Look up text in dictionary after selection": "নির্বাচনের পরে টেক্সট অভিধানে দেখুন",
"Look up text in Wikipedia after selection": "নির্বাচনের পরে টেক্সট উইকিপিডিয়ায় দেখুন",
"Translate text after selection": "নির্বাচনের পরে টেক্সট অনুবাদ করুন",
"Read text aloud after selection": "নির্বাচনের পরে টেক্সট উচ্চারণ করুন",
"Proofread text after selection": "নির্বাচনের পরে টেক্সট প্রুফরিড করুন",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} সক্রিয়, {{pendingCount}} অপেক্ষমাণ",
"{{failedCount}} failed": "{{failedCount}} ব্যর্থ",
"Waiting...": "অপেক্ষা করছে...",
"Failed": "ব্যর্থ",
"Completed": "সম্পন্ন",
"Cancelled": "বাতিল",
"Retry": "পুনরায় চেষ্টা করুন",
"Active": "সক্রিয়",
"Transfer Queue": "স্থানান্তর সারি",
"Upload All": "সব আপলোড করুন",
"Download All": "সব ডাউনলোড করুন",
"Resume Transfers": "স্থানান্তর পুনরায় শুরু করুন",
"Pause Transfers": "স্থানান্তর বিরতি দিন",
"Pending": "অপেক্ষমাণ",
"No transfers": "কোনো স্থানান্তর নেই",
"Retry All": "সব পুনরায় চেষ্টা করুন",
"Clear Completed": "সম্পন্ন মুছুন",
"Clear Failed": "ব্যর্থ মুছুন",
"Upload queued: {{title}}": "আপলোড সারিতে: {{title}}",
"Download queued: {{title}}": "ডাউনলোড সারিতে: {{title}}",
"Book not found in library": "লাইব্রেরিতে বই পাওয়া যায়নি",
"Unknown error": "অজানা ত্রুটি",
"Please log in to continue": "চালিয়ে যেতে লগইন করুন",
"Cloud File Transfers": "ক্লাউড ফাইল স্থানান্তরসমূহ",
"Show Search Results": "অনুসন্ধান ফলাফল দেখান",
"Search results for '{{term}}'": "'{{term}}' এর ফলাফল",
"Close Search": "অনুসন্ধান বন্ধ করুন",
"Previous Result": "আগের ফলাফল",
"Next Result": "পরবর্তী ফলাফল",
"Bookmarks": "বুকমার্ক",
"Annotations": "টীকা",
"Show Results": "ফলাফল দেখুন",
"Clear search": "অনুসন্ধান সাফ করুন",
"Clear search history": "অনুসন্ধান ইতিহাস সাফ করুন",
"Quick action disabled": "দ্রুত কার্যক্রম অক্ষম",
"Tap to Toggle Footer": "ফুটার টগল করতে আলতো চাপুন",
"Exported successfully": "সফলভাবে রপ্তানি হয়েছে",
"Book exported successfully.": "বইটি সফলভাবে রপ্তানি হয়েছে।",
"Failed to export the book.": "বই রপ্তানি করতে ব্যর্থ।",
"Export Book": "বই রপ্তানি করুন",
"Whole word:": "সম্পূর্ণ শব্দ:",
"Error": "ত্রুটি",
"Unable to load the article. Try searching directly on {{link}}.": "নিবন্ধ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Unable to load the word. Try searching directly on {{link}}.": "শব্দ লোড করতে অক্ষম। সরাসরি {{link}} এ অনুসন্ধান করার চেষ্টা করুন।",
"Date Published": "প্রকাশনার তারিখ",
"Only for TTS:": "শুধুমাত্র TTS-এর জন্য:",
"Uploaded": "আপলোড হয়েছে",
"Downloaded": "ডাউনলোড হয়েছে",
"Deleted": "মুছে ফেলা হয়েছে",
"Note:": "নোট:",
"Time:": "সময়:",
"Format Options": "বিন্যাস বিকল্প",
"Export Date": "রপ্তানি তারিখ",
"Chapter Titles": "অধ্যায়ের শিরোনাম",
"Chapter Separator": "অধ্যায় বিভাজক",
"Highlights": "হাইলাইট",
"Note Date": "নোট তারিখ",
"Advanced": "উন্নত",
"Hide": "লুকান",
"Show": "দেখান",
"Use Custom Template": "কাস্টম টেমপ্লেট ব্যবহার করুন",
"Export Template": "রপ্তানি টেমপ্লেট",
"Template Syntax:": "টেমপ্লেট সিনট্যাক্স:",
"Insert value": "মান সন্নিবেশ করুন",
"Format date (locale)": "তারিখ বিন্যাস (স্থানীয়)",
"Format date (custom)": "তারিখ বিন্যাস (কাস্টম)",
"Conditional": "শর্তসাপেক্ষ",
"Loop": "লুপ",
"Available Variables:": "উপলব্ধ ভেরিয়েবল:",
"Book title": "বইয়ের শিরোনাম",
"Book author": "বইয়ের লেখক",
"Export date": "রপ্তানি তারিখ",
"Array of chapters": "অধ্যায়ের তালিকা",
"Chapter title": "অধ্যায়ের শিরোনাম",
"Array of annotations": "টীকাগুলির তালিকা",
"Highlighted text": "হাইলাইট করা পাঠ্য",
"Annotation note": "টীকা নোট",
"Update time": "আপডেট সময়",
"Date Format Tokens:": "তারিখ বিন্যাস টোকেন:",
"Year (4 digits)": "বছর (4 সংখ্যা)",
"Month (01-12)": "মাস (01-12)",
"Day (01-31)": "দিন (01-31)",
"Hour (00-23)": "ঘণ্টা (00-23)",
"Minute (00-59)": "মিনিট (00-59)",
"Second (00-59)": "সেকেন্ড (00-59)",
"Show Source": "উৎস দেখান",
"No content to preview": "প্রিভিউ করার জন্য কোনো কন্টেন্ট নেই",
"Export": "রপ্তানি",
"Set Timeout": "টাইমআউট সেট করুন",
"Select Voice": "ভয়েস নির্বাচন করুন",
"Toggle Sticky Bottom TTS Bar": "স্থির TTS বার টগল করুন",
"Display what I'm reading on Discord": "Discord-এ পড়ছি যা দেখান",
"Show on Discord": "Discord-এ দেখান"
}
@@ -173,9 +173,6 @@
"Token": "བཀོལ་ཐོགས།",
"Your OTP token": "ཁྱེད་ཀྱི་ OTP བཀོལ་ཐོགས།",
"Verify token": "བཀོལ་ཐོགས་བདེན་སྦྱོང་།",
"Sign in with Google": "Google བེད་སྤྱད་ནས་ནང་བསྐྱོད།",
"Sign in with Apple": "Apple བེད་སྤྱད་ནས་ནང་བསྐྱོད།",
"Sign in with GitHub": "GitHub བེད་སྤྱད་ནས་ནང་བསྐྱོད།",
"Account": "རྩིས་ཐོ།",
"Failed to delete user. Please try again later.": "སྤྱོད་མཁན་བསུབ་མ་ཐུབ། ཅུང་ཙམ་སྒུག་ནས་ཡང་བསྐྱར་ཚོད་ལྟ་བྱེད་རོགས།",
"Community Support": "སྡེ་ཁུལ་གྱི་རྒྱབ་སྐྱོར།",
@@ -336,8 +333,8 @@
"Reset Settings": "སྒྲིག་བཀོད་བསྐྱར་སྒྲིག",
"{{count}} pages left in chapter_other": "ལེའུ་འདིར་ད་དུང་ {{count}} ཤོག་ལྷེ་ལྷག་ཡོད།",
"Show Remaining Pages": "ལྷག་མའི་ཤོག་ལྷེའི་གྲངས་ཀ་མངོན་པ།",
"Source Han Serif CN VF": "སི་ཡོན་སུང་ཐི།",
"Huiwen-mincho": "ཧུའེ་ཝུན་མིང་ཁྲའོ་ཐི།",
"Source Han Serif CN": "སི་ཡོན་སུང་ཐི།",
"Huiwen-MinchoGBK": "ཧུའེ་ཝུན་མིང་ཁྲའོ་ཐི།",
"KingHwa_OldSong": "ཅིང་ཧྭ་ལའོ་སུང་ཐི།",
"Manage Subscription": "མཚམས་སྦྱོར་དོ་དམ།",
"Coming Soon": "ཉེ་བར་སྤྱོད་འགོ་འཛུགས་རྒྱུ།",
@@ -755,5 +752,133 @@
"Count": "ཨང་",
"Start Page": "ཤོག་ངོས་དང་པོ",
"Search in OPDS Catalog...": "OPDS དཀར་ཆག་ནང་འཚོལ།...",
"Please log in to use advanced TTS features.": "དབང་བསྐྱོད་ཀྱི TTS རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།"
"Please log in to use advanced TTS features": "དབང་བསྐྱོད་ཀྱི TTS རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།",
"Word limit of 30 words exceeded.": "ཚིག་གཅིག 30 ལྟར་འདོད་མེད།",
"Proofread": "མིག་སྔའི་བཤད་རྒྱུན།",
"Current selection": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"All occurrences in this book": "དེབ་ནང་གི་འདི་ཚུགས་ཀྱི་འཚོལ་ཞིབ།",
"All occurrences in your library": "ཁྱོད་ཀྱི་དེབ་མཛོད་ནང་གི་འདི་ཚུགས་ཀྱི་འཚོལ་ཞིབ།",
"Selected text:": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Replace with:": "བརྗེ་བ།:",
"Enter text...": "ཡིག་ཆ་ལ་འགྲོ།...",
"Case sensitive:": "གནས་སྟངས་འདི་ལ་འབད་དགོས།",
"Scope:": "གནས་ཚུལ།:",
"Selection": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Library": "དེབ་མཛོད།",
"Yes": "ཨིན་",
"No": "མེད་",
"Proofread Replacement Rules": "མིག་སྔའི་བཤད་རྒྱུན་བརྗེ་བའི་རྣམ་པ།",
"Selected Text Rules": "བདམས་པའི་ཡིག་ཆ་བརྗེ་སྒྱུར་སྲིད་སྒྲིག",
"No selected text replacement rules": "བདམས་པའི་ཡིག་ཆ་བརྗེ་སྒྱུར་སྲིད་སྒྲིག་མེད་པ",
"Book Specific Rules": "དེབ་སྒོས་ཀྱི་བརྗེ་སྒྱུར་སྲིད་སྒྲིག",
"No book-level replacement rules": "དེབ་རིམ་གྱི་བརྗེ་སྒྱུར་སྲིད་སྒྲིག་མེད་པ",
"Disable Quick Action": "མགྱོགས་མྱུར་བྱ་བ་སྤང་",
"Enable Quick Action on Selection": "འདེམས་པའི་སྐབས་མགྱོགས་མྱུར་བྱ་བ་སྤྱོད་",
"None": "མེད་",
"Annotation Tools": "མཆན་འགྲེལ་ལག་ཆ་",
"Enable Quick Actions": "མགྱོགས་མྱུར་བྱ་བ་སྤྱོད་",
"Quick Action": "མགྱོགས་མྱུར་བྱ་བ་",
"Copy to Notebook": "ཟིན་དེབ་ནང་འདྲ་བཤུས་",
"Copy text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་འདྲ་བཤུས་",
"Highlight text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་མངོན་གསལ་བྱེད་",
"Annotate text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་མཆན་འགྲེལ་བྱེད་",
"Search text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་འཚོལ་ཞིབ་བྱེད་",
"Look up text in dictionary after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཚིག་མཛོད་ནང་འཚོལ་",
"Look up text in Wikipedia after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཝེ་ཀི་པི་ཌི་ཡི་ནང་འཚོལ་",
"Translate text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་སྐད་བསྒྱུར་བྱེད་",
"Read text aloud after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་སྐད་ཀྱིས་ཀློག་",
"Proofread text after selection": "ཡིག་ཆ་འདེམས་པའི་ཐབས་ཀྱིས་ཞིབ་བཤེར་བྱེད་",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} བྱེད་བཞིན་པ་, {{pendingCount}} སྒུག་བཞིན་པ་",
"{{failedCount}} failed": "{{failedCount}} ཕམ་པ་",
"Waiting...": "སྒུག་བཞིན་པ་...",
"Failed": "ཕམ་པ་",
"Completed": "གྲུབ་སོང་",
"Cancelled": "དོར་སོང་",
"Retry": "བསྐྱར་ཚོད་",
"Active": "བྱེད་བཞིན་པ་",
"Transfer Queue": "སྐྱེལ་འདྲེན་སྒྲིག་གཅོད་",
"Upload All": "ཚང་མ་འཇོག་",
"Download All": "ཚང་མ་ལེན་",
"Resume Transfers": "སྐྱེལ་འདྲེན་མུ་མཐུད་",
"Pause Transfers": "སྐྱེལ་འདྲེན་མཚམས་འཇོག་",
"Pending": "སྒུག་བཞིན་པ་",
"No transfers": "སྐྱེལ་འདྲེན་མེད་",
"Retry All": "ཚང་མ་བསྐྱར་ཚོད་",
"Clear Completed": "གྲུབ་པ་གསལ་བ་",
"Clear Failed": "ཕམ་པ་གསལ་བ་",
"Upload queued: {{title}}": "འཇོག་པའི་སྒྲིག་གཅོད་ནང་བཅུག་: {{title}}",
"Download queued: {{title}}": "ལེན་པའི་སྒྲིག་གཅོད་ནང་བཅུག་: {{title}}",
"Book not found in library": "དཔེ་མཛོད་ནང་དཔེ་ཆ་རྙེད་མ་སོང་",
"Unknown error": "མ་ཤེས་པའི་ནོར་འཁྲུལ་",
"Please log in to continue": "མུ་མཐུད་པར་ནང་འཛུལ་བྱོས་",
"Cloud File Transfers": "སྤྲིན་གནས་ཡིག་ཆ་སྐྱེལ་འདྲེན།",
"Show Search Results": "འཚོལ་བཤེར་འབྲས་བུ་སྟོན།",
"Search results for '{{term}}'": "'{{term}}'ཡི་འབྲས་བུ།",
"Close Search": "འཚོལ་བཤེར་སྒོ་རྒྱག",
"Previous Result": "སྔོན་མའི་འབྲས་བུ།",
"Next Result": "རྗེས་མའི་འབྲས་བུ།",
"Bookmarks": "དཔེ་རྟགས།",
"Annotations": "མཆན།",
"Show Results": "འབྲས་བུ་སྟོན།",
"Clear search": "འཚོལ་བཤེར་གཙང་སེལ།",
"Clear search history": "འཚོལ་བཤེར་ལོ་རྒྱུས་གཙང་སེལ།",
"Quick action disabled": "མྱུར་མགྱོགས་བྱ་འགུལ་བཀག་འགོག",
"Tap to Toggle Footer": "ཞབས་མཇུག་སྒོ་འབྱེད་བྱེད་པར་གནོན།",
"Exported successfully": "ཕྱིར་འདོན་ལེགས་གྲུབ་བྱུང་།",
"Book exported successfully.": "དཔེ་དེབ་ཕྱིར་འདོན་ལེགས་གྲུབ་བྱུང་།",
"Failed to export the book.": "དཔེ་དེབ་ཕྱིར་འདོན་མི་ཐུབ།",
"Export Book": "དཔེ་དེབ་ཕྱིར་འདོན།",
"Whole word:": "ཚིག་གྲུབ་ཆ་ཚང་:",
"Error": "ནོར་འཁྲུལ།",
"Unable to load the article. Try searching directly on {{link}}.": "རྩོམ་ཡིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Unable to load the word. Try searching directly on {{link}}.": "ཚིག་མངོན་མི་ཐུབ། {{link}} ཐད་ཀར་འཚོལ་བ་བྱོས།",
"Date Published": "པར་སྐྲུན་ཚེས་གྲངས།",
"Only for TTS:": "TTS ལ་ཁོ་ན།:",
"Uploaded": "ཡར་སྐྱེལ་བྱས་ཟིན།",
"Downloaded": "མར་ལེན་བྱས་ཟིན།",
"Deleted": "བསུབས་ཟིན།",
"Note:": "གསལ་བཤད།:",
"Time:": "དུས་ཚོད།:",
"Format Options": "རྣམ་གཞག་གདམ་ག",
"Export Date": "ཕྱིར་འདོན་ཚེས་གྲངས།",
"Chapter Titles": "ལེའུའི་མཚན།",
"Chapter Separator": "ལེའུའི་དབར་མཚམས།",
"Highlights": "གཙོ་གནད།",
"Note Date": "གསལ་བཤད་ཚེས་གྲངས།",
"Advanced": "མཐོ་རིམ།",
"Hide": "སྦས་པ།",
"Show": "མངོན་པ།",
"Use Custom Template": "རང་བཟོའི་དཔེ་གཞི་བེད་སྤྱོད།",
"Export Template": "ཕྱིར་འདོན་དཔེ་གཞི།",
"Template Syntax:": "དཔེ་གཞིའི་སྒྲིག་གཞི།:",
"Insert value": "གྲངས་ཐང་བཙུགས་པ།",
"Format date (locale)": "ཚེས་གྲངས་རྣམ་གཞག (ས་གནས།)",
"Format date (custom)": "ཚེས་གྲངས་རྣམ་གཞག (རང་བཟོ།)",
"Conditional": "དམིགས་བསལ།",
"Loop": "འཁོར་ལོ།",
"Available Variables:": "བེད་སྤྱད་རུང་བའི་འགྱུར་ཅན།:",
"Book title": "དེབ་མཚན།",
"Book author": "དེབ་རྩོམ་པ་པོ།",
"Export date": "ཕྱིར་འདོན་ཚེས་གྲངས།",
"Array of chapters": "ལེའུའི་ལེབ་ངོས།",
"Chapter title": "ལེའུའི་མཚན།",
"Array of annotations": "གསལ་བཤད་ལེབ་ངོས།",
"Highlighted text": "གཙོ་གནད་ཡིག་རྐྱང་།",
"Annotation note": "གསལ་བཤད་ཟིན་བྲིས།",
"Update time": "གསར་བཅོས་དུས་ཚོད།",
"Date Format Tokens:": "ཚེས་གྲངས་རྣམ་གཞག་རྟགས།:",
"Year (4 digits)": "ལོ། (གྲངས་ཐང་4)",
"Month (01-12)": "ཟླ། (01-12)",
"Day (01-31)": "ཚེས། (01-31)",
"Hour (00-23)": "ཆུ་ཚོད། (00-23)",
"Minute (00-59)": "སྐར་མ། (00-59)",
"Second (00-59)": "སྐར་ཆ། (00-59)",
"Show Source": "འབྱུང་ཁུངས་མངོན་པ།",
"No content to preview": "སྔོན་ལྟ་བྱེད་པའི་དོན་རྐྱེན་མེད།",
"Export": "ཕྱིར་འདོན།",
"Set Timeout": "དུས་ཚོད་སྒྲིག་པ།",
"Select Voice": "སྐད་གདངས་འདེམས།",
"Toggle Sticky Bottom TTS Bar": "TTS སྡོམ་ཐིག་བརྗེ་བ།",
"Display what I'm reading on Discord": "Discord ཐོག་ཀློག་བཞིན་པའི་དཔེ་ཆ་སྟོན།",
"Show on Discord": "Discord ཐོག་སྟོན།"
}
@@ -172,9 +172,6 @@
"Token": "Token",
"Your OTP token": "Ihr OTP-Token",
"Verify token": "Token überprüfen",
"Sign in with Google": "Mit Google anmelden",
"Sign in with Apple": "Mit Apple anmelden",
"Sign in with GitHub": "Mit GitHub anmelden",
"Account": "Konto",
"Failed to delete user. Please try again later.": "Benutzer konnte nicht gelöscht werden. Bitte versuchen Sie es später erneut.",
"Community Support": "Community-Support",
@@ -340,8 +337,8 @@
"{{count}} pages left in chapter_one": "{{count}} Seite verbleibend im Kapitel",
"{{count}} pages left in chapter_other": "{{count}} Seiten verbleibend im Kapitel",
"Show Remaining Pages": "Verbleibende Seiten anzeigen",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Abo verwalten",
"Coming Soon": "Demnächst verfügbar",
@@ -765,5 +762,133 @@
"Count": "Anzahl",
"Start Page": "Startseite",
"Search in OPDS Catalog...": "Im OPDS-Katalog suchen...",
"Please log in to use advanced TTS features.": "Bitte melde dich an, um erweiterte TTS-Funktionen zu nutzen."
"Please log in to use advanced TTS features": "Bitte melde dich an, um erweiterte TTS-Funktionen zu nutzen",
"Word limit of 30 words exceeded.": "Das Wortlimit von 30 Wörtern wurde überschritten.",
"Proofread": "Korrekturlesen",
"Current selection": "Aktuelle Auswahl",
"All occurrences in this book": "Alle Vorkommen in diesem Buch",
"All occurrences in your library": "Alle Vorkommen in Ihrer Bibliothek",
"Selected text:": "Ausgewählter Text:",
"Replace with:": "Ersetzen durch:",
"Enter text...": "Text eingeben …",
"Case sensitive:": "Groß-/Kleinschreibung:",
"Scope:": "Geltungsbereich:",
"Selection": "Auswahl",
"Library": "Bibliothek",
"Yes": "Ja",
"No": "Nein",
"Proofread Replacement Rules": "Korrektur-Ersetzungsregeln",
"Selected Text Rules": "Regeln für ausgewählten Text",
"No selected text replacement rules": "Keine Ersetzungsregeln für ausgewählten Text",
"Book Specific Rules": "Buchspezifische Regeln",
"No book-level replacement rules": "Keine Ersetzungsregeln auf Buchebene",
"Disable Quick Action": "Schnellaktion deaktivieren",
"Enable Quick Action on Selection": "Schnellaktion bei Auswahl aktivieren",
"None": "Keine",
"Annotation Tools": "Anmerkungswerkzeuge",
"Enable Quick Actions": "Schnellaktionen aktivieren",
"Quick Action": "Schnellaktion",
"Copy to Notebook": "In Notizbuch kopieren",
"Copy text after selection": "Text nach Auswahl kopieren",
"Highlight text after selection": "Text nach Auswahl hervorheben",
"Annotate text after selection": "Text nach Auswahl annotieren",
"Search text after selection": "Text nach Auswahl suchen",
"Look up text in dictionary after selection": "Text nach Auswahl im Wörterbuch nachschlagen",
"Look up text in Wikipedia after selection": "Text nach Auswahl in Wikipedia nachschlagen",
"Translate text after selection": "Text nach Auswahl übersetzen",
"Read text aloud after selection": "Text nach Auswahl vorlesen",
"Proofread text after selection": "Text nach Auswahl Korrektur lesen",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktiv, {{pendingCount}} ausstehend",
"{{failedCount}} failed": "{{failedCount}} fehlgeschlagen",
"Waiting...": "Warten...",
"Failed": "Fehlgeschlagen",
"Completed": "Abgeschlossen",
"Cancelled": "Abgebrochen",
"Retry": "Erneut versuchen",
"Active": "Aktiv",
"Transfer Queue": "Übertragungswarteschlange",
"Upload All": "Alle hochladen",
"Download All": "Alle herunterladen",
"Resume Transfers": "Übertragungen fortsetzen",
"Pause Transfers": "Übertragungen pausieren",
"Pending": "Ausstehend",
"No transfers": "Keine Übertragungen",
"Retry All": "Alle erneut versuchen",
"Clear Completed": "Abgeschlossene löschen",
"Clear Failed": "Fehlgeschlagene löschen",
"Upload queued: {{title}}": "Upload in Warteschlange: {{title}}",
"Download queued: {{title}}": "Download in Warteschlange: {{title}}",
"Book not found in library": "Buch nicht in der Bibliothek gefunden",
"Unknown error": "Unbekannter Fehler",
"Please log in to continue": "Bitte melden Sie sich an, um fortzufahren",
"Cloud File Transfers": "Cloud-Dateiübertragungen",
"Show Search Results": "Suchergebnisse anzeigen",
"Search results for '{{term}}'": "Ergebnisse für '{{term}}'",
"Close Search": "Suche schließen",
"Previous Result": "Vorheriges Ergebnis",
"Next Result": "Nächstes Ergebnis",
"Bookmarks": "Lesezeichen",
"Annotations": "Anmerkungen",
"Show Results": "Ergebnisse anzeigen",
"Clear search": "Suche löschen",
"Clear search history": "Suchverlauf löschen",
"Quick action disabled": "Schnellaktion deaktiviert",
"Tap to Toggle Footer": "Tippen, um Fußzeile umzuschalten",
"Exported successfully": "Erfolgreich exportiert",
"Book exported successfully.": "Buch erfolgreich exportiert.",
"Failed to export the book.": "Buch konnte nicht exportiert werden.",
"Export Book": "Buch exportieren",
"Whole word:": "Ganzes Wort:",
"Error": "Fehler",
"Unable to load the article. Try searching directly on {{link}}.": "Artikel kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Wort kann nicht geladen werden. Versuchen Sie die direkte Suche auf {{link}}.",
"Date Published": "Veröffentlichungsdatum",
"Only for TTS:": "Nur für TTS:",
"Uploaded": "Hochgeladen",
"Downloaded": "Heruntergeladen",
"Deleted": "Gelöscht",
"Note:": "Notiz:",
"Time:": "Zeit:",
"Format Options": "Formatoptionen",
"Export Date": "Exportdatum",
"Chapter Titles": "Kapiteltitel",
"Chapter Separator": "Kapiteltrennzeichen",
"Highlights": "Markierungen",
"Note Date": "Notizdatum",
"Advanced": "Erweitert",
"Hide": "Ausblenden",
"Show": "Anzeigen",
"Use Custom Template": "Benutzerdefinierte Vorlage verwenden",
"Export Template": "Exportvorlage",
"Template Syntax:": "Vorlagensyntax:",
"Insert value": "Wert einfügen",
"Format date (locale)": "Datum formatieren (Gebietsschema)",
"Format date (custom)": "Datum formatieren (benutzerdefiniert)",
"Conditional": "Bedingung",
"Loop": "Schleife",
"Available Variables:": "Verfügbare Variablen:",
"Book title": "Buchtitel",
"Book author": "Buchautor",
"Export date": "Exportdatum",
"Array of chapters": "Array von Kapiteln",
"Chapter title": "Kapiteltitel",
"Array of annotations": "Array von Anmerkungen",
"Highlighted text": "Markierter Text",
"Annotation note": "Anmerkungsnotiz",
"Update time": "Aktualisierungszeit",
"Date Format Tokens:": "Datumsformat-Token:",
"Year (4 digits)": "Jahr (4 Ziffern)",
"Month (01-12)": "Monat (01-12)",
"Day (01-31)": "Tag (01-31)",
"Hour (00-23)": "Stunde (00-23)",
"Minute (00-59)": "Minute (00-59)",
"Second (00-59)": "Sekunde (00-59)",
"Show Source": "Quelle anzeigen",
"No content to preview": "Kein Inhalt zur Vorschau",
"Export": "Exportieren",
"Set Timeout": "Zeitlimit festlegen",
"Select Voice": "Stimme auswählen",
"Toggle Sticky Bottom TTS Bar": "Fixierte TTS-Leiste umschalten",
"Display what I'm reading on Discord": "Zeige was ich auf Discord lese",
"Show on Discord": "Auf Discord zeigen"
}
@@ -173,9 +173,6 @@
"Token": "Κωδικός επαλήθευσης",
"Your OTP token": "Ο κωδικός OTP σας",
"Verify token": "Επαλήθευση κωδικού",
"Sign in with Google": "Σύνδεση με Google",
"Sign in with Apple": "Σύνδεση με Apple",
"Sign in with GitHub": "Σύνδεση με GitHub",
"Account": "Λογαριασμός",
"Failed to delete user. Please try again later.": "Αποτυχία διαγραφής χρήστη. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"Community Support": "Υποστήριξη κοινότητας",
@@ -341,8 +338,8 @@
"{{count}} pages left in chapter_one": "Μένει {{count}} σελίδα",
"{{count}} pages left in chapter_other": "Μένουν {{count}} σελίδες",
"Show Remaining Pages": "Εμφάνιση υπολοίπων",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Διαχείριση συνδρομής",
"Coming Soon": "Έρχεται σύντομα",
@@ -765,5 +762,133 @@
"Count": "Πλήθος",
"Start Page": "Αρχική Σελίδα",
"Search in OPDS Catalog...": "Αναζήτηση στον κατάλογο OPDS...",
"Please log in to use advanced TTS features.": "Παρακαλώ συνδεθείτε για να χρησιμοποιήσετε προηγμένες λειτουργίες TTS."
"Please log in to use advanced TTS features": "Παρακαλώ συνδεθείτε για να χρησιμοποιήσετε προηγμένες λειτουργίες TTS",
"Word limit of 30 words exceeded.": "Υπέρβαση ορίου 30 λέξεων.",
"Proofread": "Διόρθωση",
"Current selection": "Τρέχουσα επιλογή",
"All occurrences in this book": "Όλες οι εμφανίσεις σε αυτό το βιβλίο",
"All occurrences in your library": "Όλες οι εμφανίσεις στη βιβλιοθήκη σας",
"Selected text:": "Επιλεγμένο κείμενο:",
"Replace with:": "Αντικατάσταση με:",
"Enter text...": "Εισαγωγή κειμένου…",
"Case sensitive:": "Διάκριση πεζών-κεφαλαίων:",
"Scope:": "Εύρος εφαρμογής:",
"Selection": "Επιλογή",
"Library": "Βιβλιοθήκη",
"Yes": "Ναι",
"No": "Όχι",
"Proofread Replacement Rules": "Κανόνες αντικατάστασης διόρθωσης",
"Selected Text Rules": "Κανόνες επιλεγμένου κειμένου",
"No selected text replacement rules": "Δεν υπάρχουν κανόνες αντικατάστασης για το επιλεγμένο κείμενο",
"Book Specific Rules": "Κανόνες για συγκεκριμένο βιβλίο",
"No book-level replacement rules": "Δεν υπάρχουν κανόνες αντικατάστασης σε επίπεδο βιβλίου",
"Disable Quick Action": "Απενεργοποίηση γρήγορης ενέργειας",
"Enable Quick Action on Selection": "Ενεργοποίηση γρήγορης ενέργειας κατά την επιλογή",
"None": "Καμία",
"Annotation Tools": "Εργαλεία σχολιασμού",
"Enable Quick Actions": "Ενεργοποίηση γρήγορων ενεργειών",
"Quick Action": "Γρήγορη ενέργεια",
"Copy to Notebook": "Αντιγραφή στο σημειωματάριο",
"Copy text after selection": "Αντιγραφή κειμένου μετά την επιλογή",
"Highlight text after selection": "Επισήμανση κειμένου μετά την επιλογή",
"Annotate text after selection": "Σχολιασμός κειμένου μετά την επιλογή",
"Search text after selection": "Αναζήτηση κειμένου μετά την επιλογή",
"Look up text in dictionary after selection": "Αναζήτηση κειμένου στο λεξικό μετά την επιλογή",
"Look up text in Wikipedia after selection": "Αναζήτηση κειμένου στη Wikipedia μετά την επιλογή",
"Translate text after selection": "Μετάφραση κειμένου μετά την επιλογή",
"Read text aloud after selection": "Ανάγνωση κειμένου μετά την επιλογή",
"Proofread text after selection": "Διόρθωση κειμένου μετά την επιλογή",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} ενεργές, {{pendingCount}} σε αναμονή",
"{{failedCount}} failed": "{{failedCount}} απέτυχαν",
"Waiting...": "Αναμονή...",
"Failed": "Απέτυχε",
"Completed": "Ολοκληρώθηκε",
"Cancelled": "Ακυρώθηκε",
"Retry": "Επανάληψη",
"Active": "Ενεργές",
"Transfer Queue": "Ουρά μεταφορών",
"Upload All": "Ανέβασμα όλων",
"Download All": "Λήψη όλων",
"Resume Transfers": "Συνέχιση μεταφορών",
"Pause Transfers": "Παύση μεταφορών",
"Pending": "Σε αναμονή",
"No transfers": "Χωρίς μεταφορές",
"Retry All": "Επανάληψη όλων",
"Clear Completed": "Εκκαθάριση ολοκληρωμένων",
"Clear Failed": "Εκκαθάριση αποτυχημένων",
"Upload queued: {{title}}": "Ανέβασμα στην ουρά: {{title}}",
"Download queued: {{title}}": "Λήψη στην ουρά: {{title}}",
"Book not found in library": "Το βιβλίο δεν βρέθηκε στη βιβλιοθήκη",
"Unknown error": "Άγνωστο σφάλμα",
"Please log in to continue": "Παρακαλώ συνδεθείτε για να συνεχίσετε",
"Cloud File Transfers": "Μεταφορές αρχείων στο cloud",
"Show Search Results": "Εμφάνιση αποτελεσμάτων",
"Search results for '{{term}}'": "Αποτελέσματα για '{{term}}'",
"Close Search": "Κλείσιμο αναζήτησης",
"Previous Result": "Προηγούμενο αποτέλεσμα",
"Next Result": "Επόμενο αποτέλεσμα",
"Bookmarks": "Σελιδοδείκτες",
"Annotations": "Σχολιασμοί",
"Show Results": "Εμφάνιση αποτελεσμάτων",
"Clear search": "Εκκαθάριση αναζήτησης",
"Clear search history": "Εκκαθάριση ιστορικού αναζήτησης",
"Quick action disabled": "Η γρήγορη ενέργεια απενεργοποιήθηκε",
"Tap to Toggle Footer": "Πατήστε για εναλλαγή υποσέλιδου",
"Exported successfully": "Εξαγωγή επιτυχής",
"Book exported successfully.": "Το βιβλίο εξήχθη επιτυχώς.",
"Failed to export the book.": "Αποτυχία εξαγωγής του βιβλίου.",
"Export Book": "Εξαγωγή βιβλίου",
"Whole word:": "Ολόκληρη λέξη:",
"Error": "Σφάλμα",
"Unable to load the article. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης του άρθρου. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Αδυναμία φόρτωσης της λέξης. Δοκιμάστε να αναζητήσετε απευθείας στο {{link}}.",
"Date Published": "Ημερομηνία δημοσίευσης",
"Only for TTS:": "Μόνο για TTS:",
"Uploaded": "Μεταφορτώθηκε",
"Downloaded": "Λήφθηκε",
"Deleted": "Διαγράφηκε",
"Note:": "Σημείωση:",
"Time:": "Ώρα:",
"Format Options": "Επιλογές μορφοποίησης",
"Export Date": "Ημερομηνία εξαγωγής",
"Chapter Titles": "Τίτλοι κεφαλαίων",
"Chapter Separator": "Διαχωριστικό κεφαλαίων",
"Highlights": "Επισημάνσεις",
"Note Date": "Ημερομηνία σημείωσης",
"Advanced": "Για προχωρημένους",
"Hide": "Απόκρυψη",
"Show": "Εμφάνιση",
"Use Custom Template": "Χρήση προσαρμοσμένου προτύπου",
"Export Template": "Πρότυπο εξαγωγής",
"Template Syntax:": "Σύνταξη προτύπου:",
"Insert value": "Εισαγωγή τιμής",
"Format date (locale)": "Μορφοποίηση ημερομηνίας (τοπική)",
"Format date (custom)": "Μορφοποίηση ημερομηνίας (προσαρμοσμένη)",
"Conditional": "Υπό όρους",
"Loop": "Βρόχος",
"Available Variables:": "Διαθέσιμες μεταβλητές:",
"Book title": "Τίτλος βιβλίου",
"Book author": "Συγγραφέας βιβλίου",
"Export date": "Ημερομηνία εξαγωγής",
"Array of chapters": "Πίνακας κεφαλαίων",
"Chapter title": "Τίτλος κεφαλαίου",
"Array of annotations": "Πίνακας σχολιασμών",
"Highlighted text": "Επισημασμένο κείμενο",
"Annotation note": "Σημείωση σχολιασμού",
"Update time": "Ώρα ενημέρωσης",
"Date Format Tokens:": "Σύμβολα μορφοποίησης ημερομηνίας:",
"Year (4 digits)": "Έτος (4 ψηφία)",
"Month (01-12)": "Μήνας (01-12)",
"Day (01-31)": "Ημέρα (01-31)",
"Hour (00-23)": "Ώρα (00-23)",
"Minute (00-59)": "Λεπτό (00-59)",
"Second (00-59)": "Δευτερόλεπτο (00-59)",
"Show Source": "Εμφάνιση πηγής",
"No content to preview": "Δεν υπάρχει περιεχόμενο για προεπισκόπηση",
"Export": "Εξαγωγή",
"Set Timeout": "Ορισμός χρονικού ορίου",
"Select Voice": "Επιλογή φωνής",
"Toggle Sticky Bottom TTS Bar": "Εναλλαγή καρφιτσωμένης μπάρας TTS",
"Display what I'm reading on Discord": "Εμφάνιση του βιβλίου που διαβάζω στο Discord",
"Show on Discord": "Εμφάνιση στο Discord"
}
@@ -1,8 +1,8 @@
{
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Are you sure to delete {{count}} selected book(s)?_one": "Are you sure to delete {{count}} selected book?",
"Are you sure to delete {{count}} selected book(s)?_other": "Are you sure to delete {{count}} selected books?",
@@ -200,9 +200,6 @@
"Token": "Código de verificación",
"Your OTP token": "Tu código OTP",
"Verify token": "Verificar código",
"Sign in with Google": "Iniciar sesión con Google",
"Sign in with Apple": "Iniciar sesión con Apple",
"Sign in with GitHub": "Iniciar sesión con GitHub",
"Account": "Cuenta",
"Failed to delete user. Please try again later.": "Error al eliminar usuario. Por favor, inténtelo de nuevo más tarde.",
"Community Support": "Soporte comunitario",
@@ -372,8 +369,8 @@
"{{count}} pages left in chapter_many": "{{count}} páginas restantes en el capítulo",
"{{count}} pages left in chapter_other": "{{count}} páginas restantes en el capítulo",
"Show Remaining Pages": "Mostrar resto",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Gestionar suscripción",
"Coming Soon": "Próximamente",
@@ -775,5 +772,133 @@
"Count": "Cuenta",
"Start Page": "Página de inicio",
"Search in OPDS Catalog...": "Buscar en el catálogo OPDS...",
"Please log in to use advanced TTS features.": "Por favor, inicie sesión para usar funciones avanzadas de TTS."
"Please log in to use advanced TTS features": "Por favor, inicie sesión para usar funciones avanzadas de TTS",
"Word limit of 30 words exceeded.": "Se superó el límite de 30 palabras.",
"Proofread": "Corrección",
"Current selection": "Selección actual",
"All occurrences in this book": "Todas las apariciones en este libro",
"All occurrences in your library": "Todas las apariciones en tu biblioteca",
"Selected text:": "Texto seleccionado:",
"Replace with:": "Reemplazar por:",
"Enter text...": "Introduce texto…",
"Case sensitive:": "Distinguir mayúsculas y minúsculas:",
"Scope:": "Ámbito:",
"Selection": "Selección",
"Library": "Biblioteca",
"Yes": "Sí",
"No": "No",
"Proofread Replacement Rules": "Reglas de reemplazo de corrección",
"Selected Text Rules": "Reglas de texto seleccionado",
"No selected text replacement rules": "No hay reglas de reemplazo para el texto seleccionado",
"Book Specific Rules": "Reglas específicas del libro",
"No book-level replacement rules": "No hay reglas de reemplazo a nivel de libro",
"Disable Quick Action": "Desactivar acción rápida",
"Enable Quick Action on Selection": "Activar acción rápida al seleccionar",
"None": "Ninguna",
"Annotation Tools": "Herramientas de anotación",
"Enable Quick Actions": "Activar acciones rápidas",
"Quick Action": "Acción rápida",
"Copy to Notebook": "Copiar al cuaderno",
"Copy text after selection": "Copiar texto después de la selección",
"Highlight text after selection": "Resaltar texto después de la selección",
"Annotate text after selection": "Anotar texto después de la selección",
"Search text after selection": "Buscar texto después de la selección",
"Look up text in dictionary after selection": "Buscar texto en el diccionario después de la selección",
"Look up text in Wikipedia after selection": "Buscar texto en Wikipedia después de la selección",
"Translate text after selection": "Traducir texto después de la selección",
"Read text aloud after selection": "Leer texto en voz alta después de la selección",
"Proofread text after selection": "Corregir texto después de la selección",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} activas, {{pendingCount}} pendientes",
"{{failedCount}} failed": "{{failedCount}} fallidas",
"Waiting...": "Esperando...",
"Failed": "Fallido",
"Completed": "Completado",
"Cancelled": "Cancelado",
"Retry": "Reintentar",
"Active": "Activas",
"Transfer Queue": "Cola de transferencias",
"Upload All": "Subir todos",
"Download All": "Descargar todos",
"Resume Transfers": "Reanudar transferencias",
"Pause Transfers": "Pausar transferencias",
"Pending": "Pendientes",
"No transfers": "Sin transferencias",
"Retry All": "Reintentar todos",
"Clear Completed": "Limpiar completados",
"Clear Failed": "Limpiar fallidos",
"Upload queued: {{title}}": "Subida en cola: {{title}}",
"Download queued: {{title}}": "Descarga en cola: {{title}}",
"Book not found in library": "Libro no encontrado en la biblioteca",
"Unknown error": "Error desconocido",
"Please log in to continue": "Inicia sesión para continuar",
"Cloud File Transfers": "Transferencias en la nube",
"Show Search Results": "Mostrar resultados",
"Search results for '{{term}}'": "Resultados para '{{term}}'",
"Close Search": "Cerrar búsqueda",
"Previous Result": "Resultado anterior",
"Next Result": "Resultado siguiente",
"Bookmarks": "Marcadores",
"Annotations": "Anotaciones",
"Show Results": "Mostrar resultados",
"Clear search": "Borrar búsqueda",
"Clear search history": "Borrar historial de búsqueda",
"Quick action disabled": "Acción rápida desactivada",
"Tap to Toggle Footer": "Toca para alternar pie de página",
"Exported successfully": "Exportado con éxito",
"Book exported successfully.": "Libro exportado con éxito.",
"Failed to export the book.": "Error al exportar el libro.",
"Export Book": "Exportar libro",
"Whole word:": "Palabra completa:",
"Error": "Error",
"Unable to load the article. Try searching directly on {{link}}.": "No se puede cargar el artículo. Intenta buscar directamente en {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "No se puede cargar la palabra. Intenta buscar directamente en {{link}}.",
"Date Published": "Fecha de publicación",
"Only for TTS:": "Solo para TTS:",
"Uploaded": "Subido",
"Downloaded": "Descargado",
"Deleted": "Eliminado",
"Note:": "Nota:",
"Time:": "Hora:",
"Format Options": "Opciones de formato",
"Export Date": "Fecha de exportación",
"Chapter Titles": "Títulos de capítulos",
"Chapter Separator": "Separador de capítulos",
"Highlights": "Resaltados",
"Note Date": "Fecha de nota",
"Advanced": "Avanzado",
"Hide": "Ocultar",
"Show": "Mostrar",
"Use Custom Template": "Usar plantilla personalizada",
"Export Template": "Plantilla de exportación",
"Template Syntax:": "Sintaxis de plantilla:",
"Insert value": "Insertar valor",
"Format date (locale)": "Formatear fecha (regional)",
"Format date (custom)": "Formatear fecha (personalizado)",
"Conditional": "Condicional",
"Loop": "Bucle",
"Available Variables:": "Variables disponibles:",
"Book title": "Título del libro",
"Book author": "Autor del libro",
"Export date": "Fecha de exportación",
"Array of chapters": "Lista de capítulos",
"Chapter title": "Título del capítulo",
"Array of annotations": "Lista de anotaciones",
"Highlighted text": "Texto resaltado",
"Annotation note": "Nota de anotación",
"Update time": "Hora de actualización",
"Date Format Tokens:": "Tokens de formato de fecha:",
"Year (4 digits)": "Año (4 dígitos)",
"Month (01-12)": "Mes (01-12)",
"Day (01-31)": "Día (01-31)",
"Hour (00-23)": "Hora (00-23)",
"Minute (00-59)": "Minuto (00-59)",
"Second (00-59)": "Segundo (00-59)",
"Show Source": "Mostrar fuente",
"No content to preview": "Sin contenido para previsualizar",
"Export": "Exportar",
"Set Timeout": "Establecer tiempo límite",
"Select Voice": "Seleccionar voz",
"Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fija",
"Display what I'm reading on Discord": "Mostrar lo que estoy leyendo en Discord",
"Show on Discord": "Mostrar en Discord"
}
@@ -172,9 +172,6 @@
"Token": "توکن",
"Your OTP token": "کد یک‌بارمصرف شما",
"Verify token": "تأیید کد",
"Sign in with Google": "ورود با گوگل",
"Sign in with Apple": "ورود با اپل",
"Sign in with GitHub": "ورود با گیت‌هاب",
"Account": "حساب",
"Failed to delete user. Please try again later.": "حذف کاربر ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
"Community Support": "پشتیبانی جامعه",
@@ -340,8 +337,8 @@
"{{count}} pages left in chapter_one": "{{count}} صفحه تا انتهای فصل",
"{{count}} pages left in chapter_other": "{{count}} صفحه تا انتهای فصل",
"Show Remaining Pages": "نمایش صفحات باقی‌مانده",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Manage Subscription": "مدیریت اشتراک",
"Coming Soon": "به‌زودی",
@@ -765,5 +762,133 @@
"Count": "تعداد",
"Start Page": "صفحه شروع",
"Search in OPDS Catalog...": "جستجو در فهرست OPDS...",
"Please log in to use advanced TTS features.": "لطفاً برای استفاده از ویژگی‌های پیشرفته TTS وارد شوید."
"Please log in to use advanced TTS features": "لطفاً برای استفاده از ویژگی‌های پیشرفته TTS وارد شوید.",
"Word limit of 30 words exceeded.": "محدودیت ۳۰ واژه‌ای تجاوز شد.",
"Proofread": "بازبینی",
"Current selection": "انتخاب فعلی",
"All occurrences in this book": "همه موارد در این کتاب",
"All occurrences in your library": "همه موارد در کتابخانه شما",
"Selected text:": "متن انتخاب‌شده:",
"Replace with:": "جایگزین با:",
"Enter text...": "متن را وارد کنید…",
"Case sensitive:": "حساس به حروف بزرگ و کوچک:",
"Scope:": "دامنه:",
"Selection": "انتخاب",
"Library": "کتابخانه",
"Yes": "بله",
"No": "خیر",
"Proofread Replacement Rules": "قوانین جایگزینی بازبینی",
"Selected Text Rules": "قوانین متن انتخاب‌شده",
"No selected text replacement rules": "هیچ قانون جایگزینی برای متن انتخاب‌شده وجود ندارد",
"Book Specific Rules": "قوانین مخصوص کتاب",
"No book-level replacement rules": "هیچ قانون جایگزینی در سطح کتاب وجود ندارد",
"Disable Quick Action": "غیرفعال‌سازی اقدام سریع",
"Enable Quick Action on Selection": "فعال‌سازی اقدام سریع هنگام انتخاب",
"None": "هیچ‌کدام",
"Annotation Tools": "ابزارهای یادداشت‌گذاری",
"Enable Quick Actions": "فعال‌سازی اقدامات سریع",
"Quick Action": "اقدام سریع",
"Copy to Notebook": "کپی به دفترچه",
"Copy text after selection": "کپی متن پس از انتخاب",
"Highlight text after selection": "برجسته‌سازی متن پس از انتخاب",
"Annotate text after selection": "یادداشت‌گذاری متن پس از انتخاب",
"Search text after selection": "جستجوی متن پس از انتخاب",
"Look up text in dictionary after selection": "جستجوی متن در فرهنگ لغت پس از انتخاب",
"Look up text in Wikipedia after selection": "جستجوی متن در ویکی‌پدیا پس از انتخاب",
"Translate text after selection": "ترجمه متن پس از انتخاب",
"Read text aloud after selection": "خواندن متن با صدای بلند پس از انتخاب",
"Proofread text after selection": "بازبینی متن پس از انتخاب",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} فعال، {{pendingCount}} در انتظار",
"{{failedCount}} failed": "{{failedCount}} ناموفق",
"Waiting...": "در انتظار...",
"Failed": "ناموفق",
"Completed": "تکمیل شده",
"Cancelled": "لغو شده",
"Retry": "تلاش مجدد",
"Active": "فعال",
"Transfer Queue": "صف انتقال",
"Upload All": "بارگذاری همه",
"Download All": "دانلود همه",
"Resume Transfers": "ادامه انتقال‌ها",
"Pause Transfers": "توقف انتقال‌ها",
"Pending": "در انتظار",
"No transfers": "بدون انتقال",
"Retry All": "تلاش مجدد همه",
"Clear Completed": "پاک کردن تکمیل شده‌ها",
"Clear Failed": "پاک کردن ناموفق‌ها",
"Upload queued: {{title}}": "بارگذاری در صف: {{title}}",
"Download queued: {{title}}": "دانلود در صف: {{title}}",
"Book not found in library": "کتاب در کتابخانه یافت نشد",
"Unknown error": "خطای ناشناخته",
"Please log in to continue": "لطفاً برای ادامه وارد شوید",
"Cloud File Transfers": "انتقال فایل‌های ابری",
"Show Search Results": "نمایش نتایج جستجو",
"Search results for '{{term}}'": "نتایج برای «{{term}}»",
"Close Search": "بستن جستجو",
"Previous Result": "نتیجه قبلی",
"Next Result": "نتیجه بعدی",
"Bookmarks": "نشانک‌ها",
"Annotations": "یادداشت‌ها",
"Show Results": "نمایش نتایج",
"Clear search": "پاک کردن جستجو",
"Clear search history": "پاک کردن تاریخچه جستجو",
"Quick action disabled": "عملکرد سریع غیرفعال است",
"Tap to Toggle Footer": "برای تغییر پاورقی ضربه بزنید",
"Exported successfully": "صادرات موفق",
"Book exported successfully.": "کتاب با موفقیت صادر شد.",
"Failed to export the book.": "صادر کردن کتاب ناموفق بود.",
"Export Book": "صادر کردن کتاب",
"Whole word:": "کلمه کامل:",
"Error": "خطا",
"Unable to load the article. Try searching directly on {{link}}.": "بارگذاری مقاله امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Unable to load the word. Try searching directly on {{link}}.": "بارگذاری کلمه امکان‌پذیر نیست. سعی کنید مستقیماً در {{link}} جستجو کنید.",
"Date Published": "تاریخ انتشار",
"Only for TTS:": "فقط برای TTS:",
"Uploaded": "بارگذاری شد",
"Downloaded": "دانلود شد",
"Deleted": "حذف شد",
"Note:": "یادداشت:",
"Time:": "زمان:",
"Format Options": "گزینه‌های قالب",
"Export Date": "تاریخ خروجی",
"Chapter Titles": "عناوین فصل",
"Chapter Separator": "جداکننده فصل",
"Highlights": "برجسته‌ها",
"Note Date": "تاریخ یادداشت",
"Advanced": "پیشرفته",
"Hide": "پنهان کردن",
"Show": "نمایش",
"Use Custom Template": "استفاده از الگوی سفارشی",
"Export Template": "الگوی خروجی",
"Template Syntax:": "نحو الگو:",
"Insert value": "درج مقدار",
"Format date (locale)": "قالب‌بندی تاریخ (محلی)",
"Format date (custom)": "قالب‌بندی تاریخ (سفارشی)",
"Conditional": "شرطی",
"Loop": "حلقه",
"Available Variables:": "متغیرهای موجود:",
"Book title": "عنوان کتاب",
"Book author": "نویسنده کتاب",
"Export date": "تاریخ خروجی",
"Array of chapters": "آرایه فصل‌ها",
"Chapter title": "عنوان فصل",
"Array of annotations": "آرایه یادداشت‌ها",
"Highlighted text": "متن برجسته‌شده",
"Annotation note": "یادداشت حاشیه",
"Update time": "زمان به‌روزرسانی",
"Date Format Tokens:": "نشانه‌های قالب تاریخ:",
"Year (4 digits)": "سال (۴ رقم)",
"Month (01-12)": "ماه (۰۱-۱۲)",
"Day (01-31)": "روز (۰۱-۳۱)",
"Hour (00-23)": "ساعت (۰۰-۲۳)",
"Minute (00-59)": "دقیقه (۰۰-۵۹)",
"Second (00-59)": "ثانیه (۰۰-۵۹)",
"Show Source": "نمایش منبع",
"No content to preview": "محتوایی برای پیش‌نمایش وجود ندارد",
"Export": "خروجی",
"Set Timeout": "تنظیم مهلت زمانی",
"Select Voice": "انتخاب صدا",
"Toggle Sticky Bottom TTS Bar": "تغییر نوار TTS ثابت",
"Display what I'm reading on Discord": "نمایش کتاب در حال خواندن در Discord",
"Show on Discord": "نمایش در Discord"
}
@@ -172,9 +172,6 @@
"Token": "Code de vérification",
"Your OTP token": "Votre code OTP",
"Verify token": "Vérifier le code",
"Sign in with Google": "Se connecter avec Google",
"Sign in with Apple": "Se connecter avec Apple",
"Sign in with GitHub": "Se connecter avec GitHub",
"Account": "Compte",
"Failed to delete user. Please try again later.": "Échec de la suppression de l'utilisateur. Veuillez réessayer plus tard.",
"Community Support": "Support communautaire",
@@ -344,8 +341,8 @@
"{{count}} pages left in chapter_many": "{{count}} pages restantes dans le chapitre",
"{{count}} pages left in chapter_other": "{{count}} pages restantes dans le chapitre",
"Show Remaining Pages": "Voir restantes",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Gérer labonnement",
"Coming Soon": "Bientôt disponible",
@@ -775,5 +772,133 @@
"Count": "Nombre",
"Start Page": "Page de départ",
"Search in OPDS Catalog...": "Rechercher dans le catalogue OPDS...",
"Please log in to use advanced TTS features.": "Veuillez vous connecter pour utiliser les fonctionnalités avancées de TTS."
"Please log in to use advanced TTS features": "Veuillez vous connecter pour utiliser les fonctionnalités avancées de TTS",
"Word limit of 30 words exceeded.": "La limite de 30 mots a été dépassée.",
"Proofread": "Correction",
"Current selection": "Sélection actuelle",
"All occurrences in this book": "Toutes les occurrences dans ce livre",
"All occurrences in your library": "Toutes les occurrences dans votre bibliothèque",
"Selected text:": "Texte sélectionné :",
"Replace with:": "Remplacer par :",
"Enter text...": "Saisir du texte…",
"Case sensitive:": "Respecter la casse :",
"Scope:": "Portée :",
"Selection": "Sélection",
"Library": "Bibliothèque",
"Yes": "Oui",
"No": "Non",
"Proofread Replacement Rules": "Règles de remplacement de correction",
"Selected Text Rules": "Règles du texte sélectionné",
"No selected text replacement rules": "Aucune règle de remplacement pour le texte sélectionné",
"Book Specific Rules": "Règles spécifiques au livre",
"No book-level replacement rules": "Aucune règle de remplacement au niveau du livre",
"Disable Quick Action": "Désactiver laction rapide",
"Enable Quick Action on Selection": "Activer laction rapide lors de la sélection",
"None": "Aucune",
"Annotation Tools": "Outils dannotation",
"Enable Quick Actions": "Activer les actions rapides",
"Quick Action": "Action rapide",
"Copy to Notebook": "Copier dans le carnet",
"Copy text after selection": "Copier le texte après la sélection",
"Highlight text after selection": "Mettre en surbrillance le texte après la sélection",
"Annotate text after selection": "Annoter le texte après la sélection",
"Search text after selection": "Rechercher le texte après la sélection",
"Look up text in dictionary after selection": "Chercher le texte dans le dictionnaire après la sélection",
"Look up text in Wikipedia after selection": "Chercher le texte dans Wikipédia après la sélection",
"Translate text after selection": "Traduire le texte après la sélection",
"Read text aloud after selection": "Lire le texte à haute voix après la sélection",
"Proofread text after selection": "Relire le texte après la sélection",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} actifs, {{pendingCount}} en attente",
"{{failedCount}} failed": "{{failedCount}} échoués",
"Waiting...": "En attente...",
"Failed": "Échoué",
"Completed": "Terminé",
"Cancelled": "Annulé",
"Retry": "Réessayer",
"Active": "Actifs",
"Transfer Queue": "File de transfert",
"Upload All": "Tout téléverser",
"Download All": "Tout télécharger",
"Resume Transfers": "Reprendre les transferts",
"Pause Transfers": "Suspendre les transferts",
"Pending": "En attente",
"No transfers": "Aucun transfert",
"Retry All": "Tout réessayer",
"Clear Completed": "Effacer les terminés",
"Clear Failed": "Effacer les échoués",
"Upload queued: {{title}}": "Téléversement en file: {{title}}",
"Download queued: {{title}}": "Téléchargement en file: {{title}}",
"Book not found in library": "Livre non trouvé dans la bibliothèque",
"Unknown error": "Erreur inconnue",
"Please log in to continue": "Veuillez vous connecter pour continuer",
"Cloud File Transfers": "Transferts de fichiers cloud",
"Show Search Results": "Afficher les résultats",
"Search results for '{{term}}'": "Résultats pour « {{term}} »",
"Close Search": "Fermer la recherche",
"Previous Result": "Résultat précédent",
"Next Result": "Résultat suivant",
"Bookmarks": "Signets",
"Annotations": "Annotations",
"Show Results": "Afficher les résultats",
"Clear search": "Effacer la recherche",
"Clear search history": "Effacer l'historique de recherche",
"Quick action disabled": "Action rapide désactivée",
"Tap to Toggle Footer": "Appuyez pour afficher/masquer le pied de page",
"Exported successfully": "Exporté avec succès",
"Book exported successfully.": "Livre exporté avec succès.",
"Failed to export the book.": "Échec de l'exportation du livre.",
"Export Book": "Exporter le livre",
"Whole word:": "Mot entier :",
"Error": "Erreur",
"Unable to load the article. Try searching directly on {{link}}.": "Impossible de charger l'article. Essayez de rechercher directement sur {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossible de charger le mot. Essayez de rechercher directement sur {{link}}.",
"Date Published": "Date de publication",
"Only for TTS:": "Uniquement pour TTS :",
"Uploaded": "Téléversé",
"Downloaded": "Téléchargé",
"Deleted": "Supprimé",
"Note:": "Note :",
"Time:": "Heure :",
"Format Options": "Options de format",
"Export Date": "Date d'export",
"Chapter Titles": "Titres de chapitres",
"Chapter Separator": "Séparateur de chapitres",
"Highlights": "Surlignages",
"Note Date": "Date de note",
"Advanced": "Avancé",
"Hide": "Masquer",
"Show": "Afficher",
"Use Custom Template": "Utiliser un modèle personnalisé",
"Export Template": "Modèle d'export",
"Template Syntax:": "Syntaxe du modèle :",
"Insert value": "Insérer une valeur",
"Format date (locale)": "Formater la date (locale)",
"Format date (custom)": "Formater la date (personnalisé)",
"Conditional": "Conditionnel",
"Loop": "Boucle",
"Available Variables:": "Variables disponibles :",
"Book title": "Titre du livre",
"Book author": "Auteur du livre",
"Export date": "Date d'export",
"Array of chapters": "Liste de chapitres",
"Chapter title": "Titre du chapitre",
"Array of annotations": "Liste d'annotations",
"Highlighted text": "Texte surligné",
"Annotation note": "Note d'annotation",
"Update time": "Heure de mise à jour",
"Date Format Tokens:": "Jetons de format de date :",
"Year (4 digits)": "Année (4 chiffres)",
"Month (01-12)": "Mois (01-12)",
"Day (01-31)": "Jour (01-31)",
"Hour (00-23)": "Heure (00-23)",
"Minute (00-59)": "Minute (00-59)",
"Second (00-59)": "Seconde (00-59)",
"Show Source": "Afficher la source",
"No content to preview": "Aucun contenu à prévisualiser",
"Export": "Exporter",
"Set Timeout": "Définir le délai",
"Select Voice": "Sélectionner la voix",
"Toggle Sticky Bottom TTS Bar": "Basculer la barre TTS fixée",
"Display what I'm reading on Discord": "Afficher ce que je lis sur Discord",
"Show on Discord": "Afficher sur Discord"
}
@@ -173,9 +173,6 @@
"Token": "टोकन",
"Your OTP token": "आपका ओटीपी टोकन",
"Verify token": "टोकन सत्यापित करें",
"Sign in with Google": "Google के साथ साइन इन करें",
"Sign in with Apple": "Apple के साथ साइन इन करें",
"Sign in with GitHub": "GitHub के साथ साइन इन करें",
"Account": "खाता",
"Failed to delete user. Please try again later.": "उपयोगकर्ता को हटाने में विफल। कृपया बाद में पुनः प्रयास करें।",
"Community Support": "सामुदायिक सहायता",
@@ -341,8 +338,8 @@
"{{count}} pages left in chapter_one": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
"{{count}} pages left in chapter_other": "इस अध्याय में {{count}} पृष्ठ शेष हैं",
"Show Remaining Pages": "शेष पृष्ठ दिखाएँ",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "सदस्यता प्रबंधित करें",
"Coming Soon": "जल्द आ रहा है",
@@ -765,5 +762,133 @@
"Count": "गणना",
"Start Page": "प्रारंभ पृष्ठ",
"Search in OPDS Catalog...": "OPDS कैटलॉग में खोजें...",
"Please log in to use advanced TTS features.": "उन्नत TTS सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।"
"Please log in to use advanced TTS features": "उन्नत TTS सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।",
"Word limit of 30 words exceeded.": "30 शब्दों की सीमा पार हो गई है।",
"Proofread": "प्रूफ़रीड",
"Current selection": "वर्तमान चयन",
"All occurrences in this book": "इस पुस्तक में सभी स्थान",
"All occurrences in your library": "आपकी लाइब्रेरी में सभी स्थान",
"Selected text:": "चयनित पाठ:",
"Replace with:": "से बदलें:",
"Enter text...": "पाठ दर्ज करें…",
"Case sensitive:": "अक्षर संवेदनशील:",
"Scope:": "क्षेत्र:",
"Selection": "चयन",
"Library": "लाइब्रेरी",
"Yes": "हाँ",
"No": "नहीं",
"Proofread Replacement Rules": "प्रूफ़रीड प्रतिस्थापन नियम",
"Selected Text Rules": "चयनित पाठ के नियम",
"No selected text replacement rules": "चयनित पाठ के लिए कोई प्रतिस्थापन नियम नहीं हैं",
"Book Specific Rules": "पुस्तक-विशिष्ट नियम",
"No book-level replacement rules": "पुस्तक स्तर पर कोई प्रतिस्थापन नियम नहीं हैं",
"Disable Quick Action": "त्वरित क्रिया अक्षम करें",
"Enable Quick Action on Selection": "चयन पर त्वरित क्रिया सक्षम करें",
"None": "कोई नहीं",
"Annotation Tools": "टिप्पणी उपकरण",
"Enable Quick Actions": "त्वरित क्रियाएँ सक्षम करें",
"Quick Action": "त्वरित क्रिया",
"Copy to Notebook": "नोटबुक में कॉपी करें",
"Copy text after selection": "पाठ कॉपी करें चयन के बाद",
"Highlight text after selection": "चयन के बाद पाठ हाइलाइट करें",
"Annotate text after selection": "चयन के बाद पाठ पर टिप्पणी करें",
"Search text after selection": "चयन के बाद पाठ खोजें",
"Look up text in dictionary after selection": "चयन के बाद शब्दकोश में पाठ देखें",
"Look up text in Wikipedia after selection": "चयन के बाद विकिपीडिया में पाठ देखें",
"Translate text after selection": "चयन के बाद पाठ का अनुवाद करें",
"Read text aloud after selection": "चयन के बाद पाठ को जोर से पढ़ें",
"Proofread text after selection": "चयन के बाद पाठ को प्रूफरीड करें",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} सक्रिय, {{pendingCount}} लंबित",
"{{failedCount}} failed": "{{failedCount}} विफल",
"Waiting...": "प्रतीक्षा कर रहे हैं...",
"Failed": "विफल",
"Completed": "पूर्ण",
"Cancelled": "रद्द",
"Retry": "पुनः प्रयास करें",
"Active": "सक्रिय",
"Transfer Queue": "स्थानांतरण कतार",
"Upload All": "सभी अपलोड करें",
"Download All": "सभी डाउनलोड करें",
"Resume Transfers": "स्थानांतरण जारी रखें",
"Pause Transfers": "स्थानांतरण रोकें",
"Pending": "लंबित",
"No transfers": "कोई स्थानांतरण नहीं",
"Retry All": "सभी पुनः प्रयास करें",
"Clear Completed": "पूर्ण साफ़ करें",
"Clear Failed": "विफल साफ़ करें",
"Upload queued: {{title}}": "अपलोड कतार में: {{title}}",
"Download queued: {{title}}": "डाउनलोड कतार में: {{title}}",
"Book not found in library": "पुस्तकालय में पुस्तक नहीं मिली",
"Unknown error": "अज्ञात त्रुटि",
"Please log in to continue": "जारी रखने के लिए लॉगिन करें",
"Cloud File Transfers": "क्लाउड फ़ाइल स्थानांतरण",
"Show Search Results": "खोज परिणाम दिखाएं",
"Search results for '{{term}}'": "'{{term}}' के परिणाम",
"Close Search": "खोज बंद करें",
"Previous Result": "पिछला परिणाम",
"Next Result": "अगला परिणाम",
"Bookmarks": "बुकमार्क",
"Annotations": "टिप्पणियाँ",
"Show Results": "परिणाम दिखाएं",
"Clear search": "खोज साफ़ करें",
"Clear search history": "खोज इतिहास साफ़ करें",
"Quick action disabled": "त्वरित क्रिया अक्षम",
"Tap to Toggle Footer": "फुटर टॉगल करने के लिए टैप करें",
"Exported successfully": "सफलतापूर्वक निर्यात किया गया",
"Book exported successfully.": "पुस्तक सफलतापूर्वक निर्यात की गई।",
"Failed to export the book.": "पुस्तक निर्यात करने में विफल।",
"Export Book": "पुस्तक निर्यात करें",
"Whole word:": "पूरा शब्द:",
"Error": "त्रुटि",
"Unable to load the article. Try searching directly on {{link}}.": "लेख लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Unable to load the word. Try searching directly on {{link}}.": "शब्द लोड करने में असमर्थ। सीधे {{link}} पर खोजने का प्रयास करें।",
"Date Published": "प्रकाशन तिथि",
"Only for TTS:": "केवल TTS के लिए:",
"Uploaded": "अपलोड किया गया",
"Downloaded": "डाउनलोड किया गया",
"Deleted": "हटाया गया",
"Note:": "नोट:",
"Time:": "समय:",
"Format Options": "प्रारूप विकल्प",
"Export Date": "निर्यात तिथि",
"Chapter Titles": "अध्याय शीर्षक",
"Chapter Separator": "अध्याय विभाजक",
"Highlights": "हाइलाइट्स",
"Note Date": "नोट तिथि",
"Advanced": "उन्नत",
"Hide": "छिपाएं",
"Show": "दिखाएं",
"Use Custom Template": "कस्टम टेम्पलेट उपयोग करें",
"Export Template": "निर्यात टेम्पलेट",
"Template Syntax:": "टेम्पलेट सिंटैक्स:",
"Insert value": "मान डालें",
"Format date (locale)": "तिथि फ़ॉर्मेट करें (स्थानीय)",
"Format date (custom)": "तिथि फ़ॉर्मेट करें (कस्टम)",
"Conditional": "सशर्त",
"Loop": "लूप",
"Available Variables:": "उपलब्ध चर:",
"Book title": "पुस्तक शीर्षक",
"Book author": "पुस्तक लेखक",
"Export date": "निर्यात तिथि",
"Array of chapters": "अध्यायों की सूची",
"Chapter title": "अध्याय शीर्षक",
"Array of annotations": "एनोटेशन की सूची",
"Highlighted text": "हाइलाइट किया गया पाठ",
"Annotation note": "एनोटेशन नोट",
"Update time": "अपडेट समय",
"Date Format Tokens:": "तिथि प्रारूप टोकन:",
"Year (4 digits)": "वर्ष (4 अंक)",
"Month (01-12)": "माह (01-12)",
"Day (01-31)": "दिन (01-31)",
"Hour (00-23)": "घंटा (00-23)",
"Minute (00-59)": "मिनट (00-59)",
"Second (00-59)": "सेकंड (00-59)",
"Show Source": "स्रोत दिखाएं",
"No content to preview": "पूर्वावलोकन के लिए कोई सामग्री नहीं",
"Export": "निर्यात करें",
"Set Timeout": "टाइमआउट सेट करें",
"Select Voice": "आवाज़ चुनें",
"Toggle Sticky Bottom TTS Bar": "स्थिर TTS बार टॉगल करें",
"Display what I'm reading on Discord": "Discord पर पढ़ रही किताब दिखाएं",
"Show on Discord": "Discord पर दिखाएं"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "Token OTP Anda",
"Verify token": "Verifikasi token",
"Sign in with Google": "Masuk dengan Google",
"Sign in with Apple": "Masuk dengan Apple",
"Sign in with GitHub": "Masuk dengan GitHub",
"Account": "Akun",
"Failed to delete user. Please try again later.": "Gagal menghapus pengguna. Silakan coba lagi nanti.",
"Community Support": "Dukungan Komunitas",
@@ -337,8 +334,8 @@
"Reset Settings": "Atur Ulang Pengaturan",
"{{count}} pages left in chapter_other": "{{count}} halaman tersisa di bab ini",
"Show Remaining Pages": "Tampilkan halaman tersisa",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Kelola Langganan",
"Coming Soon": "Segera Hadir",
@@ -755,5 +752,133 @@
"Count": "Jumlah",
"Start Page": "Halaman Awal",
"Search in OPDS Catalog...": "Cari di Katalog OPDS...",
"Please log in to use advanced TTS features.": "Silakan masuk untuk menggunakan fitur TTS lanjutan."
"Please log in to use advanced TTS features": "Silakan masuk untuk menggunakan fitur TTS lanjutan",
"Word limit of 30 words exceeded.": "Batas 30 kata telah terlampaui.",
"Proofread": "Koreksi",
"Current selection": "Pilihan saat ini",
"All occurrences in this book": "Semua kemunculan di buku ini",
"All occurrences in your library": "Semua kemunculan di perpustakaan Anda",
"Selected text:": "Teks terpilih:",
"Replace with:": "Ganti dengan:",
"Enter text...": "Masukkan teks…",
"Case sensitive:": "Peka huruf besar dan kecil:",
"Scope:": "Cakupan:",
"Selection": "Pilihan",
"Library": "Perpustakaan",
"Yes": "Ya",
"No": "Tidak",
"Proofread Replacement Rules": "Aturan penggantian koreksi",
"Selected Text Rules": "Aturan teks terpilih",
"No selected text replacement rules": "Tidak ada aturan penggantian untuk teks terpilih",
"Book Specific Rules": "Aturan khusus buku",
"No book-level replacement rules": "Tidak ada aturan penggantian tingkat buku",
"Disable Quick Action": "Nonaktifkan Aksi Cepat",
"Enable Quick Action on Selection": "Aktifkan Aksi Cepat saat memilih",
"None": "Tidak ada",
"Annotation Tools": "Alat Anotasi",
"Enable Quick Actions": "Aktifkan Aksi Cepat",
"Quick Action": "Aksi Cepat",
"Copy to Notebook": "Salin ke Buku Catatan",
"Copy text after selection": "Salin teks setelah pemilihan",
"Highlight text after selection": "Sorot teks setelah pemilihan",
"Annotate text after selection": "Anotasi teks setelah pemilihan",
"Search text after selection": "Cari teks setelah pemilihan",
"Look up text in dictionary after selection": "Cari teks di kamus setelah pemilihan",
"Look up text in Wikipedia after selection": "Cari teks di Wikipedia setelah pemilihan",
"Translate text after selection": "Terjemahkan teks setelah pemilihan",
"Read text aloud after selection": "Bacakan teks setelah pemilihan",
"Proofread text after selection": "Periksa teks setelah pemilihan",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} menunggu",
"{{failedCount}} failed": "{{failedCount}} gagal",
"Waiting...": "Menunggu...",
"Failed": "Gagal",
"Completed": "Selesai",
"Cancelled": "Dibatalkan",
"Retry": "Coba lagi",
"Active": "Aktif",
"Transfer Queue": "Antrian Transfer",
"Upload All": "Unggah Semua",
"Download All": "Unduh Semua",
"Resume Transfers": "Lanjutkan Transfer",
"Pause Transfers": "Jeda Transfer",
"Pending": "Menunggu",
"No transfers": "Tidak ada transfer",
"Retry All": "Coba Semua Lagi",
"Clear Completed": "Hapus Selesai",
"Clear Failed": "Hapus Gagal",
"Upload queued: {{title}}": "Unggahan dalam antrian: {{title}}",
"Download queued: {{title}}": "Unduhan dalam antrian: {{title}}",
"Book not found in library": "Buku tidak ditemukan di perpustakaan",
"Unknown error": "Kesalahan tidak diketahui",
"Please log in to continue": "Silakan masuk untuk melanjutkan",
"Cloud File Transfers": "Transfer File Cloud",
"Show Search Results": "Tampilkan hasil pencarian",
"Search results for '{{term}}'": "Hasil untuk '{{term}}'",
"Close Search": "Tutup pencarian",
"Previous Result": "Hasil sebelumnya",
"Next Result": "Hasil berikutnya",
"Bookmarks": "Penanda",
"Annotations": "Anotasi",
"Show Results": "Tampilkan Hasil",
"Clear search": "Hapus pencarian",
"Clear search history": "Hapus riwayat pencarian",
"Quick action disabled": "Tindakan cepat dinonaktifkan",
"Tap to Toggle Footer": "Ketuk untuk beralih footer",
"Exported successfully": "Berhasil diekspor",
"Book exported successfully.": "Buku berhasil diekspor.",
"Failed to export the book.": "Gagal mengekspor buku.",
"Export Book": "Ekspor Buku",
"Whole word:": "Kata utuh:",
"Error": "Kesalahan",
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuat artikel. Coba cari langsung di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuat kata. Coba cari langsung di {{link}}.",
"Date Published": "Tanggal terbit",
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Diunggah",
"Downloaded": "Diunduh",
"Deleted": "Dihapus",
"Note:": "Catatan:",
"Time:": "Waktu:",
"Format Options": "Opsi Format",
"Export Date": "Tanggal Ekspor",
"Chapter Titles": "Judul Bab",
"Chapter Separator": "Pemisah Bab",
"Highlights": "Sorotan",
"Note Date": "Tanggal Catatan",
"Advanced": "Lanjutan",
"Hide": "Sembunyikan",
"Show": "Tampilkan",
"Use Custom Template": "Gunakan Template Kustom",
"Export Template": "Template Ekspor",
"Template Syntax:": "Sintaks Template:",
"Insert value": "Masukkan nilai",
"Format date (locale)": "Format tanggal (lokal)",
"Format date (custom)": "Format tanggal (kustom)",
"Conditional": "Kondisional",
"Loop": "Loop",
"Available Variables:": "Variabel yang Tersedia:",
"Book title": "Judul buku",
"Book author": "Penulis buku",
"Export date": "Tanggal ekspor",
"Array of chapters": "Daftar bab",
"Chapter title": "Judul bab",
"Array of annotations": "Daftar anotasi",
"Highlighted text": "Teks yang disorot",
"Annotation note": "Catatan anotasi",
"Update time": "Waktu pembaruan",
"Date Format Tokens:": "Token Format Tanggal:",
"Year (4 digits)": "Tahun (4 digit)",
"Month (01-12)": "Bulan (01-12)",
"Day (01-31)": "Hari (01-31)",
"Hour (00-23)": "Jam (00-23)",
"Minute (00-59)": "Menit (00-59)",
"Second (00-59)": "Detik (00-59)",
"Show Source": "Tampilkan Sumber",
"No content to preview": "Tidak ada konten untuk dipratinjau",
"Export": "Ekspor",
"Set Timeout": "Atur batas waktu",
"Select Voice": "Pilih suara",
"Toggle Sticky Bottom TTS Bar": "Alihkan bilah TTS tetap",
"Display what I'm reading on Discord": "Tampilkan buku yang sedang dibaca di Discord",
"Show on Discord": "Tampilkan di Discord"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "Il tuo token OTP",
"Verify token": "Verifica token",
"Sign in with Google": "Accedi con Google",
"Sign in with Apple": "Accedi con Apple",
"Sign in with GitHub": "Accedi con GitHub",
"Account": "Account",
"Failed to delete user. Please try again later.": "Impossibile eliminare l'utente. Riprova più tardi.",
"Community Support": "Supporto della community",
@@ -345,8 +342,8 @@
"{{count}} pages left in chapter_many": "{{count}} pagine rimaste nel capitolo",
"{{count}} pages left in chapter_other": "{{count}} pagine rimaste nel capitolo",
"Show Remaining Pages": "Mostra pagine rimanenti",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Gestisci Abbonamento",
"Coming Soon": "Prossimamente",
@@ -775,5 +772,133 @@
"Count": "Conteggio",
"Start Page": "Pagina iniziale",
"Search in OPDS Catalog...": "Cerca nel catalogo OPDS...",
"Please log in to use advanced TTS features.": "Effettua il login per utilizzare le funzionalità TTS avanzate."
"Please log in to use advanced TTS features": "Effettua il login per utilizzare le funzionalità TTS avanzate",
"Word limit of 30 words exceeded.": "È stato superato il limite di 30 parole.",
"Proofread": "Revisione",
"Current selection": "Selezione corrente",
"All occurrences in this book": "Tutte le occorrenze in questo libro",
"All occurrences in your library": "Tutte le occorrenze nella tua libreria",
"Selected text:": "Testo selezionato:",
"Replace with:": "Sostituisci con:",
"Enter text...": "Inserisci testo…",
"Case sensitive:": "Maiuscole/minuscole:",
"Scope:": "Ambito:",
"Selection": "Selezione",
"Library": "Libreria",
"Yes": "Sì",
"No": "No",
"Proofread Replacement Rules": "Regole di sostituzione per la revisione",
"Selected Text Rules": "Regole per il testo selezionato",
"No selected text replacement rules": "Nessuna regola di sostituzione per il testo selezionato",
"Book Specific Rules": "Regole specifiche del libro",
"No book-level replacement rules": "Nessuna regola di sostituzione a livello di libro",
"Disable Quick Action": "Disattiva azione rapida",
"Enable Quick Action on Selection": "Attiva azione rapida sulla selezione",
"None": "Nessuno",
"Annotation Tools": "Strumenti di annotazione",
"Enable Quick Actions": "Attiva azioni rapide",
"Quick Action": "Azione rapida",
"Copy to Notebook": "Copia nel taccuino",
"Copy text after selection": "Copia testo dopo la selezione",
"Highlight text after selection": "Evidenzia testo dopo la selezione",
"Annotate text after selection": "Annota testo dopo la selezione",
"Search text after selection": "Cerca testo dopo la selezione",
"Look up text in dictionary after selection": "Cerca testo nel dizionario dopo la selezione",
"Look up text in Wikipedia after selection": "Cerca testo in Wikipedia dopo la selezione",
"Translate text after selection": "Traduci testo dopo la selezione",
"Read text aloud after selection": "Leggi ad alta voce il testo dopo la selezione",
"Proofread text after selection": "Correggi il testo dopo la selezione",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} attivi, {{pendingCount}} in attesa",
"{{failedCount}} failed": "{{failedCount}} falliti",
"Waiting...": "In attesa...",
"Failed": "Fallito",
"Completed": "Completato",
"Cancelled": "Annullato",
"Retry": "Riprova",
"Active": "Attivi",
"Transfer Queue": "Coda trasferimenti",
"Upload All": "Carica tutti",
"Download All": "Scarica tutti",
"Resume Transfers": "Riprendi trasferimenti",
"Pause Transfers": "Sospendi trasferimenti",
"Pending": "In attesa",
"No transfers": "Nessun trasferimento",
"Retry All": "Riprova tutti",
"Clear Completed": "Cancella completati",
"Clear Failed": "Cancella falliti",
"Upload queued: {{title}}": "Caricamento in coda: {{title}}",
"Download queued: {{title}}": "Download in coda: {{title}}",
"Book not found in library": "Libro non trovato nella libreria",
"Unknown error": "Errore sconosciuto",
"Please log in to continue": "Accedi per continuare",
"Cloud File Transfers": "Trasferimenti file cloud",
"Show Search Results": "Mostra risultati",
"Search results for '{{term}}'": "Risultati per '{{term}}'",
"Close Search": "Chiudi ricerca",
"Previous Result": "Risultato precedente",
"Next Result": "Risultato successivo",
"Bookmarks": "Segnalibri",
"Annotations": "Annotazioni",
"Show Results": "Mostra risultati",
"Clear search": "Cancella ricerca",
"Clear search history": "Cancella cronologia ricerche",
"Quick action disabled": "Azione rapida disattivata",
"Tap to Toggle Footer": "Tocca per mostrare/nascondere il piè di pagina",
"Exported successfully": "Esportato con successo",
"Book exported successfully.": "Libro esportato con successo.",
"Failed to export the book.": "Impossibile esportare il libro.",
"Export Book": "Esporta libro",
"Whole word:": "Parola intera:",
"Error": "Errore",
"Unable to load the article. Try searching directly on {{link}}.": "Impossibile caricare l'articolo. Prova a cercare direttamente su {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Impossibile caricare la parola. Prova a cercare direttamente su {{link}}.",
"Date Published": "Data di pubblicazione",
"Only for TTS:": "Solo per TTS:",
"Uploaded": "Caricato",
"Downloaded": "Scaricato",
"Deleted": "Eliminato",
"Note:": "Nota:",
"Time:": "Ora:",
"Format Options": "Opzioni formato",
"Export Date": "Data esportazione",
"Chapter Titles": "Titoli capitoli",
"Chapter Separator": "Separatore capitoli",
"Highlights": "Evidenziazioni",
"Note Date": "Data nota",
"Advanced": "Avanzate",
"Hide": "Nascondi",
"Show": "Mostra",
"Use Custom Template": "Usa modello personalizzato",
"Export Template": "Modello esportazione",
"Template Syntax:": "Sintassi modello:",
"Insert value": "Inserisci valore",
"Format date (locale)": "Formatta data (locale)",
"Format date (custom)": "Formatta data (personalizzato)",
"Conditional": "Condizionale",
"Loop": "Ciclo",
"Available Variables:": "Variabili disponibili:",
"Book title": "Titolo libro",
"Book author": "Autore libro",
"Export date": "Data esportazione",
"Array of chapters": "Elenco capitoli",
"Chapter title": "Titolo capitolo",
"Array of annotations": "Elenco annotazioni",
"Highlighted text": "Testo evidenziato",
"Annotation note": "Nota annotazione",
"Update time": "Ora aggiornamento",
"Date Format Tokens:": "Token formato data:",
"Year (4 digits)": "Anno (4 cifre)",
"Month (01-12)": "Mese (01-12)",
"Day (01-31)": "Giorno (01-31)",
"Hour (00-23)": "Ora (00-23)",
"Minute (00-59)": "Minuto (00-59)",
"Second (00-59)": "Secondo (00-59)",
"Show Source": "Mostra sorgente",
"No content to preview": "Nessun contenuto da visualizzare",
"Export": "Esporta",
"Set Timeout": "Imposta timeout",
"Select Voice": "Seleziona voce",
"Toggle Sticky Bottom TTS Bar": "Attiva/disattiva barra TTS fissa",
"Display what I'm reading on Discord": "Mostra cosa sto leggendo su Discord",
"Show on Discord": "Mostra su Discord"
}
@@ -173,9 +173,6 @@
"Token": "トークン",
"Your OTP token": "あなたのOTPトークン",
"Verify token": "トークンを確認",
"Sign in with Google": "Googleでサインイン",
"Sign in with Apple": "Appleでサインイン",
"Sign in with GitHub": "GitHubでサインイン",
"Account": "アカウント",
"Failed to delete user. Please try again later.": "ユーザーの削除に失敗しました。後ほど再試行してください。",
"Community Support": "コミュニティサポート",
@@ -337,8 +334,8 @@
"Reset Settings": "設定をリセット",
"{{count}} pages left in chapter_other": "{{count}} ページ残り",
"Show Remaining Pages": "残りを表示",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "サブスクリプションを管理",
"Coming Soon": "近日公開",
@@ -755,5 +752,133 @@
"Count": "件数",
"Start Page": "開始ページ",
"Search in OPDS Catalog...": "OPDSカタログ内を検索...",
"Please log in to use advanced TTS features.": "高度なTTS機能を使用するにはログインしてください"
"Please log in to use advanced TTS features": "高度なTTS機能を使用するにはログインしてください",
"Word limit of 30 words exceeded.": "30語の上限を超えています。",
"Proofread": "校正",
"Current selection": "現在の選択",
"All occurrences in this book": "この本内のすべての出現箇所",
"All occurrences in your library": "ライブラリ内のすべての出現箇所",
"Selected text:": "選択されたテキスト:",
"Replace with:": "置換後:",
"Enter text...": "テキストを入力…",
"Case sensitive:": "大文字と小文字を区別:",
"Scope:": "適用範囲:",
"Selection": "選択",
"Library": "ライブラリ",
"Yes": "はい",
"No": "いいえ",
"Proofread Replacement Rules": "校正置換ルール",
"Selected Text Rules": "選択テキストのルール",
"No selected text replacement rules": "選択されたテキストの置換ルールはありません",
"Book Specific Rules": "書籍別ルール",
"No book-level replacement rules": "書籍レベルの置換ルールはありません",
"Disable Quick Action": "クイックアクションを無効にする",
"Enable Quick Action on Selection": "選択時にクイックアクションを有効にする",
"None": "なし",
"Annotation Tools": "注釈ツール",
"Enable Quick Actions": "クイックアクションを有効にする",
"Quick Action": "クイックアクション",
"Copy to Notebook": "ノートにコピー",
"Copy text after selection": "選択後のテキストをコピー",
"Highlight text after selection": "選択後のテキストをハイライト",
"Annotate text after selection": "選択後のテキストに注釈を付ける",
"Search text after selection": "選択後のテキストを検索",
"Look up text in dictionary after selection": "選択後のテキストを辞書で調べる",
"Look up text in Wikipedia after selection": "選択後のテキストをWikipediaで調べる",
"Translate text after selection": "選択後のテキストを翻訳する",
"Read text aloud after selection": "選択後のテキストを音読する",
"Proofread text after selection": "選択後のテキストを校正する",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 進行中、{{pendingCount}} 待機中",
"{{failedCount}} failed": "{{failedCount}} 失敗",
"Waiting...": "待機中...",
"Failed": "失敗",
"Completed": "完了",
"Cancelled": "キャンセル",
"Retry": "再試行",
"Active": "進行中",
"Transfer Queue": "転送キュー",
"Upload All": "すべてアップロード",
"Download All": "すべてダウンロード",
"Resume Transfers": "転送を再開",
"Pause Transfers": "転送を一時停止",
"Pending": "待機中",
"No transfers": "転送なし",
"Retry All": "すべて再試行",
"Clear Completed": "完了を消去",
"Clear Failed": "失敗を消去",
"Upload queued: {{title}}": "アップロードをキューに追加: {{title}}",
"Download queued: {{title}}": "ダウンロードをキューに追加: {{title}}",
"Book not found in library": "ライブラリに本が見つかりません",
"Unknown error": "不明なエラー",
"Please log in to continue": "続行するにはログインしてください",
"Cloud File Transfers": "クラウドファイル転送",
"Show Search Results": "検索結果を表示",
"Search results for '{{term}}'": "「{{term}}」を含む結果",
"Close Search": "検索を閉じる",
"Previous Result": "前の結果",
"Next Result": "次の結果",
"Bookmarks": "ブックマーク",
"Annotations": "注釈",
"Show Results": "結果を表示",
"Clear search": "検索をクリア",
"Clear search history": "検索履歴をクリア",
"Quick action disabled": "クイックアクション無効",
"Tap to Toggle Footer": "タップでフッターを切り替え",
"Exported successfully": "エクスポート成功",
"Book exported successfully.": "書籍のエクスポートに成功しました。",
"Failed to export the book.": "書籍のエクスポートに失敗しました。",
"Export Book": "書籍をエクスポート",
"Whole word:": "単語全体:",
"Error": "エラー",
"Unable to load the article. Try searching directly on {{link}}.": "記事を読み込めません。{{link}}で直接検索してみてください。",
"Unable to load the word. Try searching directly on {{link}}.": "単語を読み込めません。{{link}}で直接検索してみてください。",
"Date Published": "出版日",
"Only for TTS:": "TTSのみ:",
"Uploaded": "アップロード済み",
"Downloaded": "ダウンロード済み",
"Deleted": "削除済み",
"Note:": "ノート:",
"Time:": "時刻:",
"Format Options": "形式オプション",
"Export Date": "エクスポート日",
"Chapter Titles": "章タイトル",
"Chapter Separator": "章区切り",
"Highlights": "ハイライト",
"Note Date": "ノート日付",
"Advanced": "詳細設定",
"Hide": "非表示",
"Show": "表示",
"Use Custom Template": "カスタムテンプレート使用",
"Export Template": "エクスポートテンプレート",
"Template Syntax:": "テンプレート構文:",
"Insert value": "値を挿入",
"Format date (locale)": "日付形式(ロケール)",
"Format date (custom)": "日付形式(カスタム)",
"Conditional": "条件分岐",
"Loop": "ループ",
"Available Variables:": "利用可能な変数:",
"Book title": "本のタイトル",
"Book author": "著者",
"Export date": "エクスポート日",
"Array of chapters": "章の配列",
"Chapter title": "章タイトル",
"Array of annotations": "注釈の配列",
"Highlighted text": "ハイライトテキスト",
"Annotation note": "注釈ノート",
"Update time": "更新時刻",
"Date Format Tokens:": "日付形式トークン:",
"Year (4 digits)": "年(4桁)",
"Month (01-12)": "月(01-12)",
"Day (01-31)": "日(01-31)",
"Hour (00-23)": "時(00-23)",
"Minute (00-59)": "分(00-59)",
"Second (00-59)": "秒(00-59)",
"Show Source": "ソース表示",
"No content to preview": "プレビューするコンテンツがありません",
"Export": "エクスポート",
"Set Timeout": "タイムアウト設定",
"Select Voice": "音声選択",
"Toggle Sticky Bottom TTS Bar": "TTSバー固定切替",
"Display what I'm reading on Discord": "読書中の本をDiscordに表示",
"Show on Discord": "Discordに表示"
}
@@ -173,9 +173,6 @@
"Token": "토큰",
"Your OTP token": "당신의 OTP 토큰",
"Verify token": "토큰 확인",
"Sign in with Google": "Google로 로그인",
"Sign in with Apple": "Apple로 로그인",
"Sign in with GitHub": "GitHub로 로그인",
"Account": "계정",
"Failed to delete user. Please try again later.": "사용자 삭제에 실패했습니다. 나중에 다시 시도해 주세요.",
"Community Support": "커뮤니티 지원",
@@ -337,8 +334,8 @@
"Reset Settings": "설정 재설정",
"{{count}} pages left in chapter_other": "남은 페이지 {{count}}장",
"Show Remaining Pages": "남은 페이지 보기",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "구독 관리",
"Coming Soon": "곧 출시 예정",
@@ -755,5 +752,133 @@
"Count": "개수",
"Start Page": "시작 페이지",
"Search in OPDS Catalog...": "OPDS 카탈로그에서 검색...",
"Please log in to use advanced TTS features.": "고급 TTS 기능을 사용하려면 로그인하세요."
"Please log in to use advanced TTS features": "고급 TTS 기능을 사용하려면 로그인하세요",
"Word limit of 30 words exceeded.": "30단어 제한을 초과했습니다.",
"Proofread": "교정",
"Current selection": "현재 선택",
"All occurrences in this book": "이 책의 모든 위치",
"All occurrences in your library": "라이브러리의 모든 위치",
"Selected text:": "선택한 텍스트:",
"Replace with:": "다음으로 바꾸기:",
"Enter text...": "텍스트 입력…",
"Case sensitive:": "대소문자 구분:",
"Scope:": "적용 범위:",
"Selection": "선택",
"Library": "라이브러리",
"Yes": "예",
"No": "아니요",
"Proofread Replacement Rules": "교정 대체 규칙",
"Selected Text Rules": "선택한 텍스트 규칙",
"No selected text replacement rules": "선택한 텍스트에 대한 대체 규칙이 없습니다",
"Book Specific Rules": "책별 규칙",
"No book-level replacement rules": "책 수준의 대체 규칙이 없습니다",
"Disable Quick Action": "빠른 작업 비활성화",
"Enable Quick Action on Selection": "선택 시 빠른 작업 활성화",
"None": "없음",
"Annotation Tools": "주석 도구",
"Enable Quick Actions": "빠른 작업 활성화",
"Quick Action": "빠른 작업",
"Copy to Notebook": "노트에 복사",
"Copy text after selection": "선택 후 텍스트 복사",
"Highlight text after selection": "선택 후 텍스트 강조",
"Annotate text after selection": "선택 후 텍스트 주석 달기",
"Search text after selection": "선택 후 텍스트 검색",
"Look up text in dictionary after selection": "선택 후 텍스트를 사전에서 찾기",
"Look up text in Wikipedia after selection": "선택 후 텍스트를 위키피디아에서 찾기",
"Translate text after selection": "선택 후 텍스트 번역",
"Read text aloud after selection": "선택 후 텍스트 음성 읽기",
"Proofread text after selection": "선택 후 텍스트 교정",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 진행 중, {{pendingCount}} 대기 중",
"{{failedCount}} failed": "{{failedCount}} 실패",
"Waiting...": "대기 중...",
"Failed": "실패",
"Completed": "완료됨",
"Cancelled": "취소됨",
"Retry": "재시도",
"Active": "진행 중",
"Transfer Queue": "전송 대기열",
"Upload All": "모두 업로드",
"Download All": "모두 다운로드",
"Resume Transfers": "전송 재개",
"Pause Transfers": "전송 일시정지",
"Pending": "대기 중",
"No transfers": "전송 없음",
"Retry All": "모두 재시도",
"Clear Completed": "완료된 항목 지우기",
"Clear Failed": "실패한 항목 지우기",
"Upload queued: {{title}}": "업로드 대기열에 추가됨: {{title}}",
"Download queued: {{title}}": "다운로드 대기열에 추가됨: {{title}}",
"Book not found in library": "라이브러리에서 책을 찾을 수 없음",
"Unknown error": "알 수 없는 오류",
"Please log in to continue": "계속하려면 로그인하세요",
"Cloud File Transfers": "클라우드 파일 전송",
"Show Search Results": "검색 결과 보기",
"Search results for '{{term}}'": "'{{term}}' 검색 결과",
"Close Search": "검색 닫기",
"Previous Result": "이전 결과",
"Next Result": "다음 결과",
"Bookmarks": "북마크",
"Annotations": "주석",
"Show Results": "결과 표시",
"Clear search": "검색 지우기",
"Clear search history": "검색 기록 지우기",
"Quick action disabled": "빠른 작업 비활성화됨",
"Tap to Toggle Footer": "탭하여 바닥글 전환",
"Exported successfully": "내보내기 성공",
"Book exported successfully.": "책을 성공적으로 내보냈습니다.",
"Failed to export the book.": "책 내보내기에 실패했습니다.",
"Export Book": "책 내보내기",
"Whole word:": "전체 단어:",
"Error": "오류",
"Unable to load the article. Try searching directly on {{link}}.": "문서를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Unable to load the word. Try searching directly on {{link}}.": "단어를 로드할 수 없습니다. {{link}}에서 직접 검색해 보세요.",
"Date Published": "출판일",
"Only for TTS:": "TTS 전용:",
"Uploaded": "업로드됨",
"Downloaded": "다운로드됨",
"Deleted": "삭제됨",
"Note:": "노트:",
"Time:": "시간:",
"Format Options": "형식 옵션",
"Export Date": "내보내기 날짜",
"Chapter Titles": "챕터 제목",
"Chapter Separator": "챕터 구분 기호",
"Highlights": "하이라이트",
"Note Date": "노트 날짜",
"Advanced": "고급",
"Hide": "숨기기",
"Show": "표시",
"Use Custom Template": "사용자 지정 템플릿 사용",
"Export Template": "내보내기 템플릿",
"Template Syntax:": "템플릿 구문:",
"Insert value": "값 삽입",
"Format date (locale)": "날짜 형식 (로케일)",
"Format date (custom)": "날짜 형식 (사용자 지정)",
"Conditional": "조건부",
"Loop": "반복",
"Available Variables:": "사용 가능한 변수:",
"Book title": "책 제목",
"Book author": "책 저자",
"Export date": "내보내기 날짜",
"Array of chapters": "챕터 배열",
"Chapter title": "챕터 제목",
"Array of annotations": "주석 배열",
"Highlighted text": "하이라이트된 텍스트",
"Annotation note": "주석 노트",
"Update time": "업데이트 시간",
"Date Format Tokens:": "날짜 형식 토큰:",
"Year (4 digits)": "연도 (4자리)",
"Month (01-12)": "월 (01-12)",
"Day (01-31)": "일 (01-31)",
"Hour (00-23)": "시 (00-23)",
"Minute (00-59)": "분 (00-59)",
"Second (00-59)": "초 (00-59)",
"Show Source": "소스 표시",
"No content to preview": "미리 볼 내용 없음",
"Export": "내보내기",
"Set Timeout": "시간 제한 설정",
"Select Voice": "음성 선택",
"Toggle Sticky Bottom TTS Bar": "고정 TTS 바 전환",
"Display what I'm reading on Discord": "Discord에 읽는 책 표시",
"Show on Discord": "Discord에 표시"
}
@@ -25,9 +25,6 @@
"Your OTP token": "Token OTP anda",
"Verify token": "Sahkan token",
"Go Back": "Kembali",
"Sign in with Google": "Log masuk dengan Google",
"Sign in with Apple": "Log masuk dengan Apple",
"Sign in with GitHub": "Log masuk dengan GitHub",
"New Password": "Kata Laluan Baharu",
"Your new password": "Kata laluan baharu anda",
"Update password": "Kemas kini kata laluan",
@@ -649,8 +646,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
@@ -755,5 +752,133 @@
"Count": "Jumlah",
"Start Page": "Halaman Awal",
"Search in OPDS Catalog...": "Cari dalam Katalog OPDS...",
"Please log in to use advanced TTS features.": "Sila log masuk untuk menggunakan ciri TTS lanjutan."
"Please log in to use advanced TTS features": "Sila log masuk untuk menggunakan ciri TTS lanjutan",
"Word limit of 30 words exceeded.": "Had 30 perkataan telah dilebihi.",
"Proofread": "Semakan",
"Current selection": "Pilihan semasa",
"All occurrences in this book": "Semua kejadian dalam buku ini",
"All occurrences in your library": "Semua kejadian dalam perpustakaan anda",
"Selected text:": "Teks dipilih:",
"Replace with:": "Ganti dengan:",
"Enter text...": "Masukkan teks…",
"Case sensitive:": "Peka huruf besar dan kecil:",
"Scope:": "Skop:",
"Selection": "Pilihan",
"Library": "Perpustakaan",
"Yes": "Ya",
"No": "Tidak",
"Proofread Replacement Rules": "Peraturan penggantian semakan",
"Selected Text Rules": "Peraturan teks dipilih",
"No selected text replacement rules": "Tiada peraturan penggantian untuk teks dipilih",
"Book Specific Rules": "Peraturan khusus buku",
"No book-level replacement rules": "Tiada peraturan penggantian peringkat buku",
"Disable Quick Action": "Nyahaktifkan Tindakan Pantas",
"Enable Quick Action on Selection": "Aktifkan Tindakan Pantas pada Pilihan",
"None": "Tiada",
"Annotation Tools": "Alat Anotasi",
"Enable Quick Actions": "Aktifkan Tindakan Pantas",
"Quick Action": "Tindakan Pantas",
"Copy to Notebook": "Salin ke Buku Nota",
"Copy text after selection": "Salin teks selepas pilihan",
"Highlight text after selection": "Sorot teks selepas pilihan",
"Annotate text after selection": "Anotasi teks selepas pilihan",
"Search text after selection": "Cari teks selepas pilihan",
"Look up text in dictionary after selection": "Cari teks dalam kamus selepas pilihan",
"Look up text in Wikipedia after selection": "Cari teks dalam Wikipedia selepas pilihan",
"Translate text after selection": "Terjemah teks selepas pilihan",
"Read text aloud after selection": "Baca teks dengan kuat selepas pilihan",
"Proofread text after selection": "Semak teks selepas pilihan",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} menunggu",
"{{failedCount}} failed": "{{failedCount}} gagal",
"Waiting...": "Menunggu...",
"Failed": "Gagal",
"Completed": "Selesai",
"Cancelled": "Dibatalkan",
"Retry": "Cuba lagi",
"Active": "Aktif",
"Transfer Queue": "Baris Gilir Pemindahan",
"Upload All": "Muat Naik Semua",
"Download All": "Muat Turun Semua",
"Resume Transfers": "Sambung Pemindahan",
"Pause Transfers": "Jeda Pemindahan",
"Pending": "Menunggu",
"No transfers": "Tiada pemindahan",
"Retry All": "Cuba Semua Lagi",
"Clear Completed": "Kosongkan Selesai",
"Clear Failed": "Kosongkan Gagal",
"Upload queued: {{title}}": "Muat naik dalam baris gilir: {{title}}",
"Download queued: {{title}}": "Muat turun dalam baris gilir: {{title}}",
"Book not found in library": "Buku tidak dijumpai dalam perpustakaan",
"Unknown error": "Ralat tidak diketahui",
"Please log in to continue": "Sila log masuk untuk meneruskan",
"Cloud File Transfers": "Pemindahan Fail Awan",
"Show Search Results": "Tunjukkan hasil carian",
"Search results for '{{term}}'": "Hasil untuk '{{term}}'",
"Close Search": "Tutup carian",
"Previous Result": "Hasil sebelumnya",
"Next Result": "Hasil seterusnya",
"Bookmarks": "Penanda buku",
"Annotations": "Anotasi",
"Show Results": "Tunjukkan Hasil",
"Clear search": "Kosongkan carian",
"Clear search history": "Kosongkan sejarah carian",
"Quick action disabled": "Tindakan pantas dilumpuhkan",
"Tap to Toggle Footer": "Ketik untuk togol pengaki",
"Exported successfully": "Berjaya dieksport",
"Book exported successfully.": "Buku berjaya dieksport.",
"Failed to export the book.": "Gagal mengeksport buku.",
"Export Book": "Eksport Buku",
"Whole word:": "Perkataan penuh:",
"Error": "Ralat",
"Unable to load the article. Try searching directly on {{link}}.": "Tidak dapat memuatkan artikel. Cuba cari terus di {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Tidak dapat memuatkan perkataan. Cuba cari terus di {{link}}.",
"Date Published": "Tarikh diterbitkan",
"Only for TTS:": "Hanya untuk TTS:",
"Uploaded": "Dimuat naik",
"Downloaded": "Dimuat turun",
"Deleted": "Dipadam",
"Note:": "Nota:",
"Time:": "Masa:",
"Format Options": "Pilihan Format",
"Export Date": "Tarikh Eksport",
"Chapter Titles": "Tajuk Bab",
"Chapter Separator": "Pemisah Bab",
"Highlights": "Serlahan",
"Note Date": "Tarikh Nota",
"Advanced": "Lanjutan",
"Hide": "Sembunyikan",
"Show": "Tunjukkan",
"Use Custom Template": "Gunakan Templat Tersuai",
"Export Template": "Templat Eksport",
"Template Syntax:": "Sintaks Templat:",
"Insert value": "Masukkan nilai",
"Format date (locale)": "Format tarikh (tempatan)",
"Format date (custom)": "Format tarikh (tersuai)",
"Conditional": "Bersyarat",
"Loop": "Gelung",
"Available Variables:": "Pembolehubah Tersedia:",
"Book title": "Tajuk buku",
"Book author": "Pengarang buku",
"Export date": "Tarikh eksport",
"Array of chapters": "Senarai bab",
"Chapter title": "Tajuk bab",
"Array of annotations": "Senarai anotasi",
"Highlighted text": "Teks yang diserlahkan",
"Annotation note": "Nota anotasi",
"Update time": "Masa kemas kini",
"Date Format Tokens:": "Token Format Tarikh:",
"Year (4 digits)": "Tahun (4 digit)",
"Month (01-12)": "Bulan (01-12)",
"Day (01-31)": "Hari (01-31)",
"Hour (00-23)": "Jam (00-23)",
"Minute (00-59)": "Minit (00-59)",
"Second (00-59)": "Saat (00-59)",
"Show Source": "Tunjukkan Sumber",
"No content to preview": "Tiada kandungan untuk pratonton",
"Export": "Eksport",
"Set Timeout": "Tetapkan masa tamat",
"Select Voice": "Pilih suara",
"Toggle Sticky Bottom TTS Bar": "Togol bar TTS melekit",
"Display what I'm reading on Discord": "Papar buku yang sedang dibaca di Discord",
"Show on Discord": "Papar di Discord"
}
@@ -23,9 +23,6 @@
"Token": "Token",
"Your OTP token": "Uw OTP-token",
"Verify token": "Token verifiëren",
"Sign in with Google": "Inloggen met Google",
"Sign in with Apple": "Inloggen met Apple",
"Sign in with GitHub": "Inloggen met GitHub",
"New Password": "Nieuw wachtwoord",
"Your new password": "Uw nieuwe wachtwoord",
"Update password": "Wachtwoord bijwerken",
@@ -341,8 +338,8 @@
"{{count}} pages left in chapter_one": "Nog {{count}} pagina in hoofdstuk",
"{{count}} pages left in chapter_other": "Nog {{count}} paginas in hoofdstuk",
"Show Remaining Pages": "Toon resterende paginas",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Abonnement beheren",
"Coming Soon": "Binnenkort beschikbaar",
@@ -765,5 +762,133 @@
"Count": "Aantal",
"Start Page": "Startpagina",
"Search in OPDS Catalog...": "Zoeken in OPDS-catalogus...",
"Please log in to use advanced TTS features.": "Log in om geavanceerde TTS-functies te gebruiken."
"Please log in to use advanced TTS features": "Log in om geavanceerde TTS-functies te gebruiken",
"Word limit of 30 words exceeded.": "De limiet van 30 woorden is overschreden.",
"Proofread": "Correctie",
"Current selection": "Huidige selectie",
"All occurrences in this book": "Alle voorkomens in dit boek",
"All occurrences in your library": "Alle voorkomens in je bibliotheek",
"Selected text:": "Geselecteerde tekst:",
"Replace with:": "Vervangen door:",
"Enter text...": "Tekst invoeren…",
"Case sensitive:": "Hoofdlettergevoelig:",
"Scope:": "Bereik:",
"Selection": "Selectie",
"Library": "Bibliotheek",
"Yes": "Ja",
"No": "Nee",
"Proofread Replacement Rules": "Correctie-vervangingsregels",
"Selected Text Rules": "Regels voor geselecteerde tekst",
"No selected text replacement rules": "Geen vervangingsregels voor geselecteerde tekst",
"Book Specific Rules": "Boekspecifieke regels",
"No book-level replacement rules": "Geen vervangingsregels op boekniveau",
"Disable Quick Action": "Snelle actie uitschakelen",
"Enable Quick Action on Selection": "Snelle actie bij selectie inschakelen",
"None": "Geen",
"Annotation Tools": "Annotatiehulpmiddelen",
"Enable Quick Actions": "Snelle acties inschakelen",
"Quick Action": "Snelle actie",
"Copy to Notebook": "Kopiëren naar notitieboek",
"Copy text after selection": "Kopieer tekst na selectie",
"Highlight text after selection": "Markeer tekst na selectie",
"Annotate text after selection": "Annoteren tekst na selectie",
"Search text after selection": "Zoek tekst na selectie",
"Look up text in dictionary after selection": "Zoek tekst op in woordenboek na selectie",
"Look up text in Wikipedia after selection": "Zoek tekst op in Wikipedia na selectie",
"Translate text after selection": "Vertaal tekst na selectie",
"Read text aloud after selection": "Lees tekst hardop na selectie",
"Proofread text after selection": "Corrigeer tekst na selectie",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} actief, {{pendingCount}} wachtend",
"{{failedCount}} failed": "{{failedCount}} mislukt",
"Waiting...": "Wachten...",
"Failed": "Mislukt",
"Completed": "Voltooid",
"Cancelled": "Geannuleerd",
"Retry": "Opnieuw proberen",
"Active": "Actief",
"Transfer Queue": "Overdrachtwachtrij",
"Upload All": "Alles uploaden",
"Download All": "Alles downloaden",
"Resume Transfers": "Overdrachten hervatten",
"Pause Transfers": "Overdrachten pauzeren",
"Pending": "Wachtend",
"No transfers": "Geen overdrachten",
"Retry All": "Alles opnieuw proberen",
"Clear Completed": "Voltooide wissen",
"Clear Failed": "Mislukte wissen",
"Upload queued: {{title}}": "Upload in wachtrij: {{title}}",
"Download queued: {{title}}": "Download in wachtrij: {{title}}",
"Book not found in library": "Boek niet gevonden in bibliotheek",
"Unknown error": "Onbekende fout",
"Please log in to continue": "Log in om door te gaan",
"Cloud File Transfers": "Cloud Bestandoverdrachten",
"Show Search Results": "Zoekresultaten tonen",
"Search results for '{{term}}'": "Resultaten voor '{{term}}'",
"Close Search": "Zoekopdracht sluiten",
"Previous Result": "Vorig resultaat",
"Next Result": "Volgend resultaat",
"Bookmarks": "Bladwijzers",
"Annotations": "Aantekeningen",
"Show Results": "Resultaten tonen",
"Clear search": "Zoekopdracht wissen",
"Clear search history": "Zoekgeschiedenis wissen",
"Quick action disabled": "Snelle actie uitgeschakeld",
"Tap to Toggle Footer": "Tik om voettekst te wisselen",
"Exported successfully": "Succesvol geëxporteerd",
"Book exported successfully.": "Boek succesvol geëxporteerd.",
"Failed to export the book.": "Exporteren van het boek mislukt.",
"Export Book": "Boek exporteren",
"Whole word:": "Heel woord:",
"Error": "Fout",
"Unable to load the article. Try searching directly on {{link}}.": "Kan het artikel niet laden. Probeer direct te zoeken op {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan het woord niet laden. Probeer direct te zoeken op {{link}}.",
"Date Published": "Publicatiedatum",
"Only for TTS:": "Alleen voor TTS:",
"Uploaded": "Geüpload",
"Downloaded": "Gedownload",
"Deleted": "Verwijderd",
"Note:": "Notitie:",
"Time:": "Tijd:",
"Format Options": "Formaatopties",
"Export Date": "Exportdatum",
"Chapter Titles": "Hoofdstuktitels",
"Chapter Separator": "Hoofdstukscheiding",
"Highlights": "Markeringen",
"Note Date": "Notitiedatum",
"Advanced": "Geavanceerd",
"Hide": "Verbergen",
"Show": "Tonen",
"Use Custom Template": "Aangepast sjabloon gebruiken",
"Export Template": "Exportsjabloon",
"Template Syntax:": "Sjabloonsyntaxis:",
"Insert value": "Waarde invoegen",
"Format date (locale)": "Datum opmaken (lokaal)",
"Format date (custom)": "Datum opmaken (aangepast)",
"Conditional": "Voorwaardelijk",
"Loop": "Lus",
"Available Variables:": "Beschikbare variabelen:",
"Book title": "Boektitel",
"Book author": "Boekauteur",
"Export date": "Exportdatum",
"Array of chapters": "Reeks hoofdstukken",
"Chapter title": "Hoofdstuktitel",
"Array of annotations": "Reeks annotaties",
"Highlighted text": "Gemarkeerde tekst",
"Annotation note": "Annotatienotitie",
"Update time": "Updatetijd",
"Date Format Tokens:": "Datumformaattokens:",
"Year (4 digits)": "Jaar (4 cijfers)",
"Month (01-12)": "Maand (01-12)",
"Day (01-31)": "Dag (01-31)",
"Hour (00-23)": "Uur (00-23)",
"Minute (00-59)": "Minuut (00-59)",
"Second (00-59)": "Seconde (00-59)",
"Show Source": "Bron tonen",
"No content to preview": "Geen inhoud om te bekijken",
"Export": "Exporteren",
"Set Timeout": "Time-out instellen",
"Select Voice": "Stem selecteren",
"Toggle Sticky Bottom TTS Bar": "Vaste TTS-balk schakelen",
"Display what I'm reading on Discord": "Toon wat ik lees op Discord",
"Show on Discord": "Toon op Discord"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "Twój OTP token",
"Verify token": "Weryfikacja tokena",
"Sign in with Google": "Zaloguj się za pomocą Google",
"Sign in with Apple": "Zaloguj się za pomocą Apple",
"Sign in with GitHub": "Zaloguj się za pomocą GitHub",
"Account": "Konto",
"Failed to delete user. Please try again later.": "Nie udało się usunąć użytkownika. Spróbuj ponownie później.",
"Community Support": "Wsparcie społeczności",
@@ -349,8 +346,8 @@
"{{count}} pages left in chapter_many": "Pozostało {{count}} stron w rozdziale",
"{{count}} pages left in chapter_other": "Pozostało {{count}} stron w rozdziale",
"Show Remaining Pages": "Pokaż pozostałe strony",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Zarządzaj subskrypcją",
"Coming Soon": "Wkrótce dostępne",
@@ -785,5 +782,133 @@
"Count": "Liczba",
"Start Page": "Strona startowa",
"Search in OPDS Catalog...": "Szukaj w katalogu OPDS...",
"Please log in to use advanced TTS features.": "Zaloguj się, aby korzystać z zaawansowanych funkcji TTS."
"Please log in to use advanced TTS features": "Zaloguj się, aby korzystać z zaawansowanych funkcji TTS",
"Word limit of 30 words exceeded.": "Przekroczono limit 30 słów.",
"Proofread": "Korekta",
"Current selection": "Bieżące zaznaczenie",
"All occurrences in this book": "Wszystkie wystąpienia w tej książce",
"All occurrences in your library": "Wszystkie wystąpienia w Twojej bibliotece",
"Selected text:": "Zaznaczony tekst:",
"Replace with:": "Zastąp przez:",
"Enter text...": "Wpisz tekst…",
"Case sensitive:": "Uwzględniaj wielkość liter:",
"Scope:": "Zakres:",
"Selection": "Zaznaczenie",
"Library": "Biblioteka",
"Yes": "Tak",
"No": "Nie",
"Proofread Replacement Rules": "Reguły zastępowania korekty",
"Selected Text Rules": "Reguły dla zaznaczonego tekstu",
"No selected text replacement rules": "Brak reguł zastępowania dla zaznaczonego tekstu",
"Book Specific Rules": "Reguły specyficzne dla książki",
"No book-level replacement rules": "Brak reguł zastępowania na poziomie książki",
"Disable Quick Action": "Wyłącz szybką akcję",
"Enable Quick Action on Selection": "Włącz szybką akcję przy zaznaczeniu",
"None": "Brak",
"Annotation Tools": "Narzędzia do adnotacji",
"Enable Quick Actions": "Włącz szybkie akcje",
"Quick Action": "Szybka akcja",
"Copy to Notebook": "Kopiuj do notatnika",
"Copy text after selection": "Kopiuj zaznaczony tekst",
"Highlight text after selection": "Podświetl zaznaczony tekst",
"Annotate text after selection": "Dodaj adnotację do zaznaczonego tekstu",
"Search text after selection": "Wyszukaj zaznaczony tekst",
"Look up text in dictionary after selection": "Sprawdź zaznaczony tekst w słowniku",
"Look up text in Wikipedia after selection": "Sprawdź zaznaczony tekst w Wikipedii",
"Translate text after selection": "Przetłumacz zaznaczony tekst",
"Read text aloud after selection": "Odczytaj zaznaczony tekst na głos",
"Proofread text after selection": "Sprawdź zaznaczony tekst",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktywnych, {{pendingCount}} oczekujących",
"{{failedCount}} failed": "{{failedCount}} nieudanych",
"Waiting...": "Oczekiwanie...",
"Failed": "Nieudane",
"Completed": "Ukończone",
"Cancelled": "Anulowane",
"Retry": "Ponów",
"Active": "Aktywne",
"Transfer Queue": "Kolejka transferów",
"Upload All": "Wyślij wszystko",
"Download All": "Pobierz wszystko",
"Resume Transfers": "Wznów transfery",
"Pause Transfers": "Wstrzymaj transfery",
"Pending": "Oczekujące",
"No transfers": "Brak transferów",
"Retry All": "Ponów wszystko",
"Clear Completed": "Wyczyść ukończone",
"Clear Failed": "Wyczyść nieudane",
"Upload queued: {{title}}": "Wysyłanie w kolejce: {{title}}",
"Download queued: {{title}}": "Pobieranie w kolejce: {{title}}",
"Book not found in library": "Książka nie została znaleziona w bibliotece",
"Unknown error": "Nieznany błąd",
"Please log in to continue": "Zaloguj się, aby kontynuować",
"Cloud File Transfers": "Transfery plików w chmurze",
"Show Search Results": "Pokaż wyniki wyszukiwania",
"Search results for '{{term}}'": "Wyniki dla '{{term}}'",
"Close Search": "Zamknij wyszukiwanie",
"Previous Result": "Poprzedni wynik",
"Next Result": "Następny wynik",
"Bookmarks": "Zakładki",
"Annotations": "Adnotacje",
"Show Results": "Pokaż wyniki",
"Clear search": "Wyczyść wyszukiwanie",
"Clear search history": "Wyczyść historię wyszukiwania",
"Quick action disabled": "Szybka akcja wyłączona",
"Tap to Toggle Footer": "Dotknij, aby przełączyć stopkę",
"Exported successfully": "Wyeksportowano pomyślnie",
"Book exported successfully.": "Książka została wyeksportowana pomyślnie.",
"Failed to export the book.": "Nie udało się wyeksportować książki.",
"Export Book": "Eksportuj książkę",
"Whole word:": "Całe słowo:",
"Error": "Błąd",
"Unable to load the article. Try searching directly on {{link}}.": "Nie można załadować artykułu. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Nie można załadować słowa. Spróbuj wyszukać bezpośrednio na {{link}}.",
"Date Published": "Data wydania",
"Only for TTS:": "Tylko dla TTS:",
"Uploaded": "Przesłano",
"Downloaded": "Pobrano",
"Deleted": "Usunięto",
"Note:": "Notatka:",
"Time:": "Czas:",
"Format Options": "Opcje formatu",
"Export Date": "Data eksportu",
"Chapter Titles": "Tytuły rozdziałów",
"Chapter Separator": "Separator rozdziałów",
"Highlights": "Zaznaczenia",
"Note Date": "Data notatki",
"Advanced": "Zaawansowane",
"Hide": "Ukryj",
"Show": "Pokaż",
"Use Custom Template": "Użyj własnego szablonu",
"Export Template": "Szablon eksportu",
"Template Syntax:": "Składnia szablonu:",
"Insert value": "Wstaw wartość",
"Format date (locale)": "Formatuj datę (lokalizacja)",
"Format date (custom)": "Formatuj datę (własny)",
"Conditional": "Warunkowy",
"Loop": "Pętla",
"Available Variables:": "Dostępne zmienne:",
"Book title": "Tytuł książki",
"Book author": "Autor książki",
"Export date": "Data eksportu",
"Array of chapters": "Tablica rozdziałów",
"Chapter title": "Tytuł rozdziału",
"Array of annotations": "Tablica adnotacji",
"Highlighted text": "Zaznaczony tekst",
"Annotation note": "Notatka adnotacji",
"Update time": "Czas aktualizacji",
"Date Format Tokens:": "Tokeny formatu daty:",
"Year (4 digits)": "Rok (4 cyfry)",
"Month (01-12)": "Miesiąc (01-12)",
"Day (01-31)": "Dzień (01-31)",
"Hour (00-23)": "Godzina (00-23)",
"Minute (00-59)": "Minuta (00-59)",
"Second (00-59)": "Sekunda (00-59)",
"Show Source": "Pokaż źródło",
"No content to preview": "Brak treści do podglądu",
"Export": "Eksportuj",
"Set Timeout": "Ustaw limit czasu",
"Select Voice": "Wybierz głos",
"Toggle Sticky Bottom TTS Bar": "Przełącz przypiętą dolną belkę TTS",
"Display what I'm reading on Discord": "Wyświetl to, co czytam na Discord",
"Show on Discord": "Pokaż na Discord"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "Seu token OTP",
"Verify token": "Verificar token",
"Sign in with Google": "Entrar com o Google",
"Sign in with Apple": "Entrar com o Apple",
"Sign in with GitHub": "Entrar com o GitHub",
"Account": "Conta",
"Failed to delete user. Please try again later.": "Falha ao excluir usuário. Por favor, tente novamente mais tarde.",
"Community Support": "Suporte da comunidade",
@@ -345,8 +342,8 @@
"{{count}} pages left in chapter_many": "Faltam {{count}} páginas neste capítulo",
"{{count}} pages left in chapter_other": "Faltam {{count}} páginas neste capítulo",
"Show Remaining Pages": "Mostrar páginas restantes",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Gerenciar assinatura",
"Coming Soon": "Em breve",
@@ -775,5 +772,133 @@
"Count": "Contagem",
"Start Page": "Página Inicial",
"Search in OPDS Catalog...": "Pesquisar no Catálogo OPDS...",
"Please log in to use advanced TTS features.": "Por favor, faça login para usar recursos avançados de TTS."
"Please log in to use advanced TTS features": "Por favor, faça login para usar recursos avançados de TTS",
"Word limit of 30 words exceeded.": "O limite de 30 palavras foi excedido.",
"Proofread": "Revisão",
"Current selection": "Seleção atual",
"All occurrences in this book": "Todas as ocorrências neste livro",
"All occurrences in your library": "Todas as ocorrências na sua biblioteca",
"Selected text:": "Texto selecionado:",
"Replace with:": "Substituir por:",
"Enter text...": "Introduzir texto…",
"Case sensitive:": "Diferenciar maiúsculas e minúsculas:",
"Scope:": "Âmbito:",
"Selection": "Seleção",
"Library": "Biblioteca",
"Yes": "Sim",
"No": "Não",
"Proofread Replacement Rules": "Regras de substituição da revisão",
"Selected Text Rules": "Regras do texto selecionado",
"No selected text replacement rules": "Não existem regras de substituição para o texto selecionado",
"Book Specific Rules": "Regras específicas do livro",
"No book-level replacement rules": "Não existem regras de substituição ao nível do livro",
"Disable Quick Action": "Desativar Ação Rápida",
"Enable Quick Action on Selection": "Ativar Ação Rápida na Seleção",
"None": "Nenhum",
"Annotation Tools": "Ferramentas de Anotação",
"Enable Quick Actions": "Ativar Ações Rápidas",
"Quick Action": "Ação Rápida",
"Copy to Notebook": "Copiar para o Caderno",
"Copy text after selection": "Copiar texto após a seleção",
"Highlight text after selection": "Destacar texto após a seleção",
"Annotate text after selection": "Anotar texto após a seleção",
"Search text after selection": "Pesquisar texto após a seleção",
"Look up text in dictionary after selection": "Procurar texto no dicionário após a seleção",
"Look up text in Wikipedia after selection": "Procurar texto na Wikipedia após a seleção",
"Translate text after selection": "Traduzir texto após a seleção",
"Read text aloud after selection": "Ler texto em voz alta após a seleção",
"Proofread text after selection": "Revisar texto após a seleção",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} ativos, {{pendingCount}} pendentes",
"{{failedCount}} failed": "{{failedCount}} falharam",
"Waiting...": "Aguardando...",
"Failed": "Falhou",
"Completed": "Concluído",
"Cancelled": "Cancelado",
"Retry": "Tentar novamente",
"Active": "Ativos",
"Transfer Queue": "Fila de Transferências",
"Upload All": "Enviar Todos",
"Download All": "Baixar Todos",
"Resume Transfers": "Retomar Transferências",
"Pause Transfers": "Pausar Transferências",
"Pending": "Pendentes",
"No transfers": "Sem transferências",
"Retry All": "Tentar Todos Novamente",
"Clear Completed": "Limpar Concluídos",
"Clear Failed": "Limpar Falhos",
"Upload queued: {{title}}": "Upload na fila: {{title}}",
"Download queued: {{title}}": "Download na fila: {{title}}",
"Book not found in library": "Livro não encontrado na biblioteca",
"Unknown error": "Erro desconhecido",
"Please log in to continue": "Faça login para continuar",
"Cloud File Transfers": "Transferências de Arquivos",
"Show Search Results": "Mostrar resultados",
"Search results for '{{term}}'": "Resultados para '{{term}}'",
"Close Search": "Fechar pesquisa",
"Previous Result": "Resultado anterior",
"Next Result": "Próximo resultado",
"Bookmarks": "Marcadores",
"Annotations": "Anotações",
"Show Results": "Mostrar resultados",
"Clear search": "Limpar pesquisa",
"Clear search history": "Limpar histórico de pesquisa",
"Quick action disabled": "Ação rápida desativada",
"Tap to Toggle Footer": "Toque para alternar rodapé",
"Exported successfully": "Exportado com sucesso",
"Book exported successfully.": "Livro exportado com sucesso.",
"Failed to export the book.": "Falha ao exportar o livro.",
"Export Book": "Exportar livro",
"Whole word:": "Palavra inteira:",
"Error": "Erro",
"Unable to load the article. Try searching directly on {{link}}.": "Não foi possível carregar o artigo. Tente pesquisar diretamente em {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Não foi possível carregar a palavra. Tente pesquisar diretamente em {{link}}.",
"Date Published": "Data de publicação",
"Only for TTS:": "Apenas para TTS:",
"Uploaded": "Enviado",
"Downloaded": "Baixado",
"Deleted": "Excluído",
"Note:": "Nota:",
"Time:": "Hora:",
"Format Options": "Opções de formato",
"Export Date": "Data de exportação",
"Chapter Titles": "Títulos dos capítulos",
"Chapter Separator": "Separador de capítulos",
"Highlights": "Destaques",
"Note Date": "Data da nota",
"Advanced": "Avançado",
"Hide": "Ocultar",
"Show": "Mostrar",
"Use Custom Template": "Usar modelo personalizado",
"Export Template": "Modelo de exportação",
"Template Syntax:": "Sintaxe do modelo:",
"Insert value": "Inserir valor",
"Format date (locale)": "Formatar data (local)",
"Format date (custom)": "Formatar data (personalizado)",
"Conditional": "Condicional",
"Loop": "Loop",
"Available Variables:": "Variáveis disponíveis:",
"Book title": "Título do livro",
"Book author": "Autor do livro",
"Export date": "Data de exportação",
"Array of chapters": "Lista de capítulos",
"Chapter title": "Título do capítulo",
"Array of annotations": "Lista de anotações",
"Highlighted text": "Texto destacado",
"Annotation note": "Nota de anotação",
"Update time": "Hora de atualização",
"Date Format Tokens:": "Tokens de formato de data:",
"Year (4 digits)": "Ano (4 dígitos)",
"Month (01-12)": "Mês (01-12)",
"Day (01-31)": "Dia (01-31)",
"Hour (00-23)": "Hora (00-23)",
"Minute (00-59)": "Minuto (00-59)",
"Second (00-59)": "Segundo (00-59)",
"Show Source": "Mostrar fonte",
"No content to preview": "Sem conteúdo para visualizar",
"Export": "Exportar",
"Set Timeout": "Definir tempo limite",
"Select Voice": "Selecionar voz",
"Toggle Sticky Bottom TTS Bar": "Alternar barra TTS fixada",
"Display what I'm reading on Discord": "Mostrar o que estou lendo no Discord",
"Show on Discord": "Mostrar no Discord"
}
@@ -173,9 +173,6 @@
"Token": "Токен",
"Your OTP token": "Ваш OTP токен",
"Verify token": "Проверить токен",
"Sign in with Google": "Войти через Google",
"Sign in with Apple": "Войти через Apple",
"Sign in with GitHub": "Войти через GitHub",
"Account": "Аккаунт",
"Failed to delete user. Please try again later.": "Не удалось удалить пользователя. Пожалуйста, повторите попытку позже.",
"Community Support": "Поддержка сообщества",
@@ -349,8 +346,8 @@
"{{count}} pages left in chapter_many": "Осталось {{count}} страниц в главе",
"{{count}} pages left in chapter_other": "Осталось {{count}} страниц в главе",
"Show Remaining Pages": "Показать оставшиеся страницы",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Управление подпиской",
"Coming Soon": "Скоро будет",
@@ -785,5 +782,133 @@
"Count": "Количество",
"Start Page": "Начальная страница",
"Search in OPDS Catalog...": "Поиск в каталоге OPDS...",
"Please log in to use advanced TTS features.": "Пожалуйста, войдите в систему, чтобы использовать расширенные функции TTS."
"Please log in to use advanced TTS features": "Пожалуйста, войдите в систему, чтобы использовать расширенные функции TTS",
"Word limit of 30 words exceeded.": "Превышен лимит в 30 слов.",
"Proofread": "Корректура",
"Current selection": "Текущее выделение",
"All occurrences in this book": "Все вхождения в этой книге",
"All occurrences in your library": "Все вхождения в вашей библиотеке",
"Selected text:": "Выделенный текст:",
"Replace with:": "Заменить на:",
"Enter text...": "Введите текст…",
"Case sensitive:": "Учитывать регистр:",
"Scope:": "Область применения:",
"Selection": "Выделение",
"Library": "Библиотека",
"Yes": "Да",
"No": "Нет",
"Proofread Replacement Rules": "Правила замены для корректуры",
"Selected Text Rules": "Правила для выделенного текста",
"No selected text replacement rules": "Нет правил замены для выделенного текста",
"Book Specific Rules": "Правила для конкретной книги",
"No book-level replacement rules": "Нет правил замены на уровне книги",
"Disable Quick Action": "Отключить быстрые действия",
"Enable Quick Action on Selection": "Включить быстрые действия при выборе",
"None": "Нет",
"Annotation Tools": "Инструменты для аннотаций",
"Enable Quick Actions": "Включить быстрые действия",
"Quick Action": "Быстрое действие",
"Copy to Notebook": "Скопировать в блокнот",
"Copy text after selection": "Копировать текст после выделения",
"Highlight text after selection": "Выделить текст после выделения",
"Annotate text after selection": "Аннотировать текст после выделения",
"Search text after selection": "Искать текст после выделения",
"Look up text in dictionary after selection": "Искать текст в словаре после выделения",
"Look up text in Wikipedia after selection": "Искать текст в Википедии после выделения",
"Translate text after selection": "Перевести текст после выделения",
"Read text aloud after selection": "Прочитать текст вслух после выделения",
"Proofread text after selection": "Корректировать текст после выделения",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} активных, {{pendingCount}} ожидающих",
"{{failedCount}} failed": "{{failedCount}} неудачных",
"Waiting...": "Ожидание...",
"Failed": "Ошибка",
"Completed": "Завершено",
"Cancelled": "Отменено",
"Retry": "Повторить",
"Active": "Активные",
"Transfer Queue": "Очередь передачи",
"Upload All": "Загрузить все",
"Download All": "Скачать все",
"Resume Transfers": "Возобновить передачу",
"Pause Transfers": "Приостановить передачу",
"Pending": "Ожидающие",
"No transfers": "Нет передач",
"Retry All": "Повторить все",
"Clear Completed": "Очистить завершенные",
"Clear Failed": "Очистить неудачные",
"Upload queued: {{title}}": "Загрузка в очереди: {{title}}",
"Download queued: {{title}}": "Скачивание в очереди: {{title}}",
"Book not found in library": "Книга не найдена в библиотеке",
"Unknown error": "Неизвестная ошибка",
"Please log in to continue": "Войдите, чтобы продолжить",
"Cloud File Transfers": "Передача файлов в облако",
"Show Search Results": "Показать результаты",
"Search results for '{{term}}'": "Результаты для «{{term}}»",
"Close Search": "Закрыть поиск",
"Previous Result": "Предыдущий результат",
"Next Result": "Следующий результат",
"Bookmarks": "Закладки",
"Annotations": "Аннотации",
"Show Results": "Показать результаты",
"Clear search": "Очистить поиск",
"Clear search history": "Очистить историю поиска",
"Quick action disabled": "Быстрое действие отключено",
"Tap to Toggle Footer": "Нажмите для переключения нижнего колонтитула",
"Exported successfully": "Успешно экспортировано",
"Book exported successfully.": "Книга успешно экспортирована.",
"Failed to export the book.": "Не удалось экспортировать книгу.",
"Export Book": "Экспорт книги",
"Whole word:": "Слово целиком:",
"Error": "Ошибка",
"Unable to load the article. Try searching directly on {{link}}.": "Не удалось загрузить статью. Попробуйте искать напрямую на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не удалось загрузить слово. Попробуйте искать напрямую на {{link}}.",
"Date Published": "Дата публикации",
"Only for TTS:": "Только для TTS:",
"Uploaded": "Загружено",
"Downloaded": "Скачано",
"Deleted": "Удалено",
"Note:": "Примечание:",
"Time:": "Время:",
"Format Options": "Параметры формата",
"Export Date": "Дата экспорта",
"Chapter Titles": "Названия глав",
"Chapter Separator": "Разделитель глав",
"Highlights": "Выделения",
"Note Date": "Дата примечания",
"Advanced": "Дополнительно",
"Hide": "Скрыть",
"Show": "Показать",
"Use Custom Template": "Использовать свой шаблон",
"Export Template": "Шаблон экспорта",
"Template Syntax:": "Синтаксис шаблона:",
"Insert value": "Вставить значение",
"Format date (locale)": "Форматировать дату (локаль)",
"Format date (custom)": "Форматировать дату (свой)",
"Conditional": "Условный",
"Loop": "Цикл",
"Available Variables:": "Доступные переменные:",
"Book title": "Название книги",
"Book author": "Автор книги",
"Export date": "Дата экспорта",
"Array of chapters": "Массив глав",
"Chapter title": "Название главы",
"Array of annotations": "Массив примечаний",
"Highlighted text": "Выделенный текст",
"Annotation note": "Примечание аннотации",
"Update time": "Время обновления",
"Date Format Tokens:": "Токены формата даты:",
"Year (4 digits)": "Год (4 цифры)",
"Month (01-12)": "Месяц (01-12)",
"Day (01-31)": "День (01-31)",
"Hour (00-23)": "Час (00-23)",
"Minute (00-59)": "Минута (00-59)",
"Second (00-59)": "Секунда (00-59)",
"Show Source": "Показать источник",
"No content to preview": "Нет содержимого для просмотра",
"Export": "Экспортировать",
"Set Timeout": "Установить тайм-аут",
"Select Voice": "Выбрать голос",
"Toggle Sticky Bottom TTS Bar": "Переключить закреплённую панель TTS",
"Display what I'm reading on Discord": "Показывать книгу в Discord",
"Show on Discord": "Показать в Discord"
}
@@ -24,9 +24,6 @@
"Token": "ටෝකනය",
"Your OTP token": "ඔබේ OTP ටෝකනය",
"Verify token": "ටෝකනය තහවුරු කරන්න",
"Sign in with Google": "Google සමඟ ඇතුල් වන්න",
"Sign in with Apple": "Apple සමඟ ඇතුල් වන්න",
"Sign in with GitHub": "GitHub සමඟ ඇතුල් වන්න",
"New Password": "නව මුරපදය",
"Your new password": "ඔබේ නව මුරපදය",
"Update password": "මුරපදය යාවත්කාලීන කරන්න",
@@ -488,8 +485,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
@@ -765,5 +762,133 @@
"Count": "ගණන",
"Start Page": "ආරම්භක පිටුව",
"Search in OPDS Catalog...": "OPDS දත්තසමුදා තුළ සෙවීම...",
"Please log in to use advanced TTS features.": "උසස් TTS විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න."
"Please log in to use advanced TTS features": "උසස් TTS විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න",
"Word limit of 30 words exceeded.": "වචන 30ක සීමාව ඉක්මවා ඇත.",
"Proofread": "සංශෝධනය",
"Current selection": "වත්මන් තේරීම",
"All occurrences in this book": "මෙම පොත තුළ ඇති සියලුම පෙනීම්",
"All occurrences in your library": "ඔබගේ පුස්තකාලයේ ඇති සියලුම පෙනීම්",
"Selected text:": "තේරූ පෙළ:",
"Replace with:": "මෙයින් ප්‍රතිස්ථාපනය කරන්න:",
"Enter text...": "පෙළ ඇතුළත් කරන්න…",
"Case sensitive:": "අකුරු විශාල/කුඩා භේදය සලකා බලන්න:",
"Scope:": "පරාසය:",
"Selection": "තේරීම",
"Library": "පුස්තකාලය",
"Yes": "ඔව්",
"No": "නැහැ",
"Proofread Replacement Rules": "සංශෝධන ප්‍රතිස්ථාපන නියමයන්",
"Selected Text Rules": "තේරූ පෙළ සඳහා නියමයන්",
"No selected text replacement rules": "තේරූ පෙළ සඳහා ප්‍රතිස්ථාපන නියමයන් නොමැත",
"Book Specific Rules": "පොතට විශේෂිත නියමයන්",
"No book-level replacement rules": "පොත් මට්ටමේ ප්‍රතිස්ථාපන නියමයන් නොමැත",
"Disable Quick Action": "තට්ටු ක්‍රියාව අක්‍රිය කරන්න",
"Enable Quick Action on Selection": "තෝරා ගැනීමේදී තට්ටු ක්‍රියාව සක්‍රීය කරන්න",
"None": "කිසිවක් නැත",
"Annotation Tools": "සටහන් මෙවලම්",
"Enable Quick Actions": "තට්ටු ක්‍රියාවන් සක්‍රීය කරන්න",
"Quick Action": "තට්ටු ක්‍රියාව",
"Copy to Notebook": "සටහන් පොතට පිටපත් කරන්න",
"Copy text after selection": "තේරීමෙන් පසු පෙළ පිටපත් කරන්න",
"Highlight text after selection": "තේරීමෙන් පසු පෙළ හයිලයිට් කරන්න",
"Annotate text after selection": "තේරීමෙන් පසු පෙළ සටහන් කරන්න",
"Search text after selection": "තේරීමෙන් පසු පෙළ සෙවීම",
"Look up text in dictionary after selection": "තේරීමෙන් පසු ශබ්දකෝෂයේ පෙළ සෙවීම",
"Look up text in Wikipedia after selection": "තේරීමෙන් පසු විකිපීඩියාවේ පෙළ සෙවීම",
"Translate text after selection": "තේරීමෙන් පසු පෙළ පරිවර්තනය කරන්න",
"Read text aloud after selection": "තේරීමෙන් පසු පෙළ උච්චාරණය කරන්න",
"Proofread text after selection": "තේරීමෙන් පසු පෙළ සංශෝධනය කරන්න",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} සක්‍රීය, {{pendingCount}} බලාපොරොත්තුවේ",
"{{failedCount}} failed": "{{failedCount}} අසාර්ථක",
"Waiting...": "බලාපොරොත්තුවේ...",
"Failed": "අසාර්ථකයි",
"Completed": "සම්පූර්ණයි",
"Cancelled": "අවලංගුයි",
"Retry": "නැවත උත්සාහ කරන්න",
"Active": "සක්‍රීය",
"Transfer Queue": "හුවමාරු පෝලිම",
"Upload All": "සියල්ල උඩුගත කරන්න",
"Download All": "සියල්ල බාගන්න",
"Resume Transfers": "හුවමාරු නැවත ආරම්භ කරන්න",
"Pause Transfers": "හුවමාරු විරාම කරන්න",
"Pending": "බලාපොරොත්තුවේ",
"No transfers": "හුවමාරු නැත",
"Retry All": "සියල්ල නැවත උත්සාහ කරන්න",
"Clear Completed": "සම්පූර්ණ කළ ඒවා මකන්න",
"Clear Failed": "අසාර්ථක ඒවා මකන්න",
"Upload queued: {{title}}": "උඩුගත කිරීම පෝලිමේ: {{title}}",
"Download queued: {{title}}": "බාගත කිරීම පෝලිමේ: {{title}}",
"Book not found in library": "පුස්තකාලයේ පොත හමු නොවීය",
"Unknown error": "නොදන්නා දෝෂයක්",
"Please log in to continue": "ඉදිරියට යාමට පිවිසෙන්න",
"Cloud File Transfers": "කලාප ගොනු හුවමාරු",
"Show Search Results": "සෙවුම් ප්‍රතිඵල පෙන්වන්න",
"Search results for '{{term}}'": "'{{term}}' සඳහා ප්‍රතිඵල",
"Close Search": "සෙවුම වසන්න",
"Previous Result": "පෙර ප්‍රතිඵලය",
"Next Result": "ඊළඟ ප්‍රතිඵලය",
"Bookmarks": "පොත් සලකුණු",
"Annotations": "අනුසටහන්",
"Show Results": "ප්‍රතිඵල පෙන්වන්න",
"Clear search": "සෙවුම හිස් කරන්න",
"Clear search history": "සෙවුම් ඉතිහාසය හිස් කරන්න",
"Quick action disabled": "ඉක්මන් ක්‍රියාව අක්‍රීයයි",
"Tap to Toggle Footer": "පාදකය ටොගල් කිරීමට තට්ටු කරන්න",
"Exported successfully": "සාර්ථකව අපනයනය කරන ලදී",
"Book exported successfully.": "පොත සාර්ථකව අපනයනය කරන ලදී.",
"Failed to export the book.": "පොත අපනයනය කිරීමට අසමත් විය.",
"Export Book": "පොත අපනයනය කරන්න",
"Whole word:": "සම්පූර්ණ වචනය:",
"Error": "දෝෂය",
"Unable to load the article. Try searching directly on {{link}}.": "ලිපිය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Unable to load the word. Try searching directly on {{link}}.": "වචනය පූරණය කළ නොහැක. {{link}} හි කෙලින්ම සෙවීමට උත්සාහ කරන්න.",
"Date Published": "ප්‍රකාශන දිනය",
"Only for TTS:": "TTS සඳහා පමණක්:",
"Uploaded": "උඩුගත කරන ලදී",
"Downloaded": "බාගත කරන ලදී",
"Deleted": "මකා දමන ලදී",
"Note:": "සටහන:",
"Time:": "කාලය:",
"Format Options": "ආකෘති විකල්ප",
"Export Date": "අපනයන දිනය",
"Chapter Titles": "කොටස් මාතෘකා",
"Chapter Separator": "කොටස් වෙන්කරු",
"Highlights": "ඉස්මතු කිරීම්",
"Note Date": "සටහන දිනය",
"Advanced": "උසස්",
"Hide": "සඟවන්න",
"Show": "පෙන්වන්න",
"Use Custom Template": "අභිරුචි සැකිල්ල භාවිතා කරන්න",
"Export Template": "අපනයන සැකිල්ල",
"Template Syntax:": "සැකිල්ලේ ව්‍යවස්ථාව:",
"Insert value": "වටිනාකම ඇතුළත් කරන්න",
"Format date (locale)": "දිනය ආකෘතිය (දේශීය)",
"Format date (custom)": "අභිරුචි දිනය ආකෘතිය",
"Conditional": "ශර්තමය",
"Loop": "ලූප්",
"Available Variables:": "ලබා ගත හැකි විචල්‍ය:",
"Book title": "පොත් මාතෘකාව",
"Book author": "පොත් රචකයා",
"Export date": "අපනයන දිනය",
"Array of chapters": "කොටස් අරය",
"Chapter title": "කොටස් මාතෘකාව",
"Array of annotations": "අනුසටහන් අරය",
"Highlighted text": "ඉස්මතු කළ පෙළ",
"Annotation note": "අනුසටහන් සටහන",
"Update time": "යාවත්කාලීන කාලය",
"Date Format Tokens:": "දිනය ආකෘති ටෝකන:",
"Year (4 digits)": "වසර (අංක 4)",
"Month (01-12)": "මාසය (01-12)",
"Day (01-31)": "දින (01-31)",
"Hour (00-23)": "පැය (00-23)",
"Minute (00-59)": "මිනිත්තු (00-59)",
"Second (00-59)": "තත්පර (00-59)",
"Show Source": "මූලාශ්‍රය පෙන්වන්න",
"No content to preview": "පෙරදසුන සඳහා අන්තර්ගතයක් නැත",
"Export": "අපනයනය කරන්න",
"Set Timeout": "කාල සීමාව සකසන්න",
"Select Voice": "හඬ තෝරන්න",
"Toggle Sticky Bottom TTS Bar": "ඇලවූ TTS තීරුව මාරු කරන්න",
"Display what I'm reading on Discord": "Discord හි කියවන පොත පෙන්වන්න",
"Show on Discord": "Discord හි පෙන්වන්න"
}
@@ -25,9 +25,6 @@
"Your OTP token": "Din OTP-token",
"Verify token": "Verifiera token",
"Go Back": "Tillbaka",
"Sign in with Google": "Logga in med Google",
"Sign in with Apple": "Logga in med Apple",
"Sign in with GitHub": "Logga in med GitHub",
"New Password": "Nytt lösenord",
"Your new password": "Ditt nya lösenord",
"Update password": "Uppdatera lösenord",
@@ -573,8 +570,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Böcker",
"Open Library": "Open Library",
@@ -765,5 +762,133 @@
"Count": "Antal",
"Start Page": "Start sida",
"Search in OPDS Catalog...": "Sök i OPDS-katalog...",
"Please log in to use advanced TTS features.": "Logga in för att använda avancerade TTS-funktioner."
"Please log in to use advanced TTS features": "Logga in för att använda avancerade TTS-funktioner",
"Word limit of 30 words exceeded.": "Gränsen på 30 ord har överskridits.",
"Proofread": "Korrekturläsning",
"Current selection": "Aktuell markering",
"All occurrences in this book": "Alla förekomster i den här boken",
"All occurrences in your library": "Alla förekomster i ditt bibliotek",
"Selected text:": "Markerad text:",
"Replace with:": "Ersätt med:",
"Enter text...": "Ange text…",
"Case sensitive:": "Skiftlägeskänslig:",
"Scope:": "Omfattning:",
"Selection": "Markering",
"Library": "Bibliotek",
"Yes": "Ja",
"No": "Nej",
"Proofread Replacement Rules": "Ersättningsregler för korrekturläsning",
"Selected Text Rules": "Regler för markerad text",
"No selected text replacement rules": "Inga ersättningsregler för markerad text",
"Book Specific Rules": "Bokspecifika regler",
"No book-level replacement rules": "Inga ersättningsregler på boknivå",
"Disable Quick Action": "Inaktivera snabbåtgärd",
"Enable Quick Action on Selection": "Aktivera snabbåtgärd vid val",
"None": "Ingen",
"Annotation Tools": "Anteckningsverktyg",
"Enable Quick Actions": "Aktivera snabba åtgärder",
"Quick Action": "Snabbåtgärd",
"Copy to Notebook": "Kopiera till anteckningsbok",
"Copy text after selection": "Kopiera text efter markering",
"Highlight text after selection": "Markera text efter markering",
"Annotate text after selection": "Anteckna text efter markering",
"Search text after selection": "Sök text efter markering",
"Look up text in dictionary after selection": "Slå upp text i ordbok efter markering",
"Look up text in Wikipedia after selection": "Slå upp text i Wikipedia efter markering",
"Translate text after selection": "Översätt text efter markering",
"Read text aloud after selection": "Läs upp text efter markering",
"Proofread text after selection": "Korrekturläs text efter markering",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktiva, {{pendingCount}} väntande",
"{{failedCount}} failed": "{{failedCount}} misslyckades",
"Waiting...": "Väntar...",
"Failed": "Misslyckades",
"Completed": "Slutförd",
"Cancelled": "Avbruten",
"Retry": "Försök igen",
"Active": "Aktiva",
"Transfer Queue": "Överföringskö",
"Upload All": "Ladda upp alla",
"Download All": "Ladda ner alla",
"Resume Transfers": "Återuppta överföringar",
"Pause Transfers": "Pausa överföringar",
"Pending": "Väntande",
"No transfers": "Inga överföringar",
"Retry All": "Försök alla igen",
"Clear Completed": "Rensa slutförda",
"Clear Failed": "Rensa misslyckade",
"Upload queued: {{title}}": "Uppladdning köad: {{title}}",
"Download queued: {{title}}": "Nedladdning köad: {{title}}",
"Book not found in library": "Boken hittades inte i biblioteket",
"Unknown error": "Okänt fel",
"Please log in to continue": "Logga in för att fortsätta",
"Cloud File Transfers": "Molnfilöverföringar",
"Show Search Results": "Visa sökresultat",
"Search results for '{{term}}'": "Resultat för '{{term}}'",
"Close Search": "Stäng sökning",
"Previous Result": "Föregående resultat",
"Next Result": "Nästa resultat",
"Bookmarks": "Bokmärken",
"Annotations": "Anteckningar",
"Show Results": "Visa resultat",
"Clear search": "Rensa sökning",
"Clear search history": "Rensa sökhistorik",
"Quick action disabled": "Snabbåtgärd inaktiverad",
"Tap to Toggle Footer": "Tryck för att växla sidfot",
"Exported successfully": "Exporterad",
"Book exported successfully.": "Boken har exporterats.",
"Failed to export the book.": "Det gick inte att exportera boken.",
"Export Book": "Exportera bok",
"Whole word:": "Hela ordet:",
"Error": "Fel",
"Unable to load the article. Try searching directly on {{link}}.": "Kan inte ladda artikeln. Försök söka direkt på {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Kan inte ladda ordet. Försök söka direkt på {{link}}.",
"Date Published": "Publiceringsdatum",
"Only for TTS:": "Endast för TTS:",
"Uploaded": "Uppladdad",
"Downloaded": "Nedladdad",
"Deleted": "Borttagen",
"Note:": "Anteckning:",
"Time:": "Tid:",
"Format Options": "Formatalternativ",
"Export Date": "Exportdatum",
"Chapter Titles": "Kapiteltitlar",
"Chapter Separator": "Kapitelavgränsare",
"Highlights": "Markeringar",
"Note Date": "Anteckningsdatum",
"Advanced": "Avancerat",
"Hide": "Dölj",
"Show": "Visa",
"Use Custom Template": "Använd anpassad mall",
"Export Template": "Exportmall",
"Template Syntax:": "Mallsyntax:",
"Insert value": "Infoga värde",
"Format date (locale)": "Formatera datum (lokal)",
"Format date (custom)": "Formatera datum (anpassad)",
"Conditional": "Villkorlig",
"Loop": "Loop",
"Available Variables:": "Tillgängliga variabler:",
"Book title": "Boktitel",
"Book author": "Bokförfattare",
"Export date": "Exportdatum",
"Array of chapters": "Lista med kapitel",
"Chapter title": "Kapiteltitel",
"Array of annotations": "Lista med anteckningar",
"Highlighted text": "Markerad text",
"Annotation note": "Anteckningsnotering",
"Update time": "Uppdateringstid",
"Date Format Tokens:": "Datumformattecken:",
"Year (4 digits)": "År (4 siffror)",
"Month (01-12)": "Månad (01-12)",
"Day (01-31)": "Dag (01-31)",
"Hour (00-23)": "Timme (00-23)",
"Minute (00-59)": "Minut (00-59)",
"Second (00-59)": "Sekund (00-59)",
"Show Source": "Visa källa",
"No content to preview": "Inget innehåll att förhandsgranska",
"Export": "Exportera",
"Set Timeout": "Ställ in timeout",
"Select Voice": "Välj röst",
"Toggle Sticky Bottom TTS Bar": "Växla fast TTS-fält",
"Display what I'm reading on Discord": "Visa vad jag läser på Discord",
"Show on Discord": "Visa på Discord"
}
@@ -24,9 +24,6 @@
"Token": "டோக்கன்",
"Your OTP token": "உங்கள் OTP டோக்கன்",
"Verify token": "டோக்கனை சரிபார்க்கவும்",
"Sign in with Google": "Google மூலம் உள்நுழையவும்",
"Sign in with Apple": "Apple மூலம் உள்நுழையவும்",
"Sign in with GitHub": "GitHub மூலம் உள்நுழையவும்",
"New Password": "புதிய கடவுச்சொல்",
"Your new password": "உங்கள் புதிய கடவுச்சொல்",
"Update password": "கடவுச்சொல் புதுப்பிக்கவும்",
@@ -488,8 +485,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Books",
"Open Library": "Open Library",
@@ -765,5 +762,133 @@
"Count": "எண்ணிக்கை",
"Start Page": "தொடக்கப் பக்கம்",
"Search in OPDS Catalog...": "OPDS பட்டியலில் தேடவும்...",
"Please log in to use advanced TTS features.": "மேம்பட்ட TTS அம்சங்களை பயன்படுத்த உள்நுழையவும்."
"Please log in to use advanced TTS features": "மேம்பட்ட TTS அம்சங்களை பயன்படுத்த உள்நுழையவும்",
"Word limit of 30 words exceeded.": "30 சொற்களின் வரம்பை மீறியுள்ளது.",
"Proofread": "திருத்தம்",
"Current selection": "தற்போதைய தேர்வு",
"All occurrences in this book": "இந்த புத்தகத்தில் உள்ள அனைத்து நிகழ்வுகளும்",
"All occurrences in your library": "உங்கள் நூலகத்தில் உள்ள அனைத்து நிகழ்வுகளும்",
"Selected text:": "தேர்ந்தெடுக்கப்பட்ட உரை:",
"Replace with:": "இதனால் மாற்றவும்:",
"Enter text...": "உரையை உள்ளிடவும்…",
"Case sensitive:": "எழுத்து பெரிய/சிறிய வேறுபாட்டை கருத்தில் கொள்ளவும்:",
"Scope:": "வளயம்:",
"Selection": "தேர்வு",
"Library": "நூலகம்",
"Yes": "ஆம்",
"No": "இல்லை",
"Proofread Replacement Rules": "திருத்த மாற்று விதிகள்",
"Selected Text Rules": "தேர்ந்தெடுக்கப்பட்ட உரைக்கான விதிகள்",
"No selected text replacement rules": "தேர்ந்தெடுக்கப்பட்ட உரைக்கான மாற்று விதிகள் இல்லை",
"Book Specific Rules": "புத்தகத்திற்கு குறிப்பிட்ட விதிகள்",
"No book-level replacement rules": "புத்தக மட்டத்தில் மாற்று விதிகள் இல்லை",
"Disable Quick Action": "விரைவு செயலை முடக்கு",
"Enable Quick Action on Selection": "தேர்வில் விரைவு செயலை இயக்கு",
"None": "எதுவும் இல்லை",
"Annotation Tools": "கருத்துரை கருவிகள்",
"Enable Quick Actions": "விரைவு செயல்களை இயக்கு",
"Quick Action": "விரைவு செயல்",
"Copy to Notebook": "குறிப்பேட்டியில் நகலெடு",
"Copy text after selection": "உரையை தேர்வுக்குப் பிறகு நகலெடு",
"Highlight text after selection": "உரையை தேர்வுக்குப் பிறகு முத்திரை இடு",
"Annotate text after selection": "உரையை தேர்வுக்குப் பிறகு கருத்துரை இடு",
"Search text after selection": "உரையை தேர்வுக்குப் பிறகு தேடு",
"Look up text in dictionary after selection": "தேர்வுக்குப் பிறகு அகராதியில் உரையைத் தேடு",
"Look up text in Wikipedia after selection": "தேர்வுக்குப் பிறகு விக்கிப்பீடியாவில் உரையைத் தேடு",
"Translate text after selection": "தேர்வுக்குப் பிறகு உரையை மொழிபெயர்",
"Read text aloud after selection": "தேர்வுக்குப் பிறகு உரையை ஓதுக",
"Proofread text after selection": "தேர்வுக்குப் பிறகு உரையை திருத்துக",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} செயலில், {{pendingCount}} காத்திருப்பு",
"{{failedCount}} failed": "{{failedCount}} தோல்வி",
"Waiting...": "காத்திருக்கிறது...",
"Failed": "தோல்வி",
"Completed": "முடிந்தது",
"Cancelled": "ரத்து செய்யப்பட்டது",
"Retry": "மீண்டும் முயற்சிக்கவும்",
"Active": "செயலில்",
"Transfer Queue": "பரிமாற்ற வரிசை",
"Upload All": "அனைத்தையும் பதிவேற்றவும்",
"Download All": "அனைத்தையும் பதிவிறக்கவும்",
"Resume Transfers": "பரிமாற்றங்களை தொடரவும்",
"Pause Transfers": "பரிமாற்றங்களை இடைநிறுத்தவும்",
"Pending": "காத்திருப்பு",
"No transfers": "பரிமாற்றங்கள் இல்லை",
"Retry All": "அனைத்தையும் மீண்டும் முயற்சிக்கவும்",
"Clear Completed": "முடிந்ததை அழிக்கவும்",
"Clear Failed": "தோல்வியை அழிக்கவும்",
"Upload queued: {{title}}": "பதிவேற்றம் வரிசையில்: {{title}}",
"Download queued: {{title}}": "பதிவிறக்கம் வரிசையில்: {{title}}",
"Book not found in library": "நூலகத்தில் புத்தகம் கிடைக்கவில்லை",
"Unknown error": "தெரியாத பிழை",
"Please log in to continue": "தொடர உள்நுழையவும்",
"Cloud File Transfers": "மேகக் கோப்பு பரிமாற்றங்கள்",
"Show Search Results": "தேடல் முடிவுகளைக் காட்டு",
"Search results for '{{term}}'": "'{{term}}' க்கான முடிவுகள்",
"Close Search": "தேடலை மூடு",
"Previous Result": "முந்தைய முடிவு",
"Next Result": "அடுத்த முடிவு",
"Bookmarks": "புக்மார்க்குகள்",
"Annotations": "சிறுகுறிப்புகள்",
"Show Results": "முடிவுகளைக் காட்டு",
"Clear search": "தேடலை அழி",
"Clear search history": "தேடல் வரலாற்றை அழி",
"Quick action disabled": "விரைவு செயல் முடக்கப்பட்டது",
"Tap to Toggle Footer": "அடிக்குறிப்பை மாற்ற தட்டவும்",
"Exported successfully": "ஏற்றுமதி வெற்றி",
"Book exported successfully.": "புத்தகம் வெற்றிகரமாக ஏற்றுமதி செய்யப்பட்டது.",
"Failed to export the book.": "புத்தகத்தை ஏற்றுமதி செய்ய இயலவில்லை.",
"Export Book": "புத்தகத்தை ஏற்றுமதி செய்",
"Whole word:": "முழு சொல்:",
"Error": "பிழை",
"Unable to load the article. Try searching directly on {{link}}.": "கட்டுரையை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Unable to load the word. Try searching directly on {{link}}.": "சொல்லை ஏற்ற முடியவில்லை. நேரடியாக {{link}} இல் தேடவும்.",
"Date Published": "வெளியீட்டு தேதி",
"Only for TTS:": "TTS க்கு மட்டும்:",
"Uploaded": "பதிவேற்றப்பட்டது",
"Downloaded": "பதிவிறக்கப்பட்டது",
"Deleted": "நீக்கப்பட்டது",
"Note:": "குறிப்பு:",
"Time:": "நேரம்:",
"Format Options": "வடிவமைப்பு விருப்பங்கள்",
"Export Date": "ஏற்றுமதி தேதி",
"Chapter Titles": "அத்தியாயத் தலைப்புகள்",
"Chapter Separator": "அத்தியாய பிரிப்பான்",
"Highlights": "சிறப்பம்சங்கள்",
"Note Date": "குறிப்பு தேதி",
"Advanced": "மேம்பட்ட",
"Hide": "மறை",
"Show": "காட்டு",
"Use Custom Template": "தனிப்பயன் வார்ப்புரு பயன்படுத்து",
"Export Template": "ஏற்றுமதி வார்ப்புரு",
"Template Syntax:": "வார்ப்புரு தொடரியல்:",
"Insert value": "மதிப்பு செருகு",
"Format date (locale)": "தேதி வடிவமைப்பு (உள்ளூர்)",
"Format date (custom)": "தேதி வடிவமைப்பு (தனிப்பயன்)",
"Conditional": "நிபந்தனை",
"Loop": "சுழற்சி",
"Available Variables:": "கிடைக்கும் மாறிகள்:",
"Book title": "புத்தகத் தலைப்பு",
"Book author": "புத்தக ஆசிரியர்",
"Export date": "ஏற்றுமதி தேதி",
"Array of chapters": "அத்தியாயங்களின் பட்டியல்",
"Chapter title": "அத்தியாய தலைப்பு",
"Array of annotations": "குறிப்புகளின் பட்டியல்",
"Highlighted text": "முன்னிலைப்படுத்தப்பட்ட உரை",
"Annotation note": "குறிப்பு குறிப்பு",
"Update time": "புதுப்பிப்பு நேரம்",
"Date Format Tokens:": "தேதி வடிவமைப்பு டோக்கன்கள்:",
"Year (4 digits)": "ஆண்டு (4 இலக்கங்கள்)",
"Month (01-12)": "மாதம் (01-12)",
"Day (01-31)": "நாள் (01-31)",
"Hour (00-23)": "மணி (00-23)",
"Minute (00-59)": "நிமிடம் (00-59)",
"Second (00-59)": "விநாடி (00-59)",
"Show Source": "மூலத்தைக் காட்டு",
"No content to preview": "முன்னோட்டத்திற்கு உள்ளடக்கம் இல்லை",
"Export": "ஏற்றுமதி",
"Set Timeout": "நேர வரம்பை அமைக்கவும்",
"Select Voice": "குரலைத் தேர்ந்தெடுக்கவும்",
"Toggle Sticky Bottom TTS Bar": "நிலையான TTS பட்டியை மாற்றவும்",
"Display what I'm reading on Discord": "Discord இல் படிக்கும் புத்தகத்தை காட்டு",
"Show on Discord": "Discord இல் காட்டு"
}
@@ -23,9 +23,6 @@
"Token": "รหัสยืนยัน",
"Your OTP token": "รหัส OTP ของคุณ",
"Verify token": "ยืนยันรหัส",
"Sign in with Google": "เข้าสู่ระบบด้วย Google",
"Sign in with Apple": "เข้าสู่ระบบด้วย Apple",
"Sign in with GitHub": "เข้าสู่ระบบด้วย GitHub",
"New Password": "รหัสผ่านใหม่",
"Your new password": "รหัสผ่านใหม่ของคุณ",
"Update password": "อัปเดตรหัสผ่าน",
@@ -379,8 +376,8 @@
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"Source Han Serif CN": "Source Han Serif CN",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Azure Translator": "Azure Translator",
"DeepL": "DeepL",
@@ -755,5 +752,133 @@
"Count": "นับ",
"Start Page": "หน้าเริ่มต้น",
"Search in OPDS Catalog...": "ค้นหาในแคตตาล็อก OPDS...",
"Please log in to use advanced TTS features.": "กรุณาเข้าสู่ระบบเพื่อใช้ฟีเจอร์ TTS ขั้นสูง"
"Please log in to use advanced TTS features": "กรุณาเข้าสู่ระบบเพื่อใช้ฟีเจอร์ TTS ขั้นสูง",
"Word limit of 30 words exceeded.": "เกินขีดจำกัด 30 คำแล้ว",
"Proofread": "ตรวจทาน",
"Current selection": "การเลือกปัจจุบัน",
"All occurrences in this book": "การปรากฏทั้งหมดในหนังสือเล่มนี้",
"All occurrences in your library": "การปรากฏทั้งหมดในคลังหนังสือของคุณ",
"Selected text:": "ข้อความที่เลือก:",
"Replace with:": "แทนที่ด้วย:",
"Enter text...": "ป้อนข้อความ…",
"Case sensitive:": "คำนึงถึงตัวพิมพ์เล็ก/ใหญ่:",
"Scope:": "ขอบเขต:",
"Selection": "การเลือก",
"Library": "คลังหนังสือ",
"Yes": "ใช่",
"No": "ไม่",
"Proofread Replacement Rules": "กฎการแทนที่สำหรับการตรวจทาน",
"Selected Text Rules": "กฎสำหรับข้อความที่เลือก",
"No selected text replacement rules": "ไม่มีกฎการแทนที่สำหรับข้อความที่เลือก",
"Book Specific Rules": "กฎเฉพาะของหนังสือ",
"No book-level replacement rules": "ไม่มีกฎการแทนที่ระดับหนังสือ",
"Disable Quick Action": "ปิดใช้งานการดำเนินการด่วน",
"Enable Quick Action on Selection": "เปิดใช้งานการดำเนินการด่วนเมื่อเลือก",
"None": "ไม่มี",
"Annotation Tools": "เครื่องมือหมายเหตุ",
"Enable Quick Actions": "เปิดใช้งานการดำเนินการด่วน",
"Quick Action": "การดำเนินการด่วน",
"Copy to Notebook": "คัดลอกไปยังสมุดบันทึก",
"Copy text after selection": "คัดลอกข้อความหลังการเลือก",
"Highlight text after selection": "ไฮไลต์ข้อความหลังการเลือก",
"Annotate text after selection": "เพิ่มหมายเหตุข้อความหลังการเลือก",
"Search text after selection": "ค้นหาข้อความหลังการเลือก",
"Look up text in dictionary after selection": "ค้นหาข้อความในพจนานุกรมหลังการเลือก",
"Look up text in Wikipedia after selection": "ค้นหาข้อความในวิกิพีเดียหลังการเลือก",
"Translate text after selection": "แปลข้อความหลังการเลือก",
"Read text aloud after selection": "อ่านข้อความออกเสียงหลังการเลือก",
"Proofread text after selection": "ตรวจทานข้อความหลังการเลือก",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} กำลังดำเนินการ, {{pendingCount}} รอดำเนินการ",
"{{failedCount}} failed": "{{failedCount}} ล้มเหลว",
"Waiting...": "กำลังรอ...",
"Failed": "ล้มเหลว",
"Completed": "เสร็จสิ้น",
"Cancelled": "ยกเลิก",
"Retry": "ลองใหม่",
"Active": "กำลังดำเนินการ",
"Transfer Queue": "คิวการถ่ายโอน",
"Upload All": "อัปโหลดทั้งหมด",
"Download All": "ดาวน์โหลดทั้งหมด",
"Resume Transfers": "ดำเนินการถ่ายโอนต่อ",
"Pause Transfers": "หยุดการถ่ายโอนชั่วคราว",
"Pending": "รอดำเนินการ",
"No transfers": "ไม่มีการถ่ายโอน",
"Retry All": "ลองใหม่ทั้งหมด",
"Clear Completed": "ล้างที่เสร็จสิ้น",
"Clear Failed": "ล้างที่ล้มเหลว",
"Upload queued: {{title}}": "อัปโหลดอยู่ในคิว: {{title}}",
"Download queued: {{title}}": "ดาวน์โหลดอยู่ในคิว: {{title}}",
"Book not found in library": "ไม่พบหนังสือในห้องสมุด",
"Unknown error": "ข้อผิดพลาดที่ไม่รู้จัก",
"Please log in to continue": "กรุณาเข้าสู่ระบบเพื่อดำเนินการต่อ",
"Cloud File Transfers": "การถ่ายโอนไฟล์คลาวด์",
"Show Search Results": "แสดงผลการค้นหา",
"Search results for '{{term}}'": "ผลลัพธ์สำหรับ '{{term}}'",
"Close Search": "ปิดการค้นหา",
"Previous Result": "ผลลัพธ์ก่อนหน้า",
"Next Result": "ผลลัพธ์ถัดไป",
"Bookmarks": "บุ๊กมาร์ก",
"Annotations": "คำอธิบายประกอบ",
"Show Results": "แสดงผลลัพธ์",
"Clear search": "ล้างการค้นหา",
"Clear search history": "ล้างประวัติการค้นหา",
"Quick action disabled": "ปิดใช้งานการดำเนินการด่วน",
"Tap to Toggle Footer": "แตะเพื่อสลับส่วนท้าย",
"Exported successfully": "ส่งออกสำเร็จ",
"Book exported successfully.": "ส่งออกหนังสือสำเร็จ",
"Failed to export the book.": "ส่งออกหนังสือล้มเหลว",
"Export Book": "ส่งออกหนังสือ",
"Whole word:": "คำทั้งคำ:",
"Error": "ข้อผิดพลาด",
"Unable to load the article. Try searching directly on {{link}}.": "ไม่สามารถโหลดบทความได้ ลองค้นหาโดยตรงที่ {{link}}",
"Unable to load the word. Try searching directly on {{link}}.": "ไม่สามารถโหลดคำได้ ลองค้นหาโดยตรงที่ {{link}}",
"Date Published": "วันที่เผยแพร่",
"Only for TTS:": "สำหรับ TTS เท่านั้น:",
"Uploaded": "อัปโหลดแล้ว",
"Downloaded": "ดาวน์โหลดแล้ว",
"Deleted": "ลบแล้ว",
"Note:": "หมายเหตุ:",
"Time:": "เวลา:",
"Format Options": "ตัวเลือกรูปแบบ",
"Export Date": "วันที่ส่งออก",
"Chapter Titles": "ชื่อบท",
"Chapter Separator": "ตัวคั่นบท",
"Highlights": "ไฮไลต์",
"Note Date": "วันที่หมายเหตุ",
"Advanced": "ขั้นสูง",
"Hide": "ซ่อน",
"Show": "แสดง",
"Use Custom Template": "ใช้เทมเพลตที่กำหนดเอง",
"Export Template": "เทมเพลตส่งออก",
"Template Syntax:": "ไวยากรณ์เทมเพลต:",
"Insert value": "แทรกค่า",
"Format date (locale)": "จัดรูปแบบวันที่ (ท้องถิ่น)",
"Format date (custom)": "จัดรูปแบบวันที่ (กำหนดเอง)",
"Conditional": "เงื่อนไข",
"Loop": "ลูป",
"Available Variables:": "ตัวแปรที่พร้อมใช้งาน:",
"Book title": "ชื่อหนังสือ",
"Book author": "ผู้แต่งหนังสือ",
"Export date": "วันที่ส่งออก",
"Array of chapters": "อาร์เรย์ของบท",
"Chapter title": "ชื่อบท",
"Array of annotations": "อาร์เรย์ของคำอธิบาย",
"Highlighted text": "ข้อความที่ไฮไลต์",
"Annotation note": "หมายเหตุคำอธิบาย",
"Update time": "เวลาอัปเดต",
"Date Format Tokens:": "โทเค็นรูปแบบวันที่:",
"Year (4 digits)": "ปี (4 หลัก)",
"Month (01-12)": "เดือน (01-12)",
"Day (01-31)": "วัน (01-31)",
"Hour (00-23)": "ชั่วโมง (00-23)",
"Minute (00-59)": "นาที (00-59)",
"Second (00-59)": "วินาที (00-59)",
"Show Source": "แสดงซอร์ส",
"No content to preview": "ไม่มีเนื้อหาให้แสดงตัวอย่าง",
"Export": "ส่งออก",
"Set Timeout": "ตั้งค่าหมดเวลา",
"Select Voice": "เลือกเสียง",
"Toggle Sticky Bottom TTS Bar": "สลับแถบ TTS แบบติด",
"Display what I'm reading on Discord": "แสดงหนังสือที่กำลังอ่านบน Discord",
"Show on Discord": "แสดงบน Discord"
}
@@ -173,9 +173,6 @@
"Token": "Token",
"Your OTP token": "OTP token'ınız",
"Verify token": "Token'ı doğrula",
"Sign in with Google": "Google ile giriş yap",
"Sign in with Apple": "Apple ile giriş yap",
"Sign in with GitHub": "GitHub ile giriş yap",
"Account": "Hesap",
"Failed to delete user. Please try again later.": "Kullanıcı silinemedi. Lütfen daha sonra tekrar deneyin.",
"Community Support": "Topluluk Desteği",
@@ -341,8 +338,8 @@
"{{count}} pages left in chapter_one": "Bu bölümde {{count}} sayfa kaldı",
"{{count}} pages left in chapter_other": "Bu bölümde {{count}} sayfa kaldı",
"Show Remaining Pages": "Kalan sayfaları göster",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Aboneliği Yönet",
"Coming Soon": "Yakında",
@@ -765,5 +762,133 @@
"Count": "Sayım",
"Start Page": "Başlangıç Sayfası",
"Search in OPDS Catalog...": "OPDS Kataloğunda ara...",
"Please log in to use advanced TTS features.": "Gelişmiş TTS özelliklerini kullanmak için lütfen giriş yapın."
"Please log in to use advanced TTS features": "Gelişmiş TTS özelliklerini kullanmak için lütfen giriş yapın",
"Word limit of 30 words exceeded.": "30 kelime sınırı aşıldı.",
"Proofread": "Düzelt",
"Current selection": "Mevcut seçim",
"All occurrences in this book": "Bu kitaptaki tüm geçenler",
"All occurrences in your library": "Kütüphanenizdeki tüm geçenler",
"Selected text:": "Seçili metin:",
"Replace with:": "Şununla değiştir:",
"Enter text...": "Metin girin…",
"Case sensitive:": "Büyük/küçük harfe duyarlı:",
"Scope:": "Kapsam:",
"Selection": "Seçim",
"Library": "Kütüphane",
"Yes": "Evet",
"No": "Hayır",
"Proofread Replacement Rules": "Düzeltme Değiştirme Kuralları",
"Selected Text Rules": "Seçili Metin Kuralları",
"No selected text replacement rules": "Seçili metin için değiştirme kuralı yok",
"Book Specific Rules": "Kitaba Özel Kurallar",
"No book-level replacement rules": "Kitap düzeyinde değiştirme kuralı yok",
"Disable Quick Action": "Hızlı işlemi devre dışı bırak",
"Enable Quick Action on Selection": "Seçimde hızlı işlemi etkinleştir",
"None": "Hiçbiri",
"Annotation Tools": "Not araçları",
"Enable Quick Actions": "Hızlı işlemleri etkinleştir",
"Quick Action": "Hızlı işlem",
"Copy to Notebook": "Deftere kopyala",
"Copy text after selection": "Seçimden sonra metni kopyala",
"Highlight text after selection": "Seçimden sonra metni vurgula",
"Annotate text after selection": "Seçimden sonra metni not al",
"Search text after selection": "Seçimden sonra metni ara",
"Look up text in dictionary after selection": "Seçimden sonra metni sözlükte ara",
"Look up text in Wikipedia after selection": "Seçimden sonra metni Vikipedya'da ara",
"Translate text after selection": "Seçimden sonra metni çevir",
"Read text aloud after selection": "Seçimden sonra metni sesli oku",
"Proofread text after selection": "Seçimden sonra metni düzelt",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} aktif, {{pendingCount}} beklemede",
"{{failedCount}} failed": "{{failedCount}} başarısız",
"Waiting...": "Bekleniyor...",
"Failed": "Başarısız",
"Completed": "Tamamlandı",
"Cancelled": "İptal edildi",
"Retry": "Yeniden dene",
"Active": "Aktif",
"Transfer Queue": "Transfer Kuyruğu",
"Upload All": "Tümünü Yükle",
"Download All": "Tümünü İndir",
"Resume Transfers": "Transferleri Sürdür",
"Pause Transfers": "Transferleri Duraklat",
"Pending": "Beklemede",
"No transfers": "Transfer yok",
"Retry All": "Tümünü Yeniden Dene",
"Clear Completed": "Tamamlananları Temizle",
"Clear Failed": "Başarısızları Temizle",
"Upload queued: {{title}}": "Yükleme sıraya alındı: {{title}}",
"Download queued: {{title}}": "İndirme sıraya alındı: {{title}}",
"Book not found in library": "Kitap kütüphanede bulunamadı",
"Unknown error": "Bilinmeyen hata",
"Please log in to continue": "Devam etmek için giriş yapın",
"Cloud File Transfers": "Bulut Dosya Transferleri",
"Show Search Results": "Arama sonuçlarını göster",
"Search results for '{{term}}'": "'{{term}}' için sonuçlar",
"Close Search": "Aramayı kapat",
"Previous Result": "Önceki sonuç",
"Next Result": "Sonraki sonuç",
"Bookmarks": "Yer İşaretleri",
"Annotations": "Açıklamalar",
"Show Results": "Sonuçları Göster",
"Clear search": "Aramayı temizle",
"Clear search history": "Arama geçmişini temizle",
"Quick action disabled": "Hızlı eylem devre dışı",
"Tap to Toggle Footer": "Altbilgiyi değiştirmek için dokunun",
"Exported successfully": "Başarıyla dışa aktarıldı",
"Book exported successfully.": "Kitap başarıyla dışa aktarıldı.",
"Failed to export the book.": "Kitap dışa aktarılamadı.",
"Export Book": "Kitabı Dışa Aktar",
"Whole word:": "Tam kelime:",
"Error": "Hata",
"Unable to load the article. Try searching directly on {{link}}.": "Makale yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Unable to load the word. Try searching directly on {{link}}.": "Kelime yüklenemiyor. Doğrudan {{link}} üzerinde arama yapmayı deneyin.",
"Date Published": "Yayın tarihi",
"Only for TTS:": "Sadece TTS için:",
"Uploaded": "Yüklendi",
"Downloaded": "İndirildi",
"Deleted": "Silindi",
"Note:": "Not:",
"Time:": "Saat:",
"Format Options": "Format Seçenekleri",
"Export Date": "Dışa Aktarma Tarihi",
"Chapter Titles": "Bölüm Başlıkları",
"Chapter Separator": "Bölüm Ayracı",
"Highlights": "Vurgular",
"Note Date": "Not Tarihi",
"Advanced": "Gelişmiş",
"Hide": "Gizle",
"Show": "Göster",
"Use Custom Template": "Özel Şablon Kullan",
"Export Template": "Dışa Aktarma Şablonu",
"Template Syntax:": "Şablon Sözdizimi:",
"Insert value": "Değer ekle",
"Format date (locale)": "Tarihi biçimlendir (yerel)",
"Format date (custom)": "Tarihi biçimlendir (özel)",
"Conditional": "Koşullu",
"Loop": "Döngü",
"Available Variables:": "Kullanılabilir Değişkenler:",
"Book title": "Kitap başlığı",
"Book author": "Kitap yazarı",
"Export date": "Dışa aktarma tarihi",
"Array of chapters": "Bölümler dizisi",
"Chapter title": "Bölüm başlığı",
"Array of annotations": "Notlar dizisi",
"Highlighted text": "Vurgulanan metin",
"Annotation note": "Not açıklaması",
"Update time": "Güncelleme zamanı",
"Date Format Tokens:": "Tarih Biçimi Belirteçleri:",
"Year (4 digits)": "Yıl (4 basamak)",
"Month (01-12)": "Ay (01-12)",
"Day (01-31)": "Gün (01-31)",
"Hour (00-23)": "Saat (00-23)",
"Minute (00-59)": "Dakika (00-59)",
"Second (00-59)": "Saniye (00-59)",
"Show Source": "Kaynağı Göster",
"No content to preview": "Önizlenecek içerik yok",
"Export": "Dışa Aktar",
"Set Timeout": "Zaman aşımını ayarla",
"Select Voice": "Ses seç",
"Toggle Sticky Bottom TTS Bar": "Sabit TTS çubuğunu değiştir",
"Display what I'm reading on Discord": "Discord'da okuduğumu göster",
"Show on Discord": "Discord'da göster"
}
@@ -173,9 +173,6 @@
"Token": "Токен",
"Your OTP token": "Ваш OTP-токен",
"Verify token": "Перевірити токен",
"Sign in with Google": "Увійти через Google",
"Sign in with Apple": "Увійти через Apple",
"Sign in with GitHub": "Увійти через GitHub",
"Account": "Обліковий запис",
"Failed to delete user. Please try again later.": "Не вдалося видалити користувача. Будь ласка, спробуйте пізніше.",
"Community Support": "Підтримка спільноти",
@@ -349,8 +346,8 @@
"{{count}} pages left in chapter_many": "Залишилось {{count}} сторінок у розділі",
"{{count}} pages left in chapter_other": "Залишилось {{count}} сторінок у розділі",
"Show Remaining Pages": "Показати залишок сторінок",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Керувати підпискою",
"Coming Soon": "Незабаром",
@@ -785,5 +782,133 @@
"Count": "Кількість",
"Start Page": "Початкова сторінка",
"Search in OPDS Catalog...": "Пошук у каталозі OPDS...",
"Please log in to use advanced TTS features.": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS."
"Please log in to use advanced TTS features": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS",
"Word limit of 30 words exceeded.": "Перевищено ліміт у 30 слів.",
"Proofread": "Вичитування",
"Current selection": "Поточне виділення",
"All occurrences in this book": "Усі входження в цій книзі",
"All occurrences in your library": "Усі входження у вашій бібліотеці",
"Selected text:": "Виділений текст:",
"Replace with:": "Замінити на:",
"Enter text...": "Введіть текст…",
"Case sensitive:": "З урахуванням регістру:",
"Scope:": "Область застосування:",
"Selection": "Виділення",
"Library": "Бібліотека",
"Yes": "Так",
"No": "Ні",
"Proofread Replacement Rules": "Правила заміни для вичитування",
"Selected Text Rules": "Правила для виділеного тексту",
"No selected text replacement rules": "Немає правил заміни для виділеного тексту",
"Book Specific Rules": "Правила для конкретної книги",
"No book-level replacement rules": "Немає правил заміни на рівні книги",
"Disable Quick Action": "Вимкнути швидку дію",
"Enable Quick Action on Selection": "Увімкнути швидку дію при виборі",
"None": "Жоден",
"Annotation Tools": "Інструменти анотацій",
"Enable Quick Actions": "Увімкнути швидкі дії",
"Quick Action": "Швидка дія",
"Copy to Notebook": "Скопіювати до блокнота",
"Copy text after selection": "Скопіювати текст після виділення",
"Highlight text after selection": "Виділити текст після виділення",
"Annotate text after selection": "Додати анотацію до тексту після виділення",
"Search text after selection": "Шукати текст після виділення",
"Look up text in dictionary after selection": "Шукати текст у словнику після виділення",
"Look up text in Wikipedia after selection": "Шукати текст у Вікіпедії після виділення",
"Translate text after selection": "Перекласти текст після виділення",
"Read text aloud after selection": "Прочитати текст вголос після виділення",
"Proofread text after selection": "Вичитати текст після виділення",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} активних, {{pendingCount}} очікують",
"{{failedCount}} failed": "{{failedCount}} невдалих",
"Waiting...": "Очікування...",
"Failed": "Помилка",
"Completed": "Завершено",
"Cancelled": "Скасовано",
"Retry": "Повторити",
"Active": "Активні",
"Transfer Queue": "Черга передачі",
"Upload All": "Завантажити все",
"Download All": "Завантажити все",
"Resume Transfers": "Продовжити передачу",
"Pause Transfers": "Призупинити передачу",
"Pending": "Очікують",
"No transfers": "Немає передач",
"Retry All": "Повторити все",
"Clear Completed": "Очистити завершені",
"Clear Failed": "Очистити невдалі",
"Upload queued: {{title}}": "Завантаження в черзі: {{title}}",
"Download queued: {{title}}": "Завантаження в черзі: {{title}}",
"Book not found in library": "Книгу не знайдено в бібліотеці",
"Unknown error": "Невідома помилка",
"Please log in to continue": "Увійдіть, щоб продовжити",
"Cloud File Transfers": "Передача файлів у хмару",
"Show Search Results": "Показати результати",
"Search results for '{{term}}'": "Результати для «{{term}}»",
"Close Search": "Закрити пошук",
"Previous Result": "Попередній результат",
"Next Result": "Наступний результат",
"Bookmarks": "Закладки",
"Annotations": "Анотації",
"Show Results": "Показати результати",
"Clear search": "Очистити пошук",
"Clear search history": "Очистити історію пошуку",
"Quick action disabled": "Швидку дію вимкнено",
"Tap to Toggle Footer": "Торкніться, щоб перемкнути нижній колонтитул",
"Exported successfully": "Успішно експортовано",
"Book exported successfully.": "Книгу успішно експортовано.",
"Failed to export the book.": "Не вдалося експортувати книгу.",
"Export Book": "Експортувати книгу",
"Whole word:": "Ціле слово:",
"Error": "Помилка",
"Unable to load the article. Try searching directly on {{link}}.": "Не вдалося завантажити статтю. Спробуйте шукати безпосередньо на {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Не вдалося завантажити слово. Спробуйте шукати безпосередньо на {{link}}.",
"Date Published": "Дата публікації",
"Only for TTS:": "Тільки для TTS:",
"Uploaded": "Завантажено",
"Downloaded": "Звантажено",
"Deleted": "Видалено",
"Note:": "Примітка:",
"Time:": "Час:",
"Format Options": "Параметри формату",
"Export Date": "Дата експорту",
"Chapter Titles": "Назви розділів",
"Chapter Separator": "Роздільник розділів",
"Highlights": "Виділення",
"Note Date": "Дата примітки",
"Advanced": "Розширені",
"Hide": "Сховати",
"Show": "Показати",
"Use Custom Template": "Використовувати власний шаблон",
"Export Template": "Шаблон експорту",
"Template Syntax:": "Синтаксис шаблону:",
"Insert value": "Вставити значення",
"Format date (locale)": "Форматувати дату (локаль)",
"Format date (custom)": "Форматувати дату (власний)",
"Conditional": "Умовний",
"Loop": "Цикл",
"Available Variables:": "Доступні змінні:",
"Book title": "Назва книги",
"Book author": "Автор книги",
"Export date": "Дата експорту",
"Array of chapters": "Масив розділів",
"Chapter title": "Назва розділу",
"Array of annotations": "Масив приміток",
"Highlighted text": "Виділений текст",
"Annotation note": "Примітка анотації",
"Update time": "Час оновлення",
"Date Format Tokens:": "Токени формату дати:",
"Year (4 digits)": "Рік (4 цифри)",
"Month (01-12)": "Місяць (01-12)",
"Day (01-31)": "День (01-31)",
"Hour (00-23)": "Година (00-23)",
"Minute (00-59)": "Хвилина (00-59)",
"Second (00-59)": "Секунда (00-59)",
"Show Source": "Показати джерело",
"No content to preview": "Немає вмісту для перегляду",
"Export": "Експортувати",
"Set Timeout": "Встановити тайм-аут",
"Select Voice": "Вибрати голос",
"Toggle Sticky Bottom TTS Bar": "Перемкнути закріплену панель TTS",
"Display what I'm reading on Discord": "Показувати книгу в Discord",
"Show on Discord": "Показати в Discord"
}
@@ -173,9 +173,6 @@
"Token": "Mã token",
"Your OTP token": "Mã OTP của bạn",
"Verify token": "Xác minh mã token",
"Sign in with Google": "Đăng nhập với Google",
"Sign in with Apple": "Đăng nhập với Apple",
"Sign in with GitHub": "Đăng nhập với GitHub",
"Account": "Tài khoản",
"Failed to delete user. Please try again later.": "Không thể xóa người dùng. Vui lòng thử lại sau.",
"Community Support": "Hỗ trợ cộng đồng",
@@ -337,8 +334,8 @@
"Reset Settings": "Đặt lại cài đặt",
"{{count}} pages left in chapter_other": "Còn {{count}} trang trong chương",
"Show Remaining Pages": "Hiển thị trang còn lại",
"Source Han Serif CN VF": "Source Han Serif",
"Huiwen-mincho": "Huiwen Mincho",
"Source Han Serif CN": "Source Han Serif",
"Huiwen-MinchoGBK": "Huiwen Mincho",
"KingHwa_OldSong": "KingHwa Song",
"Manage Subscription": "Quản lý đăng ký",
"Coming Soon": "Sắp ra mắt",
@@ -755,5 +752,133 @@
"Count": "Số lượng",
"Start Page": "Trang bắt đầu",
"Search in OPDS Catalog...": "Tìm kiếm trong danh mục OPDS...",
"Please log in to use advanced TTS features.": "Vui lòng đăng nhập để sử dụng các tính năng TTS nâng cao."
"Please log in to use advanced TTS features": "Vui lòng đăng nhập để sử dụng các tính năng TTS nâng cao",
"Word limit of 30 words exceeded.": "Đã vượt quá giới hạn 30 từ.",
"Proofread": "Hiệu đính",
"Current selection": "Vùng chọn hiện tại",
"All occurrences in this book": "Tất cả các lần xuất hiện trong sách này",
"All occurrences in your library": "Tất cả các lần xuất hiện trong thư viện của bạn",
"Selected text:": "Văn bản đã chọn:",
"Replace with:": "Thay thế bằng:",
"Enter text...": "Nhập văn bản…",
"Case sensitive:": "Phân biệt chữ hoa/thường:",
"Scope:": "Phạm vi:",
"Selection": "Vùng chọn",
"Library": "Thư viện",
"Yes": "Có",
"No": "Không",
"Proofread Replacement Rules": "Quy tắc thay thế hiệu đính",
"Selected Text Rules": "Quy tắc cho văn bản đã chọn",
"No selected text replacement rules": "Không có quy tắc thay thế cho văn bản đã chọn",
"Book Specific Rules": "Quy tắc dành riêng cho sách",
"No book-level replacement rules": "Không có quy tắc thay thế ở cấp sách",
"Disable Quick Action": "Vô hiệu hóa Hành động Nhanh",
"Enable Quick Action on Selection": "Bật Hành động Nhanh khi chọn",
"None": "Không có",
"Annotation Tools": "Công cụ chú thích",
"Enable Quick Actions": "Bật Hành động Nhanh",
"Quick Action": "Hành động Nhanh",
"Copy to Notebook": "Sao chép vào Sổ tay",
"Copy text after selection": "Sao chép văn bản sau khi chọn",
"Highlight text after selection": "Đánh dấu văn bản sau khi chọn",
"Annotate text after selection": "Chú thích văn bản sau khi chọn",
"Search text after selection": "Tìm kiếm văn bản sau khi chọn",
"Look up text in dictionary after selection": "Tra cứu văn bản trong từ điển sau khi chọn",
"Look up text in Wikipedia after selection": "Tra cứu văn bản trong Wikipedia sau khi chọn",
"Translate text after selection": "Dịch văn bản sau khi chọn",
"Read text aloud after selection": "Đọc to văn bản sau khi chọn",
"Proofread text after selection": "Hiệu đính văn bản sau khi chọn",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} đang hoạt động, {{pendingCount}} đang chờ",
"{{failedCount}} failed": "{{failedCount}} thất bại",
"Waiting...": "Đang chờ...",
"Failed": "Thất bại",
"Completed": "Hoàn thành",
"Cancelled": "Đã hủy",
"Retry": "Thử lại",
"Active": "Đang hoạt động",
"Transfer Queue": "Hàng đợi truyền",
"Upload All": "Tải lên tất cả",
"Download All": "Tải xuống tất cả",
"Resume Transfers": "Tiếp tục truyền",
"Pause Transfers": "Tạm dừng truyền",
"Pending": "Đang chờ",
"No transfers": "Không có truyền",
"Retry All": "Thử lại tất cả",
"Clear Completed": "Xóa đã hoàn thành",
"Clear Failed": "Xóa thất bại",
"Upload queued: {{title}}": "Tải lên trong hàng đợi: {{title}}",
"Download queued: {{title}}": "Tải xuống trong hàng đợi: {{title}}",
"Book not found in library": "Không tìm thấy sách trong thư viện",
"Unknown error": "Lỗi không xác định",
"Please log in to continue": "Vui lòng đăng nhập để tiếp tục",
"Cloud File Transfers": "Truyền tệp đám mây",
"Show Search Results": "Hiển thị kết quả tìm kiếm",
"Search results for '{{term}}'": "Kết quả cho '{{term}}'",
"Close Search": "Đóng tìm kiếm",
"Previous Result": "Kết quả trước",
"Next Result": "Kết quả tiếp theo",
"Bookmarks": "Dấu trang",
"Annotations": "Chú thích",
"Show Results": "Hiển thị kết quả",
"Clear search": "Xóa tìm kiếm",
"Clear search history": "Xóa lịch sử tìm kiếm",
"Quick action disabled": "Đã tắt thao tác nhanh",
"Tap to Toggle Footer": "Nhấn để bật/tắt chân trang",
"Exported successfully": "Xuất thành công",
"Book exported successfully.": "Sách đã được xuất thành công.",
"Failed to export the book.": "Xuất sách thất bại.",
"Export Book": "Xuất sách",
"Whole word:": "Toàn bộ từ:",
"Error": "Lỗi",
"Unable to load the article. Try searching directly on {{link}}.": "Không thể tải bài viết. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Unable to load the word. Try searching directly on {{link}}.": "Không thể tải từ. Hãy thử tìm kiếm trực tiếp trên {{link}}.",
"Date Published": "Ngày xuất bản",
"Only for TTS:": "Chỉ dành cho TTS:",
"Uploaded": "Đã tải lên",
"Downloaded": "Đã tải xuống",
"Deleted": "Đã xóa",
"Note:": "Ghi chú:",
"Time:": "Thời gian:",
"Format Options": "Tùy chọn định dạng",
"Export Date": "Ngày xuất",
"Chapter Titles": "Tiêu đề chương",
"Chapter Separator": "Dấu phân cách chương",
"Highlights": "Đánh dấu",
"Note Date": "Ngày ghi chú",
"Advanced": "Nâng cao",
"Hide": "Ẩn",
"Show": "Hiển thị",
"Use Custom Template": "Sử dụng mẫu tùy chỉnh",
"Export Template": "Mẫu xuất",
"Template Syntax:": "Cú pháp mẫu:",
"Insert value": "Chèn giá trị",
"Format date (locale)": "Định dạng ngày (địa phương)",
"Format date (custom)": "Định dạng ngày (tùy chỉnh)",
"Conditional": "Điều kiện",
"Loop": "Vòng lặp",
"Available Variables:": "Biến có sẵn:",
"Book title": "Tiêu đề sách",
"Book author": "Tác giả sách",
"Export date": "Ngày xuất",
"Array of chapters": "Danh sách chương",
"Chapter title": "Tiêu đề chương",
"Array of annotations": "Danh sách chú thích",
"Highlighted text": "Văn bản được đánh dấu",
"Annotation note": "Ghi chú chú thích",
"Update time": "Thời gian cập nhật",
"Date Format Tokens:": "Token định dạng ngày:",
"Year (4 digits)": "Năm (4 chữ số)",
"Month (01-12)": "Tháng (01-12)",
"Day (01-31)": "Ngày (01-31)",
"Hour (00-23)": "Giờ (00-23)",
"Minute (00-59)": "Phút (00-59)",
"Second (00-59)": "Giây (00-59)",
"Show Source": "Hiển thị nguồn",
"No content to preview": "Không có nội dung để xem trước",
"Export": "Xuất",
"Set Timeout": "Đặt thời gian chờ",
"Select Voice": "Chọn giọng nói",
"Toggle Sticky Bottom TTS Bar": "Bật/tắt thanh TTS cố định",
"Display what I'm reading on Discord": "Hiển thị sách đang đọc trên Discord",
"Show on Discord": "Hiện trên Discord"
}
@@ -173,9 +173,6 @@
"Token": "令牌",
"Your OTP token": "您的 OTP 令牌",
"Verify token": "验证令牌",
"Sign in with Google": "使用 Google 登录",
"Sign in with Apple": "使用 Apple 登录",
"Sign in with GitHub": "使用 GitHub 登录",
"Account": "账户",
"Failed to delete user. Please try again later.": "删除用户失败。请稍后重试。",
"Community Support": "社区支持",
@@ -337,8 +334,8 @@
"Reset Settings": "重置设置",
"{{count}} pages left in chapter_other": "本章剩余 {{count}} 页",
"Show Remaining Pages": "显示剩余页数",
"Source Han Serif CN VF": "思源宋体",
"Huiwen-mincho": "汇文明朝体",
"Source Han Serif CN": "思源宋体",
"Huiwen-MinchoGBK": "汇文明朝体",
"KingHwa_OldSong": "京华老宋体",
"Manage Subscription": "管理订阅",
"Coming Soon": "即将上线",
@@ -755,5 +752,133 @@
"Count": "数量",
"Start Page": "起始页",
"Search in OPDS Catalog...": "在 OPDS 目录中搜索...",
"Please log in to use advanced TTS features.": "请登录以使用高级 TTS 功能"
"Please log in to use advanced TTS features": "请登录以使用高级 TTS 功能",
"Word limit of 30 words exceeded.": "已超过 30 个词的限制。",
"Proofread": "校对",
"Current selection": "当前选中内容",
"All occurrences in this book": "本书中的所有出现位置",
"All occurrences in your library": "整个书库中的所有出现位置",
"Selected text:": "选中文本:",
"Replace with:": "替换为:",
"Enter text...": "请输入文本…",
"Case sensitive:": "区分大小写:",
"Scope:": "作用范围:",
"Selection": "选中内容",
"Library": "书库",
"Yes": "是",
"No": "否",
"Proofread Replacement Rules": "校对替换规则",
"Selected Text Rules": "选中文本规则",
"No selected text replacement rules": "暂无选中文本的替换规则",
"Book Specific Rules": "书籍范围规则",
"No book-level replacement rules": "暂无书籍级别的替换规则",
"Disable Quick Action": "禁用快速操作",
"Enable Quick Action on Selection": "在选择时启用快速操作",
"None": "无",
"Annotation Tools": "注释工具",
"Enable Quick Actions": "启用快捷操作",
"Quick Action": "快捷操作",
"Copy to Notebook": "复制到笔记本",
"Copy text after selection": "复制选中文本",
"Highlight text after selection": "划线选中文本",
"Annotate text after selection": "注释选中文本",
"Search text after selection": "搜索选中文本",
"Look up text in dictionary after selection": "在字典中查找选中文本",
"Look up text in Wikipedia after selection": "在维基百科中查找选中文本",
"Translate text after selection": "翻译选中文本",
"Read text aloud after selection": "朗读选中文本",
"Proofread text after selection": "校对选中文本",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 进行中,{{pendingCount}} 等待中",
"{{failedCount}} failed": "{{failedCount}} 失败",
"Waiting...": "等待中...",
"Failed": "失败",
"Completed": "已完成",
"Cancelled": "已取消",
"Retry": "重试",
"Active": "进行中",
"Transfer Queue": "传输队列",
"Upload All": "全部上传",
"Download All": "全部下载",
"Resume Transfers": "继续传输",
"Pause Transfers": "暂停传输",
"Pending": "等待中",
"No transfers": "没有传输",
"Retry All": "全部重试",
"Clear Completed": "清除已完成",
"Clear Failed": "清除失败",
"Upload queued: {{title}}": "上传已排队:{{title}}",
"Download queued: {{title}}": "下载已排队:{{title}}",
"Book not found in library": "在书库中找不到书籍",
"Unknown error": "未知错误",
"Please log in to continue": "请登录以继续",
"Cloud File Transfers": "云文件传输",
"Show Search Results": "显示搜索结果",
"Search results for '{{term}}'": "包含\"{{term}}\"的结果",
"Close Search": "关闭搜索",
"Previous Result": "上一个结果",
"Next Result": "下一个结果",
"Bookmarks": "书签",
"Annotations": "注释",
"Show Results": "显示结果",
"Clear search": "清除搜索",
"Clear search history": "清除搜索历史",
"Quick action disabled": "快捷操作已禁用",
"Tap to Toggle Footer": "点击切换页脚",
"Exported successfully": "导出成功",
"Book exported successfully.": "书籍导出成功。",
"Failed to export the book.": "书籍导出失败。",
"Export Book": "导出书籍",
"Whole word:": "全词匹配:",
"Only for TTS:": "仅用于TTS",
"Error": "错误",
"Unable to load the article. Try searching directly on {{link}}.": "无法加载文章。请直接在 {{link}} 上搜索。",
"Unable to load the word. Try searching directly on {{link}}.": "无法加载词汇。请直接在 {{link}} 上搜索。",
"Date Published": "出版日期",
"Uploaded": "已上传",
"Downloaded": "已下载",
"Deleted": "已删除",
"Note:": "笔记:",
"Time:": "时间:",
"Format Options": "格式选项",
"Export Date": "导出日期",
"Chapter Titles": "章节标题",
"Chapter Separator": "章节分隔符",
"Highlights": "高亮",
"Note Date": "笔记日期",
"Advanced": "高级",
"Hide": "隐藏",
"Show": "显示",
"Use Custom Template": "使用自定义模板",
"Export Template": "导出模板",
"Template Syntax:": "模板语法:",
"Insert value": "插入值",
"Format date (locale)": "格式化日期(区域)",
"Format date (custom)": "格式化日期(自定义)",
"Conditional": "条件",
"Loop": "循环",
"Available Variables:": "可用变量:",
"Book title": "书名",
"Book author": "作者",
"Export date": "导出日期",
"Array of chapters": "章节数组",
"Chapter title": "章节标题",
"Array of annotations": "标注数组",
"Highlighted text": "高亮文本",
"Annotation note": "标注笔记",
"Update time": "更新时间",
"Date Format Tokens:": "日期格式标记:",
"Year (4 digits)": "年(4位)",
"Month (01-12)": "月(01-12",
"Day (01-31)": "日(01-31",
"Hour (00-23)": "时(00-23",
"Minute (00-59)": "分(00-59",
"Second (00-59)": "秒(00-59",
"Show Source": "显示源码",
"No content to preview": "无内容可预览",
"Export": "导出",
"Set Timeout": "设置超时",
"Select Voice": "选择语音",
"Toggle Sticky Bottom TTS Bar": "切换固定TTS栏",
"Display what I'm reading on Discord": "在Discord显示阅读状态",
"Show on Discord": "在Discord显示"
}
@@ -173,9 +173,6 @@
"Token": "令牌",
"Your OTP token": "您的 OTP 令牌",
"Verify token": "驗證令牌",
"Sign in with Google": "使用 Google 登入",
"Sign in with Apple": "使用 Apple 登入",
"Sign in with GitHub": "使用 GitHub 登入",
"Account": "帳戶",
"Failed to delete user. Please try again later.": "刪除使用者失敗。請稍後再試。",
"Community Support": "社群支援",
@@ -337,8 +334,8 @@
"Reset Settings": "重置設置",
"{{count}} pages left in chapter_other": "本章剩餘 {{count}} 頁",
"Show Remaining Pages": "顯示剩餘頁數",
"Source Han Serif CN VF": "思源宋體",
"Huiwen-mincho": "匯文明朝體",
"Source Han Serif CN": "思源宋體",
"Huiwen-MinchoGBK": "匯文明朝體",
"KingHwa_OldSong": "京華老宋體",
"Manage Subscription": "管理訂閱",
"Coming Soon": "即將推出",
@@ -755,5 +752,133 @@
"Count": "數量",
"Start Page": "起始頁",
"Search in OPDS Catalog...": "在 OPDS 目錄中搜尋...",
"Please log in to use advanced TTS features.": "請登入以使用進階 TTS 功能"
"Please log in to use advanced TTS features": "請登入以使用進階 TTS 功能",
"Word limit of 30 words exceeded.": "已超過 30 個詞的限制。",
"Proofread": "校對",
"Current selection": "目前選取內容",
"All occurrences in this book": "本書中的所有出現位置",
"All occurrences in your library": "整個書庫中的所有出現位置",
"Selected text:": "已選取文字:",
"Replace with:": "取代為:",
"Enter text...": "請輸入文字…",
"Case sensitive:": "區分大小寫:",
"Scope:": "適用範圍:",
"Selection": "選取內容",
"Library": "書庫",
"Yes": "是",
"No": "否",
"Proofread Replacement Rules": "校對取代規則",
"Selected Text Rules": "已選取文字規則",
"No selected text replacement rules": "沒有已選取文字的取代規則",
"Book Specific Rules": "書籍層級規則",
"No book-level replacement rules": "沒有書籍層級的取代規則",
"Disable Quick Action": "停用快速操作",
"Enable Quick Action on Selection": "選取時啟用快速操作",
"None": "無",
"Annotation Tools": "註解工具",
"Enable Quick Actions": "啟用快速操作",
"Quick Action": "快速操作",
"Copy to Notebook": "複製到筆記本",
"Copy text after selection": "複製選取後的文字",
"Highlight text after selection": "劃線選取後的文字",
"Annotate text after selection": "註解選取後的文字",
"Search text after selection": "搜尋選取後的文字",
"Look up text in dictionary after selection": "在字典中查找選取後的文字",
"Look up text in Wikipedia after selection": "在維基百科中查找選取後的文字",
"Translate text after selection": "翻譯選取後的文字",
"Read text aloud after selection": "朗讀選取後的文字",
"Proofread text after selection": "校對選取後的文字",
"{{activeCount}} active, {{pendingCount}} pending": "{{activeCount}} 進行中,{{pendingCount}} 等待中",
"{{failedCount}} failed": "{{failedCount}} 失敗",
"Waiting...": "等待中...",
"Failed": "失敗",
"Completed": "已完成",
"Cancelled": "已取消",
"Retry": "重試",
"Active": "進行中",
"Transfer Queue": "傳輸佇列",
"Upload All": "全部上傳",
"Download All": "全部下載",
"Resume Transfers": "繼續傳輸",
"Pause Transfers": "暫停傳輸",
"Pending": "等待中",
"No transfers": "沒有傳輸",
"Retry All": "全部重試",
"Clear Completed": "清除已完成",
"Clear Failed": "清除失敗",
"Upload queued: {{title}}": "上傳已排隊:{{title}}",
"Download queued: {{title}}": "下載已排隊:{{title}}",
"Book not found in library": "在書庫中找不到書籍",
"Unknown error": "未知錯誤",
"Please log in to continue": "請登入以繼續",
"Cloud File Transfers": "雲端檔案傳輸",
"Show Search Results": "顯示搜尋結果",
"Search results for '{{term}}'": "包含「{{term}}」的結果",
"Close Search": "關閉搜尋",
"Previous Result": "上一個結果",
"Next Result": "下一個結果",
"Bookmarks": "書籤",
"Annotations": "註解",
"Show Results": "顯示結果",
"Clear search": "清除搜尋",
"Clear search history": "清除搜尋歷史",
"Quick action disabled": "快捷操作已停用",
"Tap to Toggle Footer": "點擊切換頁尾",
"Exported successfully": "匯出成功",
"Book exported successfully.": "書籍匯出成功。",
"Failed to export the book.": "書籍匯出失敗。",
"Export Book": "匯出書籍",
"Whole word:": "全詞匹配:",
"Error": "錯誤",
"Unable to load the article. Try searching directly on {{link}}.": "無法載入文章。請直接在 {{link}} 上搜尋。",
"Unable to load the word. Try searching directly on {{link}}.": "無法載入詞彙。請直接在 {{link}} 上搜尋。",
"Date Published": "出版日期",
"Only for TTS:": "僅用於TTS",
"Uploaded": "已上傳",
"Downloaded": "已下載",
"Deleted": "已刪除",
"Note:": "筆記:",
"Time:": "時間:",
"Format Options": "格式選項",
"Export Date": "匯出日期",
"Chapter Titles": "章節標題",
"Chapter Separator": "章節分隔符",
"Highlights": "標記",
"Note Date": "筆記日期",
"Advanced": "進階",
"Hide": "隱藏",
"Show": "顯示",
"Use Custom Template": "使用自訂模板",
"Export Template": "匯出模板",
"Template Syntax:": "模板語法:",
"Insert value": "插入值",
"Format date (locale)": "格式化日期(區域)",
"Format date (custom)": "格式化日期(自訂)",
"Conditional": "條件",
"Loop": "迴圈",
"Available Variables:": "可用變數:",
"Book title": "書名",
"Book author": "作者",
"Export date": "匯出日期",
"Array of chapters": "章節陣列",
"Chapter title": "章節標題",
"Array of annotations": "標註陣列",
"Highlighted text": "標記文字",
"Annotation note": "標註筆記",
"Update time": "更新時間",
"Date Format Tokens:": "日期格式標記:",
"Year (4 digits)": "年(4位)",
"Month (01-12)": "月(01-12",
"Day (01-31)": "日(01-31",
"Hour (00-23)": "時(00-23",
"Minute (00-59)": "分(00-59",
"Second (00-59)": "秒(00-59",
"Show Source": "顯示原始碼",
"No content to preview": "無內容可預覽",
"Export": "匯出",
"Set Timeout": "設定逾時",
"Select Voice": "選擇語音",
"Toggle Sticky Bottom TTS Bar": "切換固定TTS欄",
"Display what I'm reading on Discord": "在Discord顯示閱讀狀態",
"Show on Discord": "在Discord顯示"
}
+37
View File
@@ -1,5 +1,42 @@
{
"releases": {
"0.9.98": {
"date": "2026-01-21",
"notes": [
"Bookshelf: Added the ability to export original book files directly from the book details dialog",
"Bookshelf: Added a new sorting option to organize books by publish date",
"Bookshelf: Improved performance for large library with faster and more stable imports",
"OPDS: Improved download reliability by correctly detecting filenames for PDF and CBZ files",
"Proofreading: Added support for whole-word replacement to make text corrections more precise",
"Notes: Added the ability to export notes using a customizable template",
"PDF: Added support for panning within PDF documents for easier navigation",
"PDF: Improved performance by pre-rendering the next page for smoother page transitions",
"Text-to-Speech: Added support for word replacement to improve pronunciation during playback",
"Text-to-Speech: Fixed an issue where playback speed settings were not applied correctly on Linux",
"E-ink: Improved reading stability by preventing unwanted scrolling when animations are disabled or on e-ink devices",
"Discord: Added Discord Rich Presence to display your current reading status on Desktop platforms",
"Android: Fixed an issue that could cause occasional crashes during app startup",
"Fonts: Improved web font loading for faster and more stable text rendering"
]
},
"0.9.97": {
"date": "2026-01-10",
"notes": [
"Annotations: Added annotation bubble icons directly on text with instant note popups",
"Annotations: Enabled quick highlighting when selecting text",
"Annotations: Added the ability to edit the text range of existing highlights",
"Annotations: Added an annotation navigation bar for easier browsing",
"OPDS: Fixed an issue where CBZ files could not be downloaded from OPDS catalogs",
"OPDS: Improved compatibility with Komga OPDS servers",
"OPDS: Added support for OPDS servers using self-signed SSL certificates",
"PDF: Fixed an issue where some PDF files failed to open",
"E-ink: Optimized colors and layout for improved e-ink device readability",
"Search: Added search term history for quicker repeated searches",
"Text-to-Speech: Reduced excessive pauses between sentences for smoother playback",
"File Transfer: Introduced a background transfer queue for uploading and downloading books",
"Proofreading: Added a proofread tool that can replace text directly within books"
]
},
"0.9.96": {
"date": "2025-12-19",
"notes": [
+4 -1
View File
@@ -26,6 +26,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
thiserror = "2"
walkdir = "2"
tokio = { version = "1", features = ["fs"] }
tokio-util = { version = "0.7", features = ["codec"] }
futures-util = "0.3"
@@ -35,7 +36,7 @@ reqwest = { version = "0.12", default-features = false, features = [
"json",
"stream",
] }
tauri = { version = "2.5.1", features = [ "protocol-asset" ] }
tauri = { version = "2", features = [ "protocol-asset" ] }
tauri-build = "2"
tauri-plugin-log = "2"
tauri-plugin-fs = "2"
@@ -53,6 +54,7 @@ tauri-plugin-persisted-scope = "2"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
tauri-plugin-websocket = "2"
tauri-plugin-sharekit = "0.3"
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
@@ -70,3 +72,4 @@ tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
discord-rich-presence = "1.0.0"
@@ -152,12 +152,23 @@
}
]
},
{
"identifier": "shell:allow-spawn",
"allow": [
{
"name": "start-readest",
"cmd": "cmd",
"args": ["/C", "start", "", { "validator": "^.*Readest(.*)\\.exe$" }]
}
]
},
"haptics:allow-vibrate",
"haptics:allow-impact-feedback",
"haptics:allow-notification-feedback",
"haptics:allow-selection-feedback",
"native-bridge:default",
"deep-link:default",
"sharekit:default",
"native-tts:default",
"deep-link:default"
"native-bridge:default"
]
}
@@ -3,6 +3,7 @@ package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.webkit.WebView
import android.net.Uri
import android.util.Log
@@ -23,7 +24,7 @@ import com.readest.native_bridge.KeyDownInterceptor
import com.readest.native_bridge.NativeBridgePlugin
class MainActivity : TauriActivity(), KeyDownInterceptor {
private lateinit var wv: WebView
private var wv: WebView? = null
private var interceptVolumeKeysEnabled = false
private var interceptBackKeyEnabled = false
@@ -47,6 +48,48 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
interceptBackKeyEnabled = enabled
}
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
val action = when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> "touchstart"
MotionEvent.ACTION_UP -> "touchend"
MotionEvent.ACTION_CANCEL -> "touchcancel"
MotionEvent.ACTION_POINTER_DOWN -> "touchstart"
MotionEvent.ACTION_POINTER_UP -> "touchend"
else -> null
}
action?.let { eventType ->
val pointerIndex = event.actionIndex
val pointerId = event.getPointerId(pointerIndex)
val x = event.getX(pointerIndex)
val y = event.getY(pointerIndex)
val pressure = event.getPressure(pointerIndex)
wv?.evaluateJavascript(
"""
try {
if (window.onNativeTouch) {
window.onNativeTouch({
type: "$eventType",
pointerId: $pointerId,
x: $x,
y: $y,
pressure: $pressure,
pointerCount: ${event.pointerCount},
timestamp: ${event.eventTime}
});
}
} catch (err) {
console.error('Native touch error:', err);
}
""".trimIndent(),
null
)
}
return super.dispatchTouchEvent(event)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
@@ -60,7 +103,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
}
if (shouldIntercept) {
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""
try { window.onNativeKeyDown("$keyName", $keyCode); } catch (_) {}
""".trimIndent(),
@@ -76,7 +119,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val keyName = keyEventMap[keyCode]
if (keyName != null) {
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""
try {
window.onNativeKeyDown("$keyName", $keyCode)
@@ -135,7 +178,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
Log.d("MainActivity", "Back invoked callback triggered ${interceptBackKeyEnabled}")
if (interceptBackKeyEnabled) {
Log.d("MainActivity", "Back intercepted (OnBackInvokedCallback)")
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""window.onNativeKeyDown("Back", ${KeyEvent.KEYCODE_BACK});""",
null
)
@@ -151,7 +194,7 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
override fun handleOnBackPressed() {
if (interceptBackKeyEnabled) {
Log.d("MainActivity", "Back intercepted (OnBackPressedDispatcher)")
wv.evaluateJavascript(
wv?.evaluateJavascript(
"""window.onNativeKeyDown("Back", ${KeyEvent.KEYCODE_BACK});""",
null
)
@@ -53,7 +53,18 @@
WriteRegStr HKCR ".cbr\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
DetailPrint "Thumbnail provider registered successfully."
Delete "$DESKTOP\Readest.lnk"
Delete "$SMPROGRAMS\Readest\Readest.lnk"
RMDir "$SMPROGRAMS\Readest"
; Create new shortcuts pointing to current installation
CreateShortcut "$DESKTOP\Readest.lnk" "$INSTDIR\Readest.exe"
CreateDirectory "$SMPROGRAMS\Readest"
CreateShortcut "$SMPROGRAMS\Readest\Readest.lnk" "$INSTDIR\Readest.exe"
DetailPrint "Shortcuts updated."
; Refresh shell to apply changes - SHCNE_ASSOCCHANGED
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
!macroend
@@ -581,7 +581,6 @@ class NativeBridgePlugin: Plugin {
let darkMode = args.darkMode
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = !visible
UIApplication.shared.setStatusBarHidden(!visible, with: .none)
let windows = UIApplication.shared.connectedScenes
@@ -4,29 +4,29 @@
import PackageDescription
let package = Package(
name: "tauri-plugin-native-tts",
platforms: [
.macOS(.v10_13),
.iOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-tts",
type: .static,
targets: ["tauri-plugin-native-tts"]),
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-tts",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
name: "tauri-plugin-native-tts",
platforms: [
.macOS(.v10_13),
.iOS(.v14),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "tauri-plugin-native-tts",
type: .static,
targets: ["tauri-plugin-native-tts"])
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "tauri-plugin-native-tts",
dependencies: [
.byName(name: "Tauri")
],
path: "Sources")
]
)
@@ -0,0 +1,122 @@
use std::process::Command;
/// Known e-ink device manufacturers and brands (case-insensitive matching)
const EINK_MANUFACTURERS: &[&str] = &[
"onyx", // BOOX devices
"boox", // BOOX devices (alternate)
"amazon", // Kindle devices
"kobo", // Kobo e-readers
"remarkable", // reMarkable tablets
"pocketbook", // PocketBook e-readers
"boyue", // Boyue/Likebook devices
"likebook", // Likebook devices
"dasung", // Dasung e-ink monitors
"bigme", // Bigme e-readers
"hisense", // Hisense e-ink phones (A5, A7, etc.)
"hanvon", // Hanvon e-readers
"tolino", // Tolino e-readers
"bookeen", // Bookeen e-readers
"supernote", // Supernote devices
"mobiscribe", // Mobiscribe e-readers
"xiaomi", // Xiaomi InkPalm (needs model check)
"meebook", // Meebook e-readers
];
/// Known e-ink device models (for manufacturers that also make non-e-ink devices)
const EINK_MODELS: &[&str] = &[
"kindle",
"a5pro",
"a7cc", // Hisense e-ink models
"a7e",
"a9",
"inkpalm", // Xiaomi InkPalm
"eink",
"e-ink",
"paper",
"note air",
"note2",
"note3",
"note5",
"nova",
"poke",
"leaf",
"page",
"tab ultra",
"max lumi",
];
/// Get Android system property using getprop command
fn get_system_property(prop: &str) -> Option<String> {
Command::new("getprop")
.arg(prop)
.output()
.ok()
.and_then(|output| {
if output.status.success() {
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
} else {
None
}
})
}
/// Check if the current Android device is an e-ink device
pub fn is_eink_device() -> bool {
// Get device manufacturer and model
let manufacturer = get_system_property("ro.product.manufacturer")
.or_else(|| get_system_property("ro.product.brand"))
.unwrap_or_default()
.to_lowercase();
let model = get_system_property("ro.product.model")
.or_else(|| get_system_property("ro.product.device"))
.unwrap_or_default()
.to_lowercase();
let device = get_system_property("ro.product.device")
.unwrap_or_default()
.to_lowercase();
// Check if manufacturer matches known e-ink manufacturers
for eink_manufacturer in EINK_MANUFACTURERS {
if manufacturer.contains(eink_manufacturer) {
// Special case for manufacturers that make both e-ink and non-e-ink devices
if *eink_manufacturer == "hisense" || *eink_manufacturer == "xiaomi" {
// Need to also check the model for these manufacturers
for eink_model in EINK_MODELS {
if model.contains(eink_model) || device.contains(eink_model) {
return true;
}
}
} else {
return true;
}
}
}
// Check if model matches known e-ink models
for eink_model in EINK_MODELS {
if model.contains(eink_model) || device.contains(eink_model) {
return true;
}
}
// Check for e-ink specific system properties
if let Some(eink_support) = get_system_property("ro.eink.support") {
if eink_support == "1" || eink_support.to_lowercase() == "true" {
return true;
}
}
// Check for BOOX specific property
if get_system_property("ro.onyx.devicename").is_some() {
return true;
}
false
}
@@ -0,0 +1,3 @@
pub mod eink;
pub use eink::is_eink_device;
@@ -0,0 +1,96 @@
use std::path::Path;
use tauri::AppHandle;
use tauri_plugin_fs::FsExt;
use walkdir::WalkDir;
#[derive(serde::Serialize)]
pub struct ScannedFile {
pub path: String,
pub size: u64,
}
#[tauri::command]
pub fn read_dir(
app: AppHandle,
path: String,
recursive: bool,
extensions: Vec<String>,
) -> Result<Vec<ScannedFile>, String> {
let scope = app.fs_scope();
let path_buf = std::path::PathBuf::from(&path);
if !scope.is_allowed(&path_buf) {
return Err("Permission denied: Path not in filesystem scope".to_string());
}
let mut files = Vec::new();
let normalized_extensions: Vec<String> =
extensions.iter().map(|ext| ext.to_lowercase()).collect();
if recursive {
for entry_result in WalkDir::new(&path).into_iter() {
match entry_result {
Ok(entry) => {
if entry.file_type().is_file() {
if let Some(scanned_file) =
process_file_entry(entry.path(), &normalized_extensions)
{
files.push(scanned_file);
}
}
}
Err(e) => {
log::warn!("RUST: Skipping file due to error: {}", e);
}
}
}
} else {
match std::fs::read_dir(&path_buf) {
Ok(entries) => {
for entry_result in entries {
match entry_result {
Ok(entry) => {
let path = entry.path();
if path.is_file() {
if let Some(scanned_file) =
process_file_entry(&path, &normalized_extensions)
{
files.push(scanned_file);
}
}
}
Err(e) => {
log::warn!("RUST: Skipping entry due to error: {}", e);
}
}
}
}
Err(e) => {
return Err(format!("Failed to read directory: {}", e));
}
}
}
Ok(files)
}
fn process_file_entry(path: &Path, extensions: &[String]) -> Option<ScannedFile> {
if extensions.is_empty() || extensions.contains(&"*".to_string()) {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
return Some(ScannedFile {
path: path.to_string_lossy().to_string(),
size,
});
} else if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if extensions.contains(&ext_str) {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
return Some(ScannedFile {
path: path.to_string_lossy().to_string(),
size,
});
}
}
None
}
@@ -0,0 +1,182 @@
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use serde::Deserialize;
use std::sync::{Arc, Mutex};
use tauri::State;
const DISCORD_APP_ID: &str = "1462683110612144348";
const MAX_TITLE_LENGTH: usize = 128;
const MAX_AUTHOR_LENGTH: usize = 128;
#[derive(Debug)]
pub struct DiscordRpcClient {
client: Option<DiscordIpcClient>,
current_book_hash: Option<String>,
}
impl DiscordRpcClient {
pub fn new() -> Self {
DiscordRpcClient {
client: None,
current_book_hash: None,
}
}
fn ensure_connected(&mut self) -> Result<(), String> {
if self.client.is_some() {
return Ok(());
}
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
match client.connect() {
Ok(_) => {
log::info!("Successfully connected to Discord");
self.client = Some(client);
Ok(())
}
Err(e) => {
log::warn!("Failed to connect to Discord: {}", e);
Err(format!("Failed to connect to Discord: {}", e))
}
}
}
fn disconnect(&mut self) {
if let Some(mut client) = self.client.take() {
let _ = client.close();
log::debug!("Disconnected from Discord");
}
self.current_book_hash = None;
}
fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BookPresenceData {
book_hash: String,
title: String,
author: Option<String>,
cover_url: Option<String>,
session_start: i64,
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
#[tauri::command]
pub async fn update_book_presence(
state: State<'_, Arc<Mutex<DiscordRpcClient>>>,
presence: BookPresenceData,
) -> Result<(), String> {
let mut client = state
.lock()
.map_err(|e| format!("Mutex lock error: {}", e))?;
if let Err(e) = client.ensure_connected() {
log::debug!("Discord not available: {}", e);
return Ok(());
}
let BookPresenceData {
book_hash,
title,
author,
cover_url,
session_start,
} = presence;
// Truncate title and author to avoid Discord API limits
let truncated_title = DiscordRpcClient::truncate_string(&title, MAX_TITLE_LENGTH);
let state_text = if let Some(ref author_name) = author {
let truncated_author = DiscordRpcClient::truncate_string(author_name, MAX_AUTHOR_LENGTH);
format!("by {}", truncated_author)
} else {
String::new()
};
let mut activity_builder = activity::Activity::new().details(&truncated_title);
if !state_text.is_empty() {
activity_builder = activity_builder.state(&state_text);
}
activity_builder =
activity_builder.timestamps(activity::Timestamps::new().start(session_start / 1000));
let large_image = cover_url
.as_deref()
.filter(|url| url.starts_with("https://"))
.unwrap_or("book_icon");
let assets_builder = activity::Assets::new()
.large_image(large_image)
.large_text(&truncated_title);
activity_builder = activity_builder.assets(assets_builder);
let button = activity::Button::new("Read on Readest", "https://web.readest.com");
activity_builder = activity_builder.buttons(vec![button]);
if let Some(ref mut discord_client) = client.client {
match discord_client.set_activity(activity_builder) {
Ok(_) => {
log::info!("Successfully updated Discord presence");
client.current_book_hash = Some(book_hash);
Ok(())
}
Err(e) => {
log::error!("Failed to update Discord activity: {}", e);
client.disconnect();
Err(format!("Failed to update Discord activity: {}", e))
}
}
} else {
Err("Discord client not initialized".to_string())
}
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
#[tauri::command]
pub async fn clear_book_presence(
state: State<'_, Arc<Mutex<DiscordRpcClient>>>,
) -> Result<(), String> {
let mut client = state
.lock()
.map_err(|e| format!("Mutex lock error: {}", e))?;
if let Some(ref mut discord_client) = client.client {
match discord_client.clear_activity() {
Ok(_) => {
log::info!("Successfully cleared Discord presence");
client.current_book_hash = None;
Ok(())
}
Err(e) => {
log::error!("Failed to clear Discord activity: {}", e);
client.disconnect();
Ok(())
}
}
} else {
log::debug!("No Discord client to clear");
client.current_book_hash = None;
Ok(())
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
#[tauri::command]
pub async fn update_book_presence(_presence: BookPresenceData) -> Result<(), String> {
Ok(()) // No-op on non-desktop platforms
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
#[tauri::command]
pub async fn clear_book_presence() -> Result<(), String> {
Ok(()) // No-op on non-desktop platforms
}
+75 -39
View File
@@ -9,6 +9,9 @@ extern crate objc;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "android")]
mod android;
use tauri::utils::config::BackgroundThrottlingPolicy;
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
@@ -19,6 +22,9 @@ use tauri_plugin_fs::FsExt;
#[cfg(desktop)]
use tauri::{Listener, Url};
mod dir_scanner;
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
mod discord_rpc;
#[cfg(target_os = "macos")]
mod macos;
mod transfer_file;
@@ -38,14 +44,14 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
let asset_protocol_scope = app.asset_protocol_scope();
for file in &files {
if let Err(e) = fs_scope.allow_file(file) {
eprintln!("Failed to allow file in fs_scope: {e}");
log::error!("Failed to allow file in fs_scope: {e}");
} else {
println!("Allowed file in fs_scope: {file:?}");
log::debug!("Allowed file in fs_scope: {file:?}");
}
if let Err(e) = asset_protocol_scope.allow_file(file) {
eprintln!("Failed to allow file in asset_protocol_scope: {e}");
log::error!("Failed to allow file in asset_protocol_scope: {e}");
} else {
println!("Allowed file in asset_protocol_scope: {file:?}");
log::debug!("Allowed file in asset_protocol_scope: {file:?}");
}
}
}
@@ -54,14 +60,14 @@ fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) {
let fs_scope = app.fs_scope();
let asset_protocol_scope = app.asset_protocol_scope();
if let Err(e) = fs_scope.allow_directory(dir, true) {
eprintln!("Failed to allow directory in fs_scope: {e}");
log::error!("Failed to allow directory in fs_scope: {e}");
} else {
println!("Allowed directory in fs_scope: {dir:?}");
log::info!("Allowed directory in fs_scope: {dir:?}");
}
if let Err(e) = asset_protocol_scope.allow_directory(dir, true) {
eprintln!("Failed to allow directory in asset_protocol_scope: {e}");
log::error!("Failed to allow directory in asset_protocol_scope: {e}");
} else {
println!("Allowed directory in asset_protocol_scope: {dir:?}");
log::info!("Allowed directory in asset_protocol_scope: {dir:?}");
}
}
@@ -144,6 +150,11 @@ struct SingleInstancePayload {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.build(),
)
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_oauth::init())
@@ -153,12 +164,17 @@ pub fn run() {
upload_file,
get_environment_variable,
get_executable_dir,
dir_scanner::read_dir,
#[cfg(target_os = "macos")]
macos::safari_auth::auth_with_safari,
#[cfg(target_os = "macos")]
macos::apple_auth::start_apple_sign_in,
#[cfg(target_os = "macos")]
macos::traffic_light::set_traffic_lights,
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
discord_rpc::update_book_presence,
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
discord_rpc::clear_book_presence,
])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_persisted_scope::init())
@@ -167,6 +183,7 @@ pub fn run() {
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_sharekit::init())
.plugin(tauri_plugin_native_bridge::init())
.plugin(tauri_plugin_native_tts::init());
@@ -206,6 +223,13 @@ pub fn run() {
builder
.setup(|#[allow(unused_variables)] app| {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
use std::sync::{Arc, Mutex};
let discord_client = Arc::new(Mutex::new(discord_rpc::DiscordRpcClient::new()));
app.manage(discord_client);
}
#[cfg(desktop)]
{
let files = get_files_from_argv(std::env::args().collect());
@@ -262,42 +286,54 @@ pub fn run() {
let _ = app.deep_link().register_all();
}
if let Err(e) = app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
) {
eprintln!("Failed to initialize tauri_plugin_log: {e}");
// Check for e-ink device on Android before building the window
#[cfg(target_os = "android")]
let is_eink = android::is_eink_device();
#[cfg(not(target_os = "android"))]
let is_eink = false;
let eink_script = if is_eink {
"window.__READEST_IS_EINK = true;"
} else {
""
};
let init_script = format!(
r#"
{eink_script}
window.addEventListener('DOMContentLoaded', function() {{
document.documentElement.classList.add('edge-to-edge');
const isTauriLocal = window.location.protocol === 'tauri:' ||
window.location.protocol === 'about:' ||
window.location.hostname === 'tauri.localhost';
const needsSafeArea = !isTauriLocal;
if (needsSafeArea && !document.getElementById('safe-area-style')) {{
const style = document.createElement('style');
style.id = 'safe-area-style';
style.textContent = `
body {{
padding-top: env(safe-area-inset-top) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
padding-left: env(safe-area-inset-left) !important;
padding-right: env(safe-area-inset-right) !important;
}}
`;
document.head.appendChild(style);
}}
}});
"#,
eink_script = eink_script
);
let app_handle = app.handle().clone();
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.background_throttling(BackgroundThrottlingPolicy::Disabled)
.background_color(tauri::window::Color(50, 49, 48, 255))
.initialization_script(
r#"
window.addEventListener('DOMContentLoaded', function() {
document.documentElement.classList.add('edge-to-edge');
const isTauriLocal = window.location.protocol === 'tauri:' ||
window.location.protocol === 'about:' ||
window.location.hostname === 'tauri.localhost';
const needsSafeArea = !isTauriLocal;
if (needsSafeArea && !document.getElementById('safe-area-style')) {
const style = document.createElement('style');
style.id = 'safe-area-style';
style.textContent = `
body {
padding-top: env(safe-area-inset-top) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
padding-left: env(safe-area-inset-left) !important;
padding-right: env(safe-area-inset-right) !important;
}
`;
document.head.appendChild(style);
}
});
"#,
)
.background_color(if is_eink {
tauri::window::Color(255, 255, 255, 255)
} else {
tauri::window::Color(50, 49, 48, 255)
})
.initialization_script(&init_script)
.on_navigation(move |url| {
if url.scheme() == "alipays" || url.scheme() == "alipay" {
let url_str = url.as_str().to_string();
@@ -38,8 +38,8 @@ pub struct AppleIDAuthorizationResponse {
}
thread_local! {
static APPLE_SIGN_IN_DELEGATE: RefCell<Option<Retained<ASAuthorizationControllerDelegateImpl>>> = RefCell::new(None);
static AUTHORIZATION_CONTROLLER: RefCell<Option<Retained<ASAuthorizationController>>> = RefCell::new(None);
static APPLE_SIGN_IN_DELEGATE: RefCell<Option<Retained<ASAuthorizationControllerDelegateImpl>>> = const { RefCell::new(None) };
static AUTHORIZATION_CONTROLLER: RefCell<Option<Retained<ASAuthorizationController>>> = const { RefCell::new(None) };
}
#[derive(Clone)]
@@ -70,7 +70,7 @@ define_class!(
{
let user_identifier = {
let user = credential.user();
if user.len() > 0 {
if !user.is_empty() {
Some(user.to_string())
} else {
None
@@ -183,7 +183,7 @@ pub fn start_apple_sign_in(app: AppHandle, payload: AppleIDAuthorizationRequest)
let auth_request = &request as &ASAuthorizationRequest;
let controller = ASAuthorizationController::initWithAuthorizationRequests(
ASAuthorizationController::alloc(),
&*NSArray::from_slice(&[auth_request]),
&NSArray::from_slice(&[auth_request]),
);
let delegate = ASAuthorizationControllerDelegateImpl::new(app.clone());
@@ -35,16 +35,11 @@ impl ThreadSafeObjcPointer {
}
}
#[derive(Default)]
pub struct AuthSession {
auth_session: Option<ThreadSafeObjcPointer>,
}
impl Default for AuthSession {
fn default() -> Self {
Self { auth_session: None }
}
}
impl Drop for AuthSession {
fn drop(&mut self) {
if let Some(session) = &self.auth_session {
@@ -108,15 +108,19 @@ pub async fn download_file(
headers: HashMap<String, String>,
body: Option<String>,
single_threaded: Option<bool>,
skip_ssl_verification: Option<bool>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
) -> Result<HashMap<String, String>> {
use futures::stream::{self, StreamExt};
use std::cmp::min;
use tokio::io::AsyncSeekExt;
const PART_SIZE: u64 = 1024 * 1024;
let client = reqwest::Client::new();
let client = reqwest::ClientBuilder::new()
.danger_accept_invalid_certs(skip_ssl_verification.unwrap_or(false))
.danger_accept_invalid_hostnames(skip_ssl_verification.unwrap_or(false))
.build()?;
let force_single = single_threaded.unwrap_or(false);
async fn single_threaded_download(
@@ -126,7 +130,7 @@ pub async fn download_file(
headers: &HashMap<String, String>,
body: &Option<String>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
) -> Result<HashMap<String, String>> {
let mut request = if let Some(body) = body {
client.post(url).body(body.clone())
} else {
@@ -145,6 +149,13 @@ pub async fn download_file(
));
}
let mut resp_headers = HashMap::new();
for (key, value) in response.headers().iter() {
if let Ok(val_str) = value.to_str() {
resp_headers.insert(key.to_string(), val_str.to_string());
}
}
let total = response.content_length().unwrap_or(0);
let mut file = BufWriter::new(File::create(file_path).await?);
let mut stream = response.bytes_stream();
@@ -161,7 +172,7 @@ pub async fn download_file(
}
file.flush().await?;
Ok(())
Ok(resp_headers)
}
if force_single {
@@ -189,6 +200,13 @@ pub async fn download_file(
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let mut resp_headers = HashMap::new();
for (key, value) in range_resp.headers().iter() {
if let Ok(val_str) = value.to_str() {
resp_headers.insert(key.to_string(), val_str.to_string());
}
}
if !accept_ranges || total == 0 {
return single_threaded_download(&client, url, file_path, &headers, &body, on_progress)
.await;
@@ -256,7 +274,7 @@ pub async fn download_file(
})
.await;
Ok(())
Ok(resp_headers)
}
#[command]
+3 -3
View File
@@ -17,8 +17,8 @@
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost http://*:* https://*:* https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com",
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
},
@@ -73,7 +73,7 @@
"iOS": {
"developmentTeam": "J5W48D69VR",
"infoPlist": "./Info-ios.plist",
"minimumSystemVersion": "14.0"
"minimumSystemVersion": "15.0"
},
"fileAssociations": [
{
@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { EnvProvider } from '@/context/EnvContext';
import ProofreadPopup from '@/app/reader/components/annotator/ProofreadPopup';
vi.mock('@/services/environment', async () => {
const actual = await vi.importActual('@/services/environment');
const mockAppService = {
init: vi.fn().mockResolvedValue(undefined),
// Add any other methods from AppService interface
};
return {
...actual,
default: {
getAppService: vi.fn().mockResolvedValue(mockAppService),
},
};
});
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe('ProofreadPopup Component', () => {
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
bookKey: 'test-book',
isVertical: false,
selectedText: 'test word',
selection: {
key: 'test-book',
text: 'test word',
cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)',
range: {
deleteContents: vi.fn(),
insertNode: vi.fn(),
startContainer: document.createTextNode('test word here'),
endContainer: document.createTextNode('test word here'),
startOffset: 5,
endOffset: 9,
} as unknown as Range,
index: 0,
},
position: { point: { x: 100, y: 100 } },
trianglePosition: { point: { x: 100, y: 100 } },
popupWidth: 440,
popupHeight: 200,
onConfirm: mockOnConfirm,
onDismiss: mockOnClose,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('Rendering', () => {
it('should render default replacement scope options', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Current selection')).toBeTruthy();
});
it('should render the replacement text input field', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
expect(input).toBeTruthy();
});
it('should render the case sensitive checkbox', () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Case sensitive:')).toBeTruthy();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
});
it('should render the Apply button', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Apply')).toBeTruthy();
});
it('should display selected text preview', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText(/Selected text:/)).toBeTruthy();
expect(screen.getByText(/"test word"/)).toBeTruthy();
});
});
describe('Case Sensitive Checkbox', () => {
it('should be checked by default (case-sensitive)', () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('should toggle when clicked', async () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
});
describe('Replacement Text Input', () => {
it('should update value when user types', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'new text' } });
expect(input.value).toBe('new text');
});
it('should trim whitespace from replacement text', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: ' trimmed ' } });
const confirmButton = screen.getByText('Apply');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith(
expect.objectContaining({
replacement: 'trimmed',
}),
);
});
});
describe('Scope Selection Handlers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createValidSelection = () => ({
...defaultProps,
selection: {
...defaultProps.selection,
text: 'word',
cfi: 'epubcfi(/6/4[chap01ref]!/4/2/1:0)',
range: {
deleteContents: vi.fn(),
insertNode: vi.fn(),
startContainer: document.createTextNode('test word here'),
endContainer: document.createTextNode('test word here'),
startOffset: 5,
endOffset: 9,
} as unknown as Range,
},
});
it('should call onConfirm with correct scope for "selection"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'selection' }));
});
});
it('should call onConfirm with correct scope for "book"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const scopeSelect = screen.getByRole('combobox');
fireEvent.change(scopeSelect, { target: { value: 'book' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' }));
});
});
it('should call onConfirm with correct scope for "library"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const scopeSelect = screen.getByRole('combobox');
fireEvent.change(scopeSelect, { target: { value: 'library' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' }));
});
});
});
describe('Click Outside Behavior', () => {
it('should not call onClose when clicking inside the menu', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.mouseDown(input);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,564 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import {
ProofreadRulesManager,
setProofreadRulesVisibility,
} from '@/app/reader/components/ProofreadRules';
import BookMenu from '@/app/reader/components/sidebar/BookMenu';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ProofreadRule } from '@/types/book';
// ------------------------------
// NEXT.JS ROUTER MOCK
// ------------------------------
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
toString: () => '',
}),
}));
// ------------------------------
// TRANSLATION MOCK
// ------------------------------
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
vi.mock('@/services/translators/cache', () => ({
initCache: vi.fn(),
loadCacheFromDB: vi.fn(),
pruneCache: vi.fn(),
}));
// ------------------------------
// ENV PROVIDER WRAPPER
// ------------------------------
// mock environment module so EnvProvider uses fake values
vi.mock('@/services/environment', async (importOriginal) => {
const actual = await importOriginal();
return {
...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform)
default: {
...(typeof actual === 'object' &&
actual !== null &&
'default' in actual &&
typeof actual.default === 'object' &&
actual.default !== null
? actual.default
: {}), // keep all real default fields
API_BASE: 'http://localhost',
ENABLE_TRANSLATOR: false,
getAppService: vi.fn().mockResolvedValue(null),
},
};
});
import { EnvProvider } from '@/context/EnvContext';
import { AuthProvider } from '@/context/AuthContext';
function renderWithProviders(ui: React.ReactNode) {
return render(
<EnvProvider>
<AuthProvider>{ui}</AuthProvider>
</EnvProvider>,
);
}
describe('ProofreadRulesManager', () => {
beforeEach(() => {
// Reset stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: {
enabled: false,
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
useSidebarStore.setState({ sideBarBookKey: null });
(useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} });
});
afterEach(() => {
cleanup();
});
it('renders book and library (global) proofreading rules from stores', async () => {
// Arrange: populate stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [
{
id: 'g1',
scope: 'library',
pattern: 'foo',
replacement: 'bar',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
},
{
id: 'g2',
scope: 'library',
pattern: 'hello',
replacement: 'world',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
},
],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ProofreadRulesManager />);
// wait a tick so the component's effect attaches the event listener
await Promise.resolve();
// open via helper which dispatches the custom event
setProofreadRulesVisibility(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Library (global) rules
expect(screen.getByText('foo')).toBeTruthy();
expect(screen.getByText("'bar'")).toBeTruthy();
expect(screen.getByText('hello')).toBeTruthy();
expect(screen.getByText("'world'")).toBeTruthy();
});
it('renders selection rules separately from book/library rules', async () => {
// Arrange: populate stores with a selection rule persisted in book config
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
const selectionRule: ProofreadRule = {
id: 's1',
scope: 'selection',
pattern: 'only-once',
replacement: 'single-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
sectionHref: 'chapter1.html',
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-wide',
replacement: 'book-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Single Instance Rules section
expect(screen.getByText('Selected Text Rules')).toBeTruthy();
expect(screen.getByText('only-once')).toBeTruthy();
expect(screen.getByText("'single-hit'")).toBeTruthy();
// Book section should still show book-wide rule
expect(screen.getByText('book-wide')).toBeTruthy();
expect(screen.getByText("'book-hit'")).toBeTruthy();
});
it('displays correct scope labels for different rule types', async () => {
const selectionRule: ProofreadRule = {
id: 's1',
scope: 'selection',
pattern: 'select-text',
replacement: 'replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
sectionHref: 'chapter1.html',
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-pattern',
replacement: 'book-replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
const libraryRule: ProofreadRule = {
id: 'l1',
scope: 'library',
pattern: 'library-pattern',
replacement: 'library-replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 3,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [libraryRule],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
const selectionRuleElement = screen.getByText('select-text').closest('li');
expect(within(selectionRuleElement!).getByText(/Selection/)).toBeTruthy();
const bookRuleElement = screen.getByText('book-pattern').closest('li');
expect(within(bookRuleElement!).getByText(/Book/)).toBeTruthy();
const libraryRuleElement = screen.getByText('library-pattern').closest('li');
expect(within(libraryRuleElement!).getByText(/Library/)).toBeTruthy();
});
it('shows case sensitivity status for each rule', async () => {
const caseSensitiveRule: ProofreadRule = {
id: 'cs1',
scope: 'book',
pattern: 'case-sensitive',
replacement: 'CS-REPLACED',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
const caseInsensitiveRule: ProofreadRule = {
id: 'ci1',
scope: 'book',
pattern: 'case-insensitive',
replacement: 'CI-REPLACED',
enabled: true,
isRegex: false,
caseSensitive: false,
order: 2,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [caseSensitiveRule, caseInsensitiveRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [caseSensitiveRule, caseInsensitiveRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
const csRuleElement = screen.getByText('case-sensitive').closest('li');
expect(within(csRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(csRuleElement!).getAllByText(/Yes/)).toBeTruthy();
const ciRuleElement = screen.getByText('case-insensitive').closest('li');
expect(within(ciRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(ciRuleElement!).getAllByText(/No/)).toBeTruthy();
});
it('opens when BookMenu item is clicked (integration)', async () => {
// Arrange stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: { viewSettings: { proofreadRules: [] } },
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Render both menu and window
renderWithProviders(
<div>
<BookMenu />
<ProofreadRulesManager />
</div>,
);
// wait a tick so effects attach
await Promise.resolve();
// Click the menu item
const menuItem = screen.getByRole('menuitem', { name: 'Proofread' });
fireEvent.click(menuItem);
// The dialog should open
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Proofread Replacement Rules')).toBeTruthy();
});
it('shows empty state messages when no rules exist', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Check for empty state messages
expect(screen.getByText('No selected text replacement rules')).toBeTruthy();
expect(screen.getByText('No book-level replacement rules')).toBeTruthy();
});
it('merges book and library rules correctly in book section', async () => {
const libraryRule: ProofreadRule = {
id: 'l1',
scope: 'library',
pattern: 'library-wide',
replacement: 'LIBRARY',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-specific',
replacement: 'BOOK',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [libraryRule],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Both library and book rules should appear in the Book Specific Rules section
expect(screen.getByText('library-wide')).toBeTruthy();
expect(screen.getByText('book-specific')).toBeTruthy();
// But they should both be under Book Specific Rules section
const bookSection = screen.getByText('Book Specific Rules').parentElement;
expect(within(bookSection!).getByText('library-wide')).toBeTruthy();
expect(within(bookSection!).getByText('book-specific')).toBeTruthy();
});
});
@@ -1,390 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import ReplacementOptions from '@/app/reader/components/annotator/ReplacementOptions';
describe('ReplacementOptions Component', () => {
// IMPORTANT: ReplacementOptions should ONLY be rendered for EPUB books.
// for non-EPUB formats (PDF, TXT, etc), the button is disabled
// and ReplacementOptions is never rendered/shown to the user.
//
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
isVertical: false,
style: { left: '100px', top: '100px' },
selectedText: 'test word',
onConfirm: mockOnConfirm,
onClose: mockOnClose,
};
// Note: ReplacementOptions component should only be rendered for EPUB books.
// All tests here implicitly test EPUB book scenarios.
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('Rendering', () => {
it('should render all three replacement scope options', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Fix this once')).toBeTruthy();
expect(screen.getByText('Fix in this book')).toBeTruthy();
expect(screen.getByText('Fix in library')).toBeTruthy();
});
it('should render the replacement text input field', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
expect(input).toBeTruthy();
});
it('should render the Case Sensitive checkbox', () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Case Sensitive')).toBeTruthy();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
});
it('should render the Cancel button', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Cancel')).toBeTruthy();
});
it('should display selected text preview', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText(/Selected:/)).toBeTruthy();
expect(screen.getByText(/"test word"/)).toBeTruthy();
});
it('should truncate long selected text in preview', () => {
const longText = 'a'.repeat(100);
render(<ReplacementOptions {...defaultProps} selectedText={longText} />);
// Should show truncated version with ellipsis
const preview = screen.getByText(/Selected:/);
expect(preview.parentElement?.textContent).toContain('...');
});
});
describe('Case Sensitive Checkbox', () => {
it('should be checked by default (case-sensitive)', () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('should toggle when clicked', async () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
it('should pass case sensitivity value to onConfirm when checked', async () => {
render(<ReplacementOptions {...defaultProps} />);
// Enter replacement text
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
// Checkbox is checked by default (case sensitive = true)
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Should show confirmation dialog
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('Yes')).toBeTruthy(); // Case sensitive: Yes
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith({
replacementText: 'replacement',
caseSensitive: true,
scope: 'once',
});
});
it('should pass case sensitivity value to onConfirm when unchecked', async () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
// Enter replacement text
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
// Uncheck the checkbox (default is true, so we click to toggle to false)
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith({
replacementText: 'replacement',
caseSensitive: false,
scope: 'once',
});
});
});
describe('Replacement Text Input', () => {
it('should update value when user types', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'new text' } });
expect(input.value).toBe('new text');
});
it('should disable scope buttons when input is empty', () => {
render(<ReplacementOptions {...defaultProps} />);
const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement;
const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement;
const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement;
expect(fixOnceButton.disabled).toBe(true);
expect(fixInBookButton.disabled).toBe(true);
expect(fixInLibraryButton.disabled).toBe(true);
});
it('should enable scope buttons when input has text', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement;
const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement;
const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement;
expect(fixOnceButton.disabled).toBe(false);
expect(fixInBookButton.disabled).toBe(false);
expect(fixInLibraryButton.disabled).toBe(false);
});
it('should trim whitespace from replacement text', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: ' trimmed ' } });
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith(
expect.objectContaining({
replacementText: 'trimmed',
}),
);
});
});
describe('Scope Button Click Handlers', () => {
it('should show confirmation dialog when "Fix this once" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix this once');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('this instance')).toBeTruthy();
});
it('should show confirmation dialog when "Fix in this book" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix in this book');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('all instances in this book')).toBeTruthy();
});
it('should show confirmation dialog when "Fix in library" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix in library');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('all instances in your library')).toBeTruthy();
});
it('should call onConfirm with correct scope for "once"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'once' }));
});
it('should call onConfirm with correct scope for "book"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix in this book'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' }));
});
it('should call onConfirm with correct scope for "library"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix in library'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' }));
});
});
describe('Confirmation Dialog', () => {
it('should display original text in confirmation', () => {
render(<ReplacementOptions {...defaultProps} selectedText='original' />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('"original"')).toBeTruthy();
});
it('should display replacement text in confirmation', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'new text' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('"new text"')).toBeTruthy();
});
it('should go back to main view when Back is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
fireEvent.click(screen.getByText('Back'));
// Should be back to main view
expect(screen.queryByText('Confirm Replacement')).toBeNull();
expect(screen.getByText('Fix this once')).toBeTruthy();
});
it('should not call onConfirm when Back is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
fireEvent.click(screen.getByText('Back'));
expect(mockOnConfirm).not.toHaveBeenCalled();
});
});
describe('Cancel Button', () => {
it('should call onClose when Cancel is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('Click Outside Behavior', () => {
it('should call onClose when clicking outside the menu', () => {
render(
<div>
<div data-testid='outside'>Outside element</div>
<ReplacementOptions {...defaultProps} />
</div>,
);
const outsideElement = screen.getByTestId('outside');
fireEvent.mouseDown(outsideElement);
expect(mockOnClose).toHaveBeenCalled();
});
it('should not call onClose when clicking inside the menu', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.mouseDown(input);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
});
@@ -1,267 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import {
ReplacementRulesWindow,
setReplacementRulesWindowVisible,
} from '@/app/reader/components/ReplacementRulesWindow';
import BookMenu from '@/app/reader/components/sidebar/BookMenu';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ReplacementRule } from '@/types/book';
// ------------------------------
// NEXT.JS ROUTER MOCK
// ------------------------------
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
toString: () => '',
}),
}));
// ------------------------------
// TRANSLATION MOCK
// ------------------------------
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
vi.mock('@/services/translators/cache', () => ({
initCache: vi.fn(),
loadCacheFromDB: vi.fn(),
pruneCache: vi.fn(),
}));
// ------------------------------
// ENV PROVIDER WRAPPER
// ------------------------------
// mock environment module so EnvProvider uses fake values
vi.mock('@/services/environment', async (importOriginal) => {
const actual = await importOriginal();
return {
...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform)
default: {
...(typeof actual === 'object' &&
actual !== null &&
'default' in actual &&
typeof actual.default === 'object' &&
actual.default !== null
? actual.default
: {}), // keep all real default fields
API_BASE: 'http://localhost',
ENABLE_TRANSLATOR: false,
getAppService: vi.fn().mockResolvedValue(null),
},
};
});
import { EnvProvider } from '@/context/EnvContext';
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe.skip('ReplacementRulesWindow', () => {
beforeEach(() => {
// Reset stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: {
enabled: false,
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
useSidebarStore.setState({ sideBarBookKey: null });
(useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} });
});
afterEach(() => {
cleanup();
});
it('renders book and global replacement rules from stores', async () => {
// Arrange: populate stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
replacementRules: [
{
id: 'g1',
pattern: 'foo',
replacement: 'bar',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
},
{
id: 'b1',
pattern: 'hello',
replacement: 'world',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
},
],
kosync: { enabled: false },
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
replacementRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ReplacementRulesWindow />);
// wait a tick so the component's effect attaches the event listener
await Promise.resolve();
// open via helper which dispatches the custom event
setReplacementRulesWindowVisible(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Global rules
expect(screen.getByText('foo')).toBeTruthy();
expect(screen.getByText('bar')).toBeTruthy();
expect(screen.getByText('hello')).toBeTruthy();
expect(screen.getByText('world')).toBeTruthy();
});
it('renders single-instance rules separately from book/global rules', async () => {
// Arrange: populate stores with a single rule persisted in book config
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: { enabled: false },
},
});
const singleRule: ReplacementRule = {
id: 's1',
pattern: 'only-once',
replacement: 'single-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
singleInstance: true,
};
const bookRule: ReplacementRule = {
id: 'b1',
pattern: 'book-wide',
replacement: 'book-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
replacementRules: [singleRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
replacementRules: [singleRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ReplacementRulesWindow />);
await Promise.resolve();
setReplacementRulesWindowVisible(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Single-instance section
expect(screen.getByText('Single Instance Rules')).toBeTruthy();
expect(screen.getByText('only-once')).toBeTruthy();
expect(screen.getByText('single-hit')).toBeTruthy();
// Book section should still show book-wide rule
expect(screen.getByText('book-wide')).toBeTruthy();
expect(screen.getByText('book-hit')).toBeTruthy();
});
it('opens when BookMenu item is clicked (integration)', async () => {
// Arrange stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: { viewSettings: { replacementRules: [] } },
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Render both menu and window
renderWithProviders(
<div>
<BookMenu />
<ReplacementRulesWindow />
</div>,
);
// wait a tick so effects attach
await Promise.resolve();
// Click the menu item
const menuItem = screen.getByRole('menuitem', { name: 'Replacement Rules' });
fireEvent.click(menuItem);
// The dialog should open
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Replacement Rules')).toBeTruthy();
});
});
@@ -0,0 +1,378 @@
import { describe, it, expect } from 'vitest';
import { makeSafeFilename } from '../../utils/misc';
describe('makeSafeFilename', () => {
describe('Basic sanitization', () => {
it('should replace unsafe characters with underscore', () => {
expect(makeSafeFilename('file<name>.txt')).toBe('file_name_.txt');
expect(makeSafeFilename('file>name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file:name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file"name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file/name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file\\name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file|name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file?name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file*name.txt')).toBe('file_name.txt');
});
it('should replace multiple unsafe characters', () => {
expect(makeSafeFilename('file<>:"|?*.txt')).toBe('file_______.txt');
});
it('should use custom replacement character', () => {
expect(makeSafeFilename('file<name>.txt', '-')).toBe('file-name-.txt');
expect(makeSafeFilename('file:name.txt', '')).toBe('filename.txt');
});
it('should handle control characters', () => {
expect(makeSafeFilename('file\x00name.txt')).toBe('file_name.txt');
expect(makeSafeFilename('file\x1Fname.txt')).toBe('file_name.txt');
});
it('should trim whitespace from result', () => {
expect(makeSafeFilename(' filename.txt ')).toBe('filename.txt');
expect(makeSafeFilename('filename.txt ')).toBe('filename.txt');
expect(makeSafeFilename(' filename.txt')).toBe('filename.txt');
});
});
describe('Reserved filenames (Windows)', () => {
it('should handle reserved names case-insensitively', () => {
expect(makeSafeFilename('CON')).toBe('CON_');
expect(makeSafeFilename('con')).toBe('con_');
expect(makeSafeFilename('Con')).toBe('Con_');
expect(makeSafeFilename('PRN')).toBe('PRN_');
expect(makeSafeFilename('AUX')).toBe('AUX_');
expect(makeSafeFilename('NUL')).toBe('NUL_');
});
it('should handle reserved names with port numbers', () => {
expect(makeSafeFilename('COM1')).toBe('COM1_');
expect(makeSafeFilename('COM9')).toBe('COM9_');
expect(makeSafeFilename('LPT1')).toBe('LPT1_');
expect(makeSafeFilename('LPT9')).toBe('LPT9_');
});
it('should not affect reserved names with extensions', () => {
// Reserved names only apply to the base name without extension
const result = makeSafeFilename('CON.txt');
expect(result).toBe('CON.txt'); // This might be CON_.txt depending on implementation
});
it('should not affect similar but non-reserved names', () => {
expect(makeSafeFilename('CONFIG')).toBe('CONFIG');
expect(makeSafeFilename('CONSOLE')).toBe('CONSOLE');
expect(makeSafeFilename('PRINTER')).toBe('PRINTER');
});
});
describe('Multi-byte UTF-8 characters', () => {
it('should preserve single multi-byte characters', () => {
expect(makeSafeFilename('文件.txt')).toBe('文件.txt');
expect(makeSafeFilename('ファイル.txt')).toBe('ファイル.txt');
expect(makeSafeFilename('파일.txt')).toBe('파일.txt');
});
it('should preserve emoji characters', () => {
expect(makeSafeFilename('📚 Book.txt')).toBe('📚 Book.txt');
expect(makeSafeFilename('🎉🎊🎈.txt')).toBe('🎉🎊🎈.txt');
expect(makeSafeFilename('Test 😀.txt')).toBe('Test 😀.txt');
});
it('should handle mixed ASCII and multi-byte characters', () => {
expect(makeSafeFilename('Book-书籍-本.txt')).toBe('Book-书籍-本.txt');
expect(makeSafeFilename('Test_测试_テスト.txt')).toBe('Test_测试_テスト.txt');
});
it('should preserve complex emoji (with modifiers and ZWJ)', () => {
expect(makeSafeFilename('👨‍👩‍👧‍👦.txt')).toBe('👨‍👩‍👧‍👦.txt'); // Family emoji with ZWJ
expect(makeSafeFilename('👍🏽.txt')).toBe('👍🏽.txt'); // Thumbs up with skin tone modifier
});
it('should handle combining characters', () => {
expect(makeSafeFilename('café.txt')).toBe('café.txt'); // é is composed
expect(makeSafeFilename('naïve.txt')).toBe('naïve.txt');
});
});
describe('Byte length truncation (250 bytes max)', () => {
it('should not truncate short filenames', () => {
const shortName = 'short.txt';
expect(makeSafeFilename(shortName)).toBe(shortName);
});
it('should truncate long ASCII filenames', () => {
const longName = 'a'.repeat(260) + '.txt';
const result = makeSafeFilename(longName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
});
it('should truncate at exactly 250 bytes', () => {
const longName = 'x'.repeat(255); // More than 250 bytes
const result = makeSafeFilename(longName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBe(250);
});
it('should preserve valid UTF-8 when truncating multi-byte characters', () => {
// Chinese characters: each is 3 bytes in UTF-8
const chineseChars = '书'.repeat(100); // 300 bytes total
const result = makeSafeFilename(chineseChars);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
// Verify no broken UTF-8 by encoding and decoding
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain(''); // No replacement characters
});
it('should handle Japanese characters when truncating', () => {
// Japanese hiragana: each is 3 bytes in UTF-8
const japaneseChars = 'あ'.repeat(100); // 300 bytes
const result = makeSafeFilename(japaneseChars);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle emoji when truncating', () => {
// Most emoji are 4 bytes in UTF-8
const emojiString = '😀'.repeat(70); // 280 bytes
const result = makeSafeFilename(emojiString);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle mixed-width characters when truncating', () => {
// Mix of 1-byte (ASCII), 2-byte (Latin extended), 3-byte (CJK), 4-byte (emoji)
const mixedString = 'Test测试тест😀'.repeat(20); // Over 250 bytes
const result = makeSafeFilename(mixedString);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle Korean characters when truncating', () => {
// Korean characters: each is 3 bytes in UTF-8
const koreanChars = '가'.repeat(100); // 300 bytes
const result = makeSafeFilename(koreanChars);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
});
describe('Edge cases', () => {
it('should handle empty string', () => {
expect(makeSafeFilename('')).toBe('');
});
it('should handle string with only unsafe characters', () => {
expect(makeSafeFilename('<>:"|?*')).toBe('_______');
});
it('should handle string that becomes empty after sanitization and trimming', () => {
const result = makeSafeFilename(' ');
expect(result).toBe('');
});
it('should handle very long extension', () => {
const longExt = '.txt'.repeat(50);
const filename = 'file' + longExt;
const result = makeSafeFilename(filename);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
});
it('should handle filename with only whitespace', () => {
expect(makeSafeFilename(' ')).toBe('');
});
it('should handle complex real-world Chinese book title', () => {
const longTitle =
'这是一个非常长的中文书名用来测试文件名处理功能这个标题包含了很多汉字字符'.repeat(3) +
'.epub';
const result = makeSafeFilename(longTitle);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
// Verify UTF-8 validity
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle complex real-world Chinese book title', () => {
const longTitle =
'榎宮祐 - NO GAME NO LIFE 遊戲人生 02 遊戲玩家兄妹似乎盯上獸耳娘的國家了'.repeat(3);
const result = makeSafeFilename(longTitle);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
// Verify UTF-8 validity
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
it('should handle complex real-world Chinese book titles with varying zero padding', () => {
const testCases = Array.from({ length: 31 }, (_, i) => {
const padding = '0'.repeat(i) + '2';
return `榎宮祐 - ${'NO GAME NO LIFE'.repeat(12)} 遊戲人生 ${padding} 遊戲玩家兄妹似乎盯上獸耳娘的國家了`;
});
for (const longTitle of testCases) {
const result = makeSafeFilename(longTitle);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
}
});
it('should handle right-to-left text (Arabic)', () => {
const arabicName = 'كتاب'.repeat(50) + '.pdf'; // Over 250 bytes
const result = makeSafeFilename(arabicName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
});
it('should handle Cyrillic characters', () => {
const cyrillicName = 'Книга'.repeat(60) + '.txt'; // Over 250 bytes
const result = makeSafeFilename(cyrillicName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
});
it('should handle Thai characters', () => {
const thaiName = 'หนังสือ'.repeat(50) + '.pdf'; // Over 250 bytes
const result = makeSafeFilename(thaiName);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
});
});
describe('Combined sanitization and truncation', () => {
it('should sanitize and truncate in correct order', () => {
const unsafeLongName = '<'.repeat(260) + '.txt';
const result = makeSafeFilename(unsafeLongName);
// Should replace < with _, then truncate
expect(result).not.toContain('<');
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
});
it('should handle reserved name that needs truncation', () => {
// Edge case: reserved name with very long content
const longReservedLike = 'CON' + 'x'.repeat(260);
const result = makeSafeFilename(longReservedLike);
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
});
it('should sanitize, handle reserved names, and truncate multi-byte characters', () => {
const complexName = 'CON:文件'.repeat(50) + '😀'.repeat(20);
const result = makeSafeFilename(complexName);
// Should not contain unsafe characters
expect(result).not.toContain(':');
// Should be within byte limit
const byteLength = new TextEncoder().encode(result).length;
expect(byteLength).toBeLessThanOrEqual(250);
// Should be valid UTF-8
const encoded = new TextEncoder().encode(result);
const decoded = new TextDecoder().decode(encoded);
expect(decoded).toBe(result);
expect(decoded).not.toContain('');
});
});
describe('Real-world scenarios', () => {
it('should handle typical book title with author', () => {
const bookTitle = 'The Great Gatsby - F. Scott Fitzgerald.epub';
expect(makeSafeFilename(bookTitle)).toBe('The Great Gatsby - F. Scott Fitzgerald.epub');
});
it('should handle Chinese book with long title', () => {
const chineseBook = '红楼梦:中国古典文学四大名著之一(全120回完整版).epub';
const result = makeSafeFilename(chineseBook);
expect(result).toBe(chineseBook); // Should fit within 250 bytes
});
it('should handle Japanese light novel title', () => {
const japaneseTitle = 'ソードアート・オンライン:アリシゼーション編.epub';
expect(makeSafeFilename(japaneseTitle)).toBe(japaneseTitle);
});
it('should handle filename with unsafe characters and emoji', () => {
const unsafeEmoji = '📚 Book: "Title" <Part 1>.epub';
const result = makeSafeFilename(unsafeEmoji);
expect(result).toBe('📚 Book_ _Title_ _Part 1_.epub');
});
it('should handle Windows-style path in filename', () => {
const windowsPath = 'C:\\Users\\Documents\\book.pdf';
const result = makeSafeFilename(windowsPath);
expect(result).toBe('C__Users_Documents_book.pdf');
});
it('should handle URL in filename', () => {
const url = 'https://example.com/book.pdf';
const result = makeSafeFilename(url);
expect(result).toBe('https___example.com_book.pdf');
});
});
});
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@ describe.concurrent('suite', () => {
beforeAll(async () => {
const wasmPath = join(process.cwd(), 'public/vendor/simplecc/simplecc_wasm_bg.wasm');
const wasmBuffer = await readFile(wasmPath);
await init(wasmBuffer);
await init({ module_or_path: wasmBuffer });
});
test('basic s2t and t2s', () => {
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getWordCount, isWordLimitExceeded } from '../../utils/wordLimit';
import { getWordCount } from '../../utils/word';
describe('Word Limit Feature', () => {
describe('getWordCount', () => {
@@ -49,49 +49,6 @@ describe('Word Limit Feature', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(getWordCount(thirtyOneWords)).toBe(31);
});
});
describe('isWordLimitExceeded', () => {
it('should return false for text under 30 words', () => {
expect(isWordLimitExceeded('hello world')).toBe(false);
expect(isWordLimitExceeded('a')).toBe(false);
});
it('should return false for exactly 30 words', () => {
const thirtyWords = Array(30).fill('word').join(' ');
expect(isWordLimitExceeded(thirtyWords)).toBe(false);
});
it('should return true for 31 words', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(isWordLimitExceeded(thirtyOneWords)).toBe(true);
});
it('should return true for many words', () => {
const manyWords = Array(100).fill('word').join(' ');
expect(isWordLimitExceeded(manyWords)).toBe(true);
});
it('should return false for empty string', () => {
expect(isWordLimitExceeded('')).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle very long words', () => {
const longWord = 'a'.repeat(1000);
expect(getWordCount(longWord)).toBe(1);
expect(isWordLimitExceeded(longWord)).toBe(false);
});
it('should handle mixed content with newlines', () => {
const text = `Line one with words.
Line two with more words.
Line three.`;
// "Line one with words." = 4, "Line two with more words." = 5, "Line three." = 2 = 11 total
expect(getWordCount(text)).toBe(11);
expect(isWordLimitExceeded(text)).toBe(false);
});
it('should handle unicode characters', () => {
expect(getWordCount('你好 世界')).toBe(2);
@@ -104,6 +61,21 @@ describe('Word Limit Feature', () => {
expect(getWordCount('chapter 1 section 2')).toBe(4);
});
});
describe('Edge cases', () => {
it('should handle very long words', () => {
const longWord = 'a'.repeat(1000);
expect(getWordCount(longWord)).toBe(1);
});
it('should handle mixed content with newlines', () => {
const text = `Line one with words.
Line two with more words.
Line three.`;
// "Line one with words." = 4, "Line two with more words." = 5, "Line three." = 2 = 11 total
expect(getWordCount(text)).toBe(11);
});
});
});
describe('Case Sensitivity Matching', () => {
@@ -103,6 +103,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
return new NextResponse(null, {
status: 200,
headers: {
...Object.fromEntries(response.headers.entries()),
'Content-Type': contentType,
'Content-Length': contentLength || '',
'Cache-Control': 'public, max-age=300',
@@ -113,12 +114,13 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
});
}
if (stream === 'true' || (contentLength && parseInt(contentLength) > 1024 * 1024)) {
if (stream === 'true' && contentLength && parseInt(contentLength) > 1024 * 1024) {
console.log(`[OPDS Proxy] Streaming: ${url}`);
return new NextResponse(response.body, {
status: 200,
headers: {
...Object.fromEntries(response.headers.entries()),
'Content-Type': contentType,
'X-Content-Length': contentLength || '',
'Cache-Control': 'public, max-age=300',
@@ -129,13 +131,16 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
},
});
} else {
const data = await response.text();
console.log(`[OPDS Proxy] Success: ${url} (${data.length} bytes)`);
const buf = await response.arrayBuffer();
const length = buf.byteLength;
console.log(`[OPDS Proxy] Success: ${url} (${length} bytes)`);
return new NextResponse(data, {
return new NextResponse(buf, {
status: 200,
headers: {
...Object.fromEntries(response.headers.entries()),
'Content-Type': contentType,
'Content-Length': length.toString(),
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+12 -7
View File
@@ -6,8 +6,7 @@ import { useRouter } from 'next/navigation';
import { Auth } from '@supabase/auth-ui-react';
import { ThemeSupa } from '@supabase/auth-ui-shared';
import { FcGoogle } from 'react-icons/fc';
import { FaApple } from 'react-icons/fa';
import { FaGithub } from 'react-icons/fa';
import { FaApple, FaGithub, FaDiscord } from 'react-icons/fa';
import { IoArrowBack } from 'react-icons/io5';
import { useAuth } from '@/context/AuthContext';
@@ -29,7 +28,7 @@ import { getAppleIdAuth, Scope } from './utils/appleIdAuth';
import { authWithCustomTab, authWithSafari } from './utils/nativeAuth';
import WindowButtons from '@/components/WindowButtons';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github';
type OAuthProvider = 'google' | 'apple' | 'azure' | 'github' | 'discord';
interface SingleInstancePayload {
args: string[];
@@ -390,7 +389,7 @@ export default function AuthPage() {
provider='google'
handleSignIn={tauriSignIn}
Icon={FcGoogle}
label={_('Sign in with Google')}
label={_('Sign in with {{provider}}', { provider: 'Google' })}
/>
<ProviderLogin
provider='apple'
@@ -398,13 +397,19 @@ export default function AuthPage() {
appService?.isIOSApp || USE_APPLE_SIGN_IN ? tauriSignInApple : tauriSignIn
}
Icon={FaApple}
label={_('Sign in with Apple')}
label={_('Sign in with {{provider}}', { provider: 'Apple' })}
/>
<ProviderLogin
provider='github'
handleSignIn={tauriSignIn}
Icon={FaGithub}
label={_('Sign in with GitHub')}
label={_('Sign in with {{provider}}', { provider: 'GitHub' })}
/>
<ProviderLogin
provider='discord'
handleSignIn={tauriSignIn}
Icon={FaDiscord}
label={_('Sign in with {{provider}}', { provider: 'Discord' })}
/>
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
<div className='w-full'>
@@ -434,7 +439,7 @@ export default function AuthPage() {
appearance={{ theme: ThemeSupa }}
theme={isDarkMode ? 'dark' : 'light'}
magicLink={true}
providers={['google', 'apple', 'github']}
providers={['google', 'apple', 'github', 'discord']}
redirectTo={getWebRedirectTo()}
localization={getAuthLocalization()}
/>
+4 -1
View File
@@ -41,7 +41,10 @@ export const viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<html
lang='en'
className={process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri' ? 'edge-to-edge' : ''}
>
<head>
<title>{title}</title>
<meta
@@ -14,7 +14,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { navigateToLogin } from '@/utils/nav';
import { formatAuthors } from '@/utils/book';
import { formatAuthors, formatDescription } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from '@/components/BookCover';
@@ -26,7 +26,7 @@ interface BookItemProps {
bookSelected: boolean;
transferProgress: number | null;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
showBookDetailsModal: (book: Book) => void;
}
@@ -61,13 +61,19 @@ const BookItem: React.FC<BookItemProps> = ({
>
<div
className={clsx(
'relative flex aspect-[28/41] justify-center',
'bookitem-main relative flex aspect-[28/41] justify-center rounded',
coverFit === 'crop' && 'overflow-hidden shadow-md',
mode === 'grid' && 'items-end',
mode === 'list' && 'min-w-20 items-center',
)}
>
<BookCover mode={mode} book={book} coverFit={coverFit} showSpine={false} />
<BookCover
mode={mode}
book={book}
coverFit={coverFit}
showSpine={false}
imageClassName='rounded shadow-md'
/>
{bookSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
@@ -85,7 +91,7 @@ const BookItem: React.FC<BookItemProps> = ({
className={clsx(
'flex w-full flex-col p-0',
mode === 'grid' && 'pt-2',
mode === 'list' && 'py-2',
mode === 'list' && 'gap-2 py-0',
)}
>
<div className={clsx('min-w-0 flex-1', mode === 'list' && 'flex flex-col gap-2')}>
@@ -104,6 +110,11 @@ const BookItem: React.FC<BookItemProps> = ({
</p>
)}
</div>
{mode === 'list' && (
<h4 className='text-neutral-content line-clamp-1 text-sm'>
{formatDescription(book.metadata?.description)}
</h4>
)}
<div
className={clsx('flex items-center', book.progress ? 'justify-between' : 'justify-end')}
style={{
@@ -154,7 +165,7 @@ const BookItem: React.FC<BookItemProps> = ({
if (!book.uploadedAt) {
handleBookUpload(book);
} else if (!book.downloadedAt) {
handleBookDownload(book);
handleBookDownload(book, { queued: true });
}
}}
>
@@ -30,7 +30,10 @@ interface BookshelfProps {
isSelectAll: boolean;
isSelectNone: boolean;
handleImportBooks: () => void;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDownload: (
book: Book,
options?: { redownload?: boolean; queued?: boolean },
) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
@@ -74,6 +77,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const abortDeletionRef = useRef(false);
const isImportingBook = useRef(false);
const iconSize15 = useResponsiveSize(15);
const autofocusRef = useAutoFocus<HTMLDivElement>();
@@ -216,9 +220,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const confirmDelete = async () => {
const books = getBooksToDelete();
const concurrency = 4;
const concurrency = 20;
for (let i = 0; i < books.length; i += concurrency) {
if (abortDeletionRef.current) {
abortDeletionRef.current = false;
break;
}
const batch = books.slice(i, i + concurrency);
await Promise.all(batch.map((book) => handleBookDelete(book, false)));
}
@@ -330,7 +338,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<button
aria-label={_('Import Books')}
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50',
'bookitem-main bg-base-100 hover:bg-base-300/50',
'flex items-center justify-center',
'aspect-[28/41] w-full',
)}
@@ -378,7 +386,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
)}
{showDeleteAlert && (
<div
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
className={clsx('delete-alert fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
@@ -389,6 +397,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
count: getBooksToDelete().length,
})}
onCancel={() => {
abortDeletionRef.current = true;
setShowDeleteAlert(false);
setShowSelectModeActions(true);
}}
@@ -82,7 +82,10 @@ interface BookshelfItemProps {
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleGroupBooks: () => void;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDownload: (
book: Book,
options?: { redownload?: boolean; queued?: boolean },
) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
@@ -112,9 +115,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const { updateBook } = useLibraryStore();
const showBookDetailsModal = useCallback(async (book: Book) => {
if (await makeBookAvailable(book)) {
handleShowDetailsBook(book);
}
handleShowDetailsBook(book);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -131,7 +132,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
let available = false;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
available = await handleBookDownload(book);
available = await handleBookDownload(book, { queued: false });
await updateBook(envConfig, book);
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
@@ -216,7 +217,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const downloadBookMenuItem = await MenuItem.new({
text: _('Download Book'),
action: async () => {
handleBookDownload(book);
handleBookDownload(book, { queued: true });
},
});
const uploadBookMenuItem = await MenuItem.new({
@@ -360,7 +361,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-2 sm:px-4 sm:py-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
appService?.isMobileApp && 'no-context-menu',
pressing && mode === 'grid' ? 'scale-95' : 'scale-100',
pressing && mode === 'grid' ? 'not-eink:scale-95' : 'scale-100',
)}
role='button'
tabIndex={0}
@@ -99,7 +99,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
<div className={clsx('group-item', appService?.hasContextMenu ? 'cursor-pointer' : '')}>
<div
className={clsx(
'relative flex overflow-hidden',
'groupitem-main relative flex overflow-hidden rounded',
mode === 'grid' && 'bg-base-100 aspect-[28/41] items-center justify-center shadow-md',
mode === 'list' && 'items-center justify-start gap-4 py-2',
)}
@@ -139,7 +139,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
mode === 'list' && 'flex-shrink-0',
)}
>
<BookCover book={book} isPreview />
<BookCover book={book} isPreview imageClassName='rounded-[2px]' />
</div>
))}
</div>
@@ -102,6 +102,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
setShowInput(true);
setIsRenaming(false);
setOriginalGroupName(null);
setSelectedGroup(null);
};
const handleRenameGroup = () => {
@@ -12,7 +12,6 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { debounce } from '@/utils/debounce';
@@ -48,13 +47,11 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { currentBookshelf } = useLibraryStore();
const { isTrafficLightVisible } = useTrafficLight();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const viewSettings = settings.globalViewSettings;
const headerRef = useRef<HTMLDivElement>(null);
const iconSize18 = useResponsiveSize(18);
const { safeAreaInsets: insets } = useThemeStore();
@@ -110,7 +107,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
<div className='relative flex h-9 w-full items-center sm:h-7'>
<span className='text-base-content/50 absolute left-3'>
<span className='text-base-content/50 absolute ps-3'>
<FaSearch className='h-4 w-4' />
</span>
<input
@@ -126,10 +123,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge h-9 w-full pl-10 pr-[30%] sm:h-7',
viewSettings?.isEink
? 'border-1 border-base-content focus:border-base-content'
: 'bg-base-300/45 border-none',
'search-input input h-9 w-full rounded-full pr-[30%] ps-10 sm:h-7',
'bg-base-300/45 border-0',
'font-sans text-sm font-light',
'placeholder:text-base-content/50 truncate',
'focus:outline-none focus:ring-0',
@@ -17,12 +17,9 @@ export function CatalogDialog({ onClose }: CatalogDialogProps) {
bgClassName={'sm:!bg-black/75'}
boxClassName='sm:min-w-[520px] sm:w-3/4 sm:h-[85%] sm:!max-w-screen-sm'
>
<div className={clsx('bg-base-100 relative flex flex-col overflow-y-auto')}>
<div className={clsx('bg-base-100 relative flex flex-col overflow-y-auto pb-4')}>
<CatalogManager />
</div>
<form method='dialog' className='modal-backdrop'>
<button onClick={onClose}>{_('Close')}</button>
</form>
</Dialog>
);
}
@@ -41,8 +41,8 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
>
<div
className={clsx(
'flex items-center justify-center shadow-lg',
'bg-base-300 text-base-content text-xs',
'text-base-content flex items-center justify-center text-xs shadow-lg',
'not-eink:bg-base-300 eink:bg-base-100 eink:border eink:border-base-content',
'mx-auto w-fit space-x-6 rounded-lg p-4',
)}
>
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation';
import { PiUserCircle, PiUserCircleCheck, PiGear } from 'react-icons/pi';
import { PiSun, PiMoon } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { MdCloudSync } from 'react-icons/md';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
@@ -15,6 +16,7 @@ import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
@@ -61,6 +63,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
);
const iconSize = useResponsiveSize(16);
const { stats, hasActiveTransfers, setIsTransferQueueOpen } = useTransferQueue();
const openTransferQueue = () => {
setIsTransferQueueOpen(true);
setIsDropdownOpen?.(false);
};
const showAboutReadest = () => {
setAboutDialogVisible(true);
setIsDropdownOpen?.(false);
@@ -223,7 +232,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
return (
<Menu
className={clsx(
'settings-menu dropdown-content no-triangle border-base-100',
'settings-menu dropdown-content no-triangle',
'z-20 mt-2 max-w-[90vw] shadow-2xl',
)}
onCancel={() => setIsDropdownOpen?.(false)}
@@ -255,6 +264,23 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
) : (
<MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem>
)}
{user && (
<MenuItem
label={_('Cloud File Transfers')}
Icon={MdCloudSync}
description={
hasActiveTransfers
? _('{{activeCount}} active, {{pendingCount}} pending', {
activeCount: stats.active,
pendingCount: stats.pending,
})
: stats.failed > 0
? _('{{failedCount}} failed', { failedCount: stats.failed })
: ''
}
onClick={openTransferQueue}
/>
)}
<MenuItem
label={_('Auto Upload Books to Cloud')}
toggled={isAutoUpload}
@@ -0,0 +1,360 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import {
MdClose,
MdRefresh,
MdPause,
MdPlayArrow,
MdCloudUpload,
MdCloudDownload,
MdCheckCircle,
MdError,
MdCancel,
MdDeleteSweep,
} from 'react-icons/md';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useLibraryStore } from '@/store/libraryStore';
import { TransferItem, TransferStatus, useTransferStore } from '@/store/transferStore';
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const formatSpeed = (bytesPerSec: number): string => {
return `${formatBytes(bytesPerSec)}/s`;
};
const formatDateTime = (timestamp: number): string => {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) {
return timeStr;
} else if (isYesterday) {
return `Yesterday ${timeStr}`;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + timeStr;
}
};
const StatusIcon: React.FC<{
status: TransferStatus;
type: 'upload' | 'download' | 'delete';
size: number;
}> = ({ status, type, size }) => {
switch (status) {
case 'completed':
return <MdCheckCircle className='text-success' size={size} />;
case 'failed':
return <MdError className='text-error' size={size} />;
case 'cancelled':
return <MdCancel className='text-warning' size={size} />;
case 'in_progress':
case 'pending':
default:
return type === 'upload' ? (
<MdCloudUpload className='text-primary' size={size} />
) : type === 'delete' ? (
<MdDeleteSweep className='text-primary' size={size} />
) : (
<MdCloudDownload className='text-primary' size={size} />
);
}
};
const TransferItemRow: React.FC<{
transfer: TransferItem;
onCancel: (id: string) => void;
onRetry: (id: string) => void;
iconSize: number;
}> = ({ transfer, onCancel, onRetry, iconSize }) => {
const _ = useTranslation();
const completedLabel = {
upload: _('Uploaded'),
download: _('Downloaded'),
delete: _('Deleted'),
};
return (
<div className='hover:bg-base-200 flex items-center gap-3 rounded-lg p-3'>
<StatusIcon status={transfer.status} type={transfer.type} size={iconSize} />
<div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{transfer.bookTitle}</div>
<div className='text-base-content/60 text-xs'>
{transfer.status === 'in_progress' && (
<>
{Math.round(transfer.progress)}% - {formatSpeed(transfer.transferSpeed)}
</>
)}
{transfer.status === 'pending' && transfer.error && (
<span className='text-warning'>{transfer.error}</span>
)}
{transfer.status === 'pending' && !transfer.error && _('Waiting...')}
{transfer.status === 'failed' && (
<span className='text-error'>{transfer.error || _('Failed')}</span>
)}
{transfer.status === 'completed' && (completedLabel[transfer.type] || _('Completed'))}
{transfer.status === 'cancelled' && _('Cancelled')}
{' · '}
{formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
</div>
{transfer.status === 'in_progress' && (
<div className='bg-base-300 mt-1 h-1.5 w-full overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all'
style={{ width: `${transfer.progress}%` }}
/>
</div>
)}
</div>
<div className='flex items-center gap-1'>
{(transfer.status === 'failed' || transfer.status === 'cancelled') && (
<button
onClick={() => onRetry(transfer.id)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={_('Retry')}
>
<MdRefresh size={iconSize} />
</button>
)}
{['pending', 'in_progress'].includes(transfer.status) && (
<button
onClick={() => onCancel(transfer.id)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={_('Cancel')}
>
<MdClose size={iconSize} />
</button>
)}
</div>
</div>
);
};
type FilterType = 'all' | 'active' | 'completed' | 'failed';
const TransferQueuePanel: React.FC = () => {
const _ = useTranslation();
const iconSize = useResponsiveSize(18);
const setIsOpen = useTransferStore((state) => state.setIsTransferQueueOpen);
const getVisibleLibrary = useLibraryStore((state) => state.getVisibleLibrary);
const {
transfers,
stats,
isQueuePaused,
cancelTransfer,
retryTransfer,
retryAllFailed,
pauseQueue,
resumeQueue,
clearCompleted,
clearFailed,
queueUpload,
queueDownload,
} = useTransferQueue();
const [filter, setFilter] = useState<FilterType>('all');
const onClose = () => setIsOpen(false);
const divRef = useKeyDownActions({ onCancel: onClose, onConfirm: onClose });
const booksToUpload = getVisibleLibrary().filter((book) => book.downloadedAt && !book.uploadedAt);
const booksToDownload = getVisibleLibrary().filter(
(book) => book.uploadedAt && !book.downloadedAt,
);
const handleUploadAll = () => {
booksToUpload.forEach((book) => queueUpload(book));
};
const handleDownloadAll = () => {
booksToDownload.forEach((book) => queueDownload(book));
};
const filteredTransfers = transfers
.filter((t) => {
switch (filter) {
case 'active':
return ['pending', 'in_progress'].includes(t.status);
case 'completed':
return t.status === 'completed';
case 'failed':
return t.status === 'failed' || t.status === 'cancelled';
default:
return true;
}
})
.sort((a, b) => {
// Sort by status priority then by createdAt
const statusOrder: Record<TransferStatus, number> = {
in_progress: 0,
pending: 1,
failed: 2,
cancelled: 3,
completed: 4,
};
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff;
return b.createdAt - a.createdAt;
});
const filterLabels: Record<FilterType, string> = {
all: _('All'),
active: _('Active'),
completed: _('Completed'),
failed: _('Failed'),
};
return (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className='absolute inset-0 bg-black/50' onClick={onClose} />
<div
ref={divRef}
className={clsx(
'modal-box bg-base-100 relative flex max-h-[85%] min-h-[65%] w-[95%] flex-col rounded-2xl p-0 shadow-xl',
'min-w-64 max-w-lg overflow-hidden',
)}
>
{/* Header */}
<div className='border-base-300 flex items-center justify-between border-b p-4'>
<h2 className='text-lg font-semibold'>{_('Transfer Queue')}</h2>
<div className='flex items-center gap-2'>
{booksToUpload.length > 0 && (
<button
onClick={handleUploadAll}
className='btn btn-ghost btn-sm gap-1'
title={_('Upload All')}
aria-label={_('Upload All')}
>
<MdCloudUpload size={iconSize} />
<span className='text-xs'>{booksToUpload.length}</span>
</button>
)}
{booksToDownload.length > 0 && (
<button
onClick={handleDownloadAll}
className='btn btn-ghost btn-sm gap-1'
title={_('Download All')}
aria-label={_('Download All')}
>
<MdCloudDownload size={iconSize} />
<span className='text-xs'>{booksToDownload.length}</span>
</button>
)}
<button
onClick={isQueuePaused ? resumeQueue : pauseQueue}
className='btn btn-ghost btn-sm btn-circle'
title={isQueuePaused ? _('Resume Transfers') : _('Pause Transfers')}
aria-label={isQueuePaused ? _('Resume Transfers') : _('Pause Transfers')}
>
{isQueuePaused ? <MdPlayArrow size={iconSize} /> : <MdPause size={iconSize} />}
</button>
<button
onClick={onClose}
className='btn btn-ghost btn-sm btn-circle'
title={_('Close')}
aria-label={_('Close')}
>
<MdClose size={iconSize} />
</button>
</div>
</div>
{/* Stats bar */}
<div className='bg-base-200 flex items-center gap-4 px-4 py-2 text-sm'>
<span>
{_('Active')}: {stats.active}
</span>
<span>
{_('Pending')}: {stats.pending}
</span>
<span>
{_('Completed')}: {stats.completed}
</span>
<span>
{_('Failed')}: {stats.failed}
</span>
</div>
{/* Filter tabs */}
<div className='border-base-300 flex gap-2 border-b p-4'>
{(['all', 'active', 'completed', 'failed'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={clsx(
'rounded-lg px-3 py-1 text-sm transition-colors',
filter === f ? 'bg-primary text-primary-content' : 'bg-base-200 hover:bg-base-300',
)}
>
{filterLabels[f]}
</button>
))}
</div>
{/* Transfer list */}
<div className='flex-1 overflow-y-auto p-2'>
{filteredTransfers.length === 0 ? (
<div className='text-base-content/60 py-8 text-center'>{_('No transfers')}</div>
) : (
filteredTransfers.map((transfer) => (
<TransferItemRow
key={transfer.id}
transfer={transfer}
onCancel={cancelTransfer}
onRetry={retryTransfer}
iconSize={iconSize}
/>
))
)}
</div>
{/* Footer actions */}
<div className='border-base-300 flex items-center justify-between border-t p-4'>
<div className='flex items-center gap-2'>
{stats.failed > 0 && (
<button onClick={retryAllFailed} className='btn btn-ghost btn-sm gap-1'>
<MdRefresh size={iconSize - 2} />
{_('Retry All')}
</button>
)}
</div>
<div className='flex items-center gap-2'>
{stats.completed > 0 && (
<button onClick={clearCompleted} className='btn btn-ghost btn-sm gap-1'>
<MdDeleteSweep size={iconSize - 2} />
{_('Clear Completed')}
</button>
)}
{stats.failed > 0 && (
<button onClick={clearFailed} className='btn btn-ghost btn-sm gap-1'>
<MdDeleteSweep size={iconSize - 2} />
{_('Clear Failed')}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default TransferQueuePanel;
@@ -44,6 +44,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
{ label: _('Format'), value: 'format' },
{ label: _('Date Read'), value: 'updated' },
{ label: _('Date Added'), value: 'created' },
{ label: _('Date Published'), value: 'published' },
];
const sortingOptions = [
@@ -99,7 +100,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
return (
<Menu
className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
onCancel={() => setIsDropdownOpen?.(false)}
>
{viewOptions.map((option) => (
@@ -11,7 +11,7 @@ export const useBooksSync = () => {
const { user } = useAuth();
const { appService } = useEnv();
const { library, isSyncing, setLibrary, setIsSyncing, setSyncProgress } = useLibraryStore();
const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const { useSyncInited, syncedBooks, syncBooks, lastSyncedAtBooks } = useSync();
const isPullingRef = useRef(false);
const getNewBooks = useCallback(() => {
@@ -64,8 +64,7 @@ export const useBooksSync = () => {
return;
}
handleAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [library, handleAutoSync]);
}, [user, library, handleAutoSync]);
const pushLibrary = useCallback(async () => {
if (!user) return;
@@ -76,9 +75,9 @@ export const useBooksSync = () => {
}, [user, syncBooks, getNewBooks]);
useEffect(() => {
if (!user) return;
if (!user || !useSyncInited) return;
pullLibrary();
}, [user, pullLibrary]);
}, [user, useSyncInited, pullLibrary]);
const updateLibrary = async () => {
if (isSyncing) return;
@@ -107,13 +106,16 @@ export const useBooksSync = () => {
return oldBook;
};
const oldBooksBatchSize = 20;
const oldBooksBatchSize = 100;
for (let i = 0; i < oldBooksNeedsDownload.length; i += oldBooksBatchSize) {
const batch = oldBooksNeedsDownload.slice(i, i + oldBooksBatchSize);
await appService?.downloadBookCovers(batch);
}
const updatedLibrary = await Promise.all(library.map(processOldBook));
setLibrary(updatedLibrary);
appService?.saveLibraryBooks(updatedLibrary);
const bookHashesInLibrary = new Set(updatedLibrary.map((book) => book.hash));
const newBooks = syncedBooks.filter(
(newBook) =>
@@ -136,11 +138,12 @@ export const useBooksSync = () => {
await appService?.downloadBookCovers(batch);
await Promise.all(batch.map(processNewBook));
const progress = Math.min((i + batchSize) / newBooks.length, 1);
setLibrary([...updatedLibrary]);
setSyncProgress(progress);
setLibrary([...updatedLibrary]);
appService?.saveLibraryBooks(updatedLibrary);
}
setLibrary(updatedLibrary);
appService?.saveLibraryBooks(updatedLibrary);
} catch (err) {
console.error('Error updating new books:', err);
} finally {
if (newBooks.length > 0) {
setIsSyncing(false);
+98 -77
View File
@@ -10,11 +10,12 @@ import 'overlayscrollbars/overlayscrollbars.css';
import { Book } from '@/types/book';
import { AppService, DeleteAction } from '@/types/system';
import { navigateToLibrary, navigateToLogin, navigateToReader } from '@/utils/nav';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { transferManager } from '@/services/transferManager';
import { getDirPath, getFilename, joinPaths } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
@@ -35,6 +36,7 @@ import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTransferStore } from '@/store/transferStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
@@ -55,6 +57,7 @@ import { UpdaterWindow } from '@/components/UpdaterWindow';
import { CatalogDialog } from './components/OPDSDialog';
import { MigrateDataWindow } from './components/MigrateDataWindow';
import { useDragDropImport } from './hooks/useDragDropImport';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { Toast } from '@/components/Toast';
import { getBreadcrumbs } from './utils/libraryUtils';
import Spinner from '@/components/Spinner';
@@ -63,6 +66,8 @@ import Bookshelf from './components/Bookshelf';
import useShortcuts from '@/hooks/useShortcuts';
import DropIndicator from '@/components/DropIndicator';
import SettingsDialog from '@/components/settings/SettingsDialog';
import ModalPortal from '@/components/ModalPortal';
import TransferQueuePanel from './components/TransferQueuePanel';
const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams();
@@ -78,6 +83,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isSyncing,
syncProgress,
updateBook,
updateBooks,
setLibrary,
getGroupId,
getGroupName,
@@ -93,6 +99,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { clearBookData } = useBookDataStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const { isTransferQueueOpen } = useTransferStore();
const [showCatalogManager, setShowCatalogManager] = useState(
searchParams?.get('opds') === 'true',
);
@@ -120,6 +127,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useUICSS();
useOpenWithBooks();
useTransferQueue(libraryLoaded);
const { pullLibrary, pushLibrary } = useBooksSync();
const { isDragging } = useDragDropImport();
@@ -233,6 +241,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (book) {
bookIds.push(book.hash);
}
if (user && book && !temp && !book.uploadedAt && settings.autoUpload) {
setTimeout(() => {
console.log('Queueing upload for book:', book.title);
transferManager.queueUpload(book);
// wait for the initialization of the transfer manager and opening of the book
}, 3000);
}
} catch (error) {
console.log('Failed to import book:', file, error);
}
@@ -391,30 +406,29 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
['Unsupported or corrupted book file', _('The book file is corrupted')],
];
const processFile = async (selectedFile: SelectedFile) => {
const processFile = async (selectedFile: SelectedFile): Promise<Book | null> => {
const file = selectedFile.file || selectedFile.path;
if (!file) return;
if (!file) return null;
try {
const book = await appService?.importBook(file, library);
if (!book) return null;
const { path, basePath } = selectedFile;
if (book && groupId) {
if (groupId) {
book.groupId = groupId;
book.groupName = getGroupName(groupId);
await updateBook(envConfig, book);
} else if (book && path && basePath) {
} else if (path && basePath) {
const rootPath = getDirPath(basePath);
const groupName = getDirPath(path).replace(rootPath, '').replace(/^\//, '');
book.groupName = groupName;
book.groupId = getGroupId(groupName);
await updateBook(envConfig, book);
}
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book, false);
}
if (book) {
successfulImports.push(book.title);
if (user && !book.uploadedAt && settings.autoUpload) {
console.log('Queueing upload for book:', book.title);
transferManager.queueUpload(book);
}
successfulImports.push(book.title);
return book;
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getFilename(filename);
@@ -424,14 +438,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
: '';
failedImports.push({ filename: baseFilename, errorMessage });
console.error('Failed to import book:', filename, error);
return null;
}
};
const concurrency = 4;
for (let i = 0; i < files.length; i += concurrency) {
const batch = files.slice(i, i + concurrency);
await Promise.all(batch.map(processFile));
const importedBooks = (await Promise.all(batch.map(processFile))).filter((book) => !!book);
await updateBooks(envConfig, importedBooks);
}
pushLibrary();
if (failedImports.length > 0) {
@@ -456,8 +473,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
}
setLibrary([...library]);
appService?.saveLibraryBooks(library);
setLoading(false);
};
@@ -471,83 +486,66 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}, 500);
const handleBookUpload = useCallback(
async (book: Book, syncBooks = true) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
await updateBook(envConfig, book);
if (syncBooks) pushLibrary();
async (book: Book, _syncBooks = true) => {
// Use transfer queue for uploads - priority 1 for manual uploads (higher priority)
const transferId = transferManager.queueUpload(book, 1);
if (transferId) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book uploaded: {{title}}', {
message: _('Upload queued: {{title}}', {
title: book.title,
}),
});
return true;
} catch (err) {
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return false;
}
}
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to upload book: {{title}}', {
title: book.title,
}),
});
return false;
}
return false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
[],
);
const handleBookDownload = useCallback(
async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
async (book: Book, downloadOptions: { redownload?: boolean; queued?: boolean } = {}) => {
const { redownload = false, queued = false } = downloadOptions;
if (redownload || !queued) {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
}
// Use transfer queue for normal downloads - priority 1 for manual downloads
const transferId = transferManager.queueDownload(book, 1);
if (transferId) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
message: _('Download queued: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
return false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
@@ -565,14 +563,32 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
cloud: _('Failed to delete cloud backup of the book: {{title}}', { title: book.title }),
local: _('Failed to delete local copy of the book: {{title}}', { title: book.title }),
};
try {
await appService?.deleteBook(book, deleteAction);
await updateBook(envConfig, book);
clearBookData(book.hash);
if (syncBooks) pushLibrary();
// Handle local deletion immediately
if (deleteAction === 'local' || deleteAction === 'both') {
await appService?.deleteBook(book, 'local');
if (deleteAction === 'both') {
book.deletedAt = Date.now();
book.downloadedAt = null;
book.coverDownloadedAt = null;
}
await updateBook(envConfig, book);
clearBookData(book.hash);
if (syncBooks) pushLibrary();
}
// Queue cloud deletion
if (deleteAction === 'cloud' || deleteAction === 'both') {
const transferId = transferManager.queueDelete(book, 1, true);
if (!transferId) {
throw new Error('Failed to queue cloud deletion');
}
}
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
timeout: 1000,
message: deletionMessages[deleteAction],
});
return true;
@@ -856,6 +872,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleBookMetadataUpdate={handleUpdateMetadata}
/>
)}
{isTransferQueueOpen && (
<ModalPortal>
<TransferQueuePanel />
</ModalPortal>
)}
<AboutWindow />
<UpdaterWindow />
<MigrateDataWindow />
@@ -47,6 +47,27 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
case 'published':
const aPublished = a.metadata?.published || '0001-01-01';
const bPublished = b.metadata?.published || '0001-01-01';
// Handle cases where published date might not exist
if (!aPublished && !bPublished) return 0;
if (!aPublished) return 1; // Books without published date go to the end
if (!bPublished) return -1;
// Try to parse dates - handle various date formats
const aDate = new Date(aPublished).getTime();
const bDate = new Date(bPublished).getTime();
// If dates are invalid (NaN), fall back to string comparison
if (isNaN(aDate) && isNaN(bDate)) {
return aPublished.localeCompare(bPublished, uiLanguage || navigator.language);
}
if (isNaN(aDate)) return 1;
if (isNaN(bDate)) return -1;
return aDate - bDate;
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
@@ -406,9 +406,6 @@ export function CatalogManager() {
</div>
</form>
</div>
<form method='dialog' className='modal-backdrop'>
<button onClick={() => setShowAddDialog(false)}>{_('Close')}</button>
</form>
</dialog>
</ModalPortal>
)}
@@ -25,13 +25,19 @@ export function NavigationCard({ item, baseURL, onClick, resolveURL }: Navigatio
<div className='card-body flex justify-center p-4'>
<div className='flex items-start gap-3'>
<div className='min-w-0 flex-1'>
<h3 className='line-clamp-1 text-sm font-semibold'>{item.title || _('Untitled')}</h3>
<h3 title={item.title || _('Untitled')} className='line-clamp-1 text-sm font-semibold'>
{item.title || _('Untitled')}
</h3>
{item.properties?.numberOfItems && (
<p className='text-base-content/60 text-xs'>
{_('{{count}} items', { count: item.properties.numberOfItems })}
</p>
)}
{summary && <p className='text-base-content/70 mt-1 line-clamp-1 text-xs'>{summary}</p>}
{summary && (
<p title={summary} className='text-base-content/70 mt-1 line-clamp-1 text-xs'>
{summary}
</p>
)}
</div>
<div className='flex h-full items-center justify-center'>
<IoChevronForward className='text-base-content/40 h-5 w-5 flex-shrink-0' />
@@ -163,19 +163,13 @@ export function PublicationView({
<Dropdown
label={_('Download')}
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost p-0 hover:bg-transparent'
buttonClassName={clsx(
'btn btn-ghost btn-primary min-w-20 rounded-3xl p-0 hover:bg-transparent',
downloadedBook && 'btn-success',
)}
disabled={downloading}
toggleButton={
<div
role='button'
tabIndex={0}
className={clsx(
`btn btn-primary min-w-20 rounded-3xl ${downloading ? 'btn-disabled' : ''}`,
downloadedBook && 'btn-success',
)}
>
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
</div>
<div>{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}</div>
}
>
<div
+72 -36
View File
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { isOPDSCatalog, getPublication, getFeed, getOpenSearch } from 'foliate-js/opds.js';
import { openUrl } from '@tauri-apps/plugin-opener';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { isWebAppPlatform } from '@/services/environment';
import { downloadFile } from '@/libs/storage';
import { Toast } from '@/components/Toast';
@@ -14,13 +15,27 @@ import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { transferManager } from '@/services/transferManager';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useTheme } from '@/hooks/useTheme';
import { useLibrary } from '@/hooks/useLibrary';
import { eventDispatcher } from '@/utils/event';
import { getFileExtFromMimeType } from '@/libs/document';
import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
import { isSearchLink, MIME, parseMediaType, resolveURL } from './utils/opdsUtils';
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
import {
getFileExtFromPath,
isSearchLink,
MIME,
parseMediaType,
resolveURL,
} from './utils/opdsUtils';
import {
getProxiedURL,
fetchWithAuth,
probeAuth,
needsProxy,
probeFilename,
} from './utils/opdsReq';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { FeedView } from './components/FeedView';
import { PublicationView } from './components/PublicationView';
@@ -49,6 +64,7 @@ export default function BrowserPage() {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { user } = useAuth();
const { libraryLoaded } = useLibrary();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const { settings } = useSettingsStore();
@@ -78,6 +94,7 @@ export default function BrowserPage() {
const searchTermRef = useRef('');
useTheme({ systemUIVisible: false });
useTransferQueue(libraryLoaded);
useEffect(() => {
startURLRef.current = state.startURL;
@@ -403,41 +420,58 @@ export default function BrowserPage() {
}
return;
} else {
const ext = parsed?.mediaType ? getFileExtFromMimeType(parsed.mediaType) : '';
const basename = new URL(url).pathname.replaceAll('/', '_');
const filename = ext ? `${basename}.${ext}` : basename;
const dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
if (dstFilePath) {
const username = usernameRef.current || '';
const password = passwordRef.current || '';
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
if (authHeader) {
headers['Authorization'] = authHeader;
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
}
const username = usernameRef.current || '';
const password = passwordRef.current || '';
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: '*/*',
};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
if (authHeader) {
headers['Authorization'] = authHeader;
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
}
await downloadFile({
appService,
dst: dstFilePath,
cfp: '',
url: downloadUrl,
headers,
singleThreaded: true,
onProgress,
});
const { library, setLibrary } = useLibraryStore.getState();
const book = await appService.importBook(dstFilePath, library);
setLibrary(library);
appService.saveLibraryBooks(library);
return book;
}
const pathname = decodeURIComponent(new URL(url).pathname);
const ext = getFileExtFromMimeType(parsed?.mediaType) || getFileExtFromPath(pathname);
const basename = pathname.replaceAll('/', '_');
const filename = ext ? `${basename}.${ext}` : basename;
let dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
console.log('Downloading to:', url, dstFilePath);
const responseHeaders = await downloadFile({
appService,
dst: dstFilePath,
cfp: '',
url: downloadUrl,
headers,
singleThreaded: true,
skipSslVerification: true,
onProgress,
});
const probedFilename = await probeFilename(responseHeaders);
if (probedFilename) {
const newFilePath = await appService?.resolveFilePath(probedFilename, 'Cache');
await appService?.copyFile(dstFilePath, newFilePath, 'None');
await appService?.deleteFile(dstFilePath, 'None');
console.log('Renamed downloaded file to:', newFilePath);
dstFilePath = newFilePath;
}
const { library, setLibrary } = useLibraryStore.getState();
const book = await appService.importBook(dstFilePath, library);
if (user && book && !book.uploadedAt && settings.autoUpload) {
setTimeout(() => {
transferManager.queueUpload(book);
}, 3000);
}
setLibrary(library);
appService.saveLibraryBooks(library);
return book;
}
} catch (e) {
console.error('Download error:', e);
@@ -445,7 +479,8 @@ export default function BrowserPage() {
}
return;
},
[state.baseURL, appService, libraryLoaded],
// eslint-disable-next-line react-hooks/exhaustive-deps
[user, state.baseURL, appService, libraryLoaded],
);
const handleGenerateCachedImageUrl = useCallback(
@@ -479,6 +514,7 @@ export default function BrowserPage() {
cfp: '',
url: downloadUrl,
singleThreaded: true,
skipSslVerification: true,
headers,
});
return await appService.getImageURL(cachedPath);
+26 -3
View File
@@ -207,7 +207,7 @@ export const probeAuth = async (
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml',
Accept: 'application/atom+xml, application/xml, text/xml, */*',
};
// Probe with HEAD request
@@ -215,6 +215,7 @@ export const probeAuth = async (
const res = await fetch(fetchURL, {
method: 'HEAD',
headers,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true },
});
// Check if authentication is required
@@ -240,7 +241,27 @@ export const probeAuth = async (
}
}
return null;
// Komga returns 200 even if requires auth, so we return Basic auth header in this case
return createBasicAuth(finalUsername, finalPassword);
};
export const probeFilename = async (headers: Record<string, string>) => {
const contentDisposition = headers['content-disposition'];
if (contentDisposition) {
const extendedMatch = contentDisposition.match(
/filename\*\s*=\s*(?:utf-8|UTF-8)'[^']*'([^;\s]+)/i,
);
if (extendedMatch?.[1]) {
return decodeURIComponent(extendedMatch[1]);
}
const plainMatch = contentDisposition.match(/filename\s*=\s*["']?([^"';\s]+)["']?/i);
if (plainMatch?.[1]) {
return decodeURIComponent(plainMatch[1]);
}
}
return '';
};
/**
@@ -265,7 +286,7 @@ export const fetchWithAuth = async (
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml',
Accept: 'application/atom+xml, application/xml, text/xml, */*',
...(options.headers as Record<string, string>),
};
@@ -274,6 +295,7 @@ export const fetchWithAuth = async (
...options,
method: options.method || 'GET',
headers,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true },
});
// Handle authentication if needed
@@ -301,6 +323,7 @@ export const fetchWithAuth = async (
...options,
method: options.method || 'GET',
headers: useProxy ? headers : { ...headers, Authorization: authHeader },
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true },
});
}
}
@@ -1,5 +1,6 @@
import { isOPDSCatalog } from 'foliate-js/opds.js';
import { OPDSLink } from '@/types/opds';
import { EXTS } from '@/libs/document';
import { fetchWithAuth } from './opdsReq';
export const groupByArray = <T, K>(arr: T[] | undefined, f: (el: T) => K | K[]): Map<K, T[]> => {
@@ -215,3 +216,13 @@ export const validateOPDSURL = async (
};
}
};
export const getFileExtFromPath = (pathname: string, delimiter = '/'): string => {
const parts = pathname.split(delimiter);
for (const ext of Object.values(EXTS)) {
if (parts.includes(ext)) {
return ext;
}
}
return '';
};
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { MdOutlineBookmarkAdd, MdOutlineBookmark } from 'react-icons/md';
import * as CFI from 'foliate-js/epubcfi.js';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -12,6 +11,7 @@ import { uniqueId } from '@/utils/misc';
import Button from '@/components/Button';
import { getCurrentPage } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { isCfiInLocation } from '@/utils/cfi';
interface BookmarkTogglerProps {
bookKey: string;
@@ -66,13 +66,8 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
}
} else {
setIsBookmarked(false);
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
bookmarks.forEach((item) => {
if (
item.type === 'bookmark' &&
CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0
) {
if (item.type === 'bookmark' && isCfiInLocation(item.cfi, cfi)) {
item.deletedAt = Date.now();
}
});
@@ -101,11 +96,9 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const { location: cfi } = progress || {};
if (!cfi) return;
const start = CFI.collapse(cfi);
const end = CFI.collapse(cfi, true);
const locationBookmarked = booknotes
.filter((booknote) => booknote.type === 'bookmark' && !booknote.deletedAt)
.some((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0);
.some((item) => isCfiInLocation(item.cfi, cfi));
setIsBookmarked(locationBookmarked);
setBookmarkRibbonVisibility(bookKey, locationBookmarked);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -3,14 +3,14 @@ import React, { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import SettingsDialog from '@/components/settings/SettingsDialog';
import SearchResultsNav from './sidebar/SearchResultsNav';
import BooknotesNav from './sidebar/BooknotesNav';
import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
@@ -34,7 +34,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { setGridInsets, hoveredBookKey } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { isSettingsDialogOpen } = useSettingsStore();
const { safeAreaInsets: screenInsets } = useThemeStore();
const aspectRatio = window.innerWidth / window.innerHeight;
@@ -198,6 +197,8 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
/>
)}
<Annotator bookKey={bookKey} />
<SearchResultsNav bookKey={bookKey} gridInsets={gridInsets} />
<BooknotesNav bookKey={bookKey} gridInsets={gridInsets} toc={bookDoc.toc || []} />
<FootnotePopup bookKey={bookKey} bookDoc={bookDoc} />
<FooterBar
bookKey={bookKey}
@@ -207,7 +208,6 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
isHoveredAnim={false}
gridInsets={gridInsets}
/>
{isSettingsDialogOpen && <SettingsDialog bookKey={bookKey} />}
</div>
);
})}
@@ -53,6 +53,7 @@ import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
import { useBookCoverAutoSave } from '../hooks/useAutoSaveBookCover';
import { useDiscordPresence } from '@/hooks/useDiscordPresence';
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
import { getViewInsets } from '@/utils/insets';
import { removeTabIndex } from '@/utils/a11y';
@@ -85,6 +86,7 @@ const FoliateViewer: React.FC<{
const { getBookData } = useBookDataStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
const bookData = getBookData(bookKey);
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
@@ -98,6 +100,12 @@ const FoliateViewer: React.FC<{
useAutoFocus<HTMLDivElement>({ ref: containerRef });
useDiscordPresence(
bookData?.book || null,
!!viewState?.isPrimary,
settings.discordRichPresenceEnabled,
);
useEffect(() => {
const timer = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(timer);
@@ -132,7 +140,8 @@ const FoliateViewer: React.FC<{
const bookData = getBookData(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(data, width, height, viewSettings.vertical);
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
const isHtml = detail.type === 'application/xhtml+xml' || detail.type === 'text/html';
if (viewSettings && bookData && isHtml) {
const ctx: TransformContext = {
bookKey,
viewSettings,
@@ -141,6 +150,7 @@ const FoliateViewer: React.FC<{
primaryLanguage: bookData.book?.primaryLanguage,
userLocale: getLocale(),
content: data,
sectionHref: detail.name,
transformers: [
'style',
'punctuation',
@@ -149,9 +159,8 @@ const FoliateViewer: React.FC<{
'language',
'sanitizer',
'simplecc',
'replacement',
'proofread',
],
sectionHref: detail.name, // Pass section href for single-instance replacements
};
return Promise.resolve(transformContent(ctx));
}
@@ -187,7 +196,9 @@ const FoliateViewer: React.FC<{
setViewSettings(bookKey, { ...viewSettings });
}
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
if (!bookData?.isFixedLayout) {
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
}
getLoadedFonts().forEach((font) => {
mountCustomFont(detail.doc, font);
@@ -328,8 +339,8 @@ const FoliateViewer: React.FC<{
});
const viewWidth = appService?.isMobile ? screen.width : window.innerWidth;
const viewHeight = appService?.isMobile ? screen.height : window.innerHeight;
const width = viewWidth - insets.left - insets.right;
const height = viewHeight - insets.top - insets.bottom;
const width = viewWidth;
const height = viewHeight;
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
view.renderer.setStyles?.(getStyles(viewSettings));
applyTranslationStyle(viewSettings);
@@ -8,11 +8,18 @@ import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import WindowButtons from '@/components/WindowButtons';
import { getHighlightColorHex } from '../utils/annotatorUtil';
import { annotationToolQuickActions } from './annotator/AnnotationTools';
import { AnnotationToolType } from '@/types/annotator';
import { saveViewSettings } from '@/helpers/settings';
import { HighlighterIcon } from '@/components/HighlighterIcon';
import Dropdown from '@/components/Dropdown';
import WindowButtons from '@/components/WindowButtons';
import QuickActionMenu from './annotator/QuickActionMenu';
import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler';
import NotebookToggler from './NotebookToggler';
@@ -38,23 +45,46 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onCloseBook,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { isTrafficLightVisible } = useTrafficLight();
const { trafficLightInFullscreen, setTrafficLightVisibility } = useTrafficLightStore();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { bookKeys, hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { bookKeys, hoveredBookKey } = useReaderStore();
const { isDarkMode, systemUIVisible, statusBarHeight } = useThemeStore();
const { isSideBarVisible } = useSidebarStore();
const iconSize16 = useResponsiveSize(16);
const { getView, getViewSettings, setHoveredBookKey } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const view = getView(bookKey);
const iconSize16 = useResponsiveSize(16);
const headerRef = useRef<HTMLDivElement>(null);
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
const docs = view?.renderer.getContents() ?? [];
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
const enableAnnotationQuickActions = viewSettings?.enableAnnotationQuickActions;
const annotationQuickActionButton =
annotationToolQuickActions.find(
(button) => button.type === viewSettings?.annotationQuickAction,
) || annotationToolQuickActions[0]!;
const annotationQuickAction = viewSettings?.annotationQuickAction;
const AnnotationToolQuickActionIcon = annotationQuickActionButton.Icon;
const highlightStyle = settings.globalReadSettings.highlightStyle;
const highlightColor = settings.globalReadSettings.highlightStyles[highlightStyle];
const highlightHexColor = getHighlightColorHex(settings, highlightColor);
const handleToggleDropdown = (isOpen: boolean) => {
setIsDropdownOpen(isOpen);
if (!isOpen) setHoveredBookKey('');
};
const handleAnnotationQuickActionSelect = (action: AnnotationToolType | null) => {
if (viewSettings?.annotationQuickAction === action) action = null;
saveViewSettings(envConfig, bookKey, 'annotationQuickAction', action, false, true);
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
if (isSideBarVisible) return;
@@ -90,7 +120,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
>
<div
role='none'
className={clsx('absolute top-0 z-10 h-11 w-full')}
className={clsx('absolute top-0 z-10 h-11 w-full', pointerInDoc && 'pointer-events-none')}
onClick={() => setHoveredBookKey(bookKey)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
@@ -137,6 +167,40 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
</div>
<BookmarkToggler bookKey={bookKey} />
<TranslationToggler bookKey={bookKey} />
{enableAnnotationQuickActions && (
<Dropdown
label={
annotationQuickAction
? _('Disable Quick Action')
: _('Enable Quick Action on Selection')
}
className='exclude-title-bar-mousedown dropdown-bottom'
buttonClassName={clsx(
'btn btn-ghost h-8 min-h-8 w-8 p-0',
viewSettings?.annotationQuickAction && 'bg-base-300/50',
)}
toggleButton={
annotationQuickAction === 'highlight' || annotationQuickAction === null ? (
<HighlighterIcon
size={iconSize16}
tipColor={annotationQuickAction === null ? '#8F8F8F' : highlightHexColor}
tipStyle={{
opacity: annotationQuickAction === null ? 0.5 : 0.8,
mixBlendMode: isDarkMode ? 'screen' : 'multiply',
}}
/>
) : (
<AnnotationToolQuickActionIcon size={iconSize16} />
)
}
onToggle={handleToggleDropdown}
>
<QuickActionMenu
selectedAction={viewSettings.annotationQuickAction}
onActionSelect={handleAnnotationQuickActionSelect}
/>
</Dropdown>
)}
</div>
<div
@@ -159,7 +223,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
</div>
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4 ps-2'>
<SettingsToggler />
<SettingsToggler bookKey={bookKey} />
<NotebookToggler bookKey={bookKey} />
<Dropdown
label={_('View Options')}
@@ -86,7 +86,7 @@ const HintInfo: React.FC<SectionInfoProps> = ({
style={
isVertical
? {
bottom: `${contentInsets.bottom * 1.5}px`,
bottom: `${(contentInsets.bottom - gridInsets.bottom) * 1.5}px`,
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
@@ -74,6 +74,8 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
const [progressInfoMode, setProgressInfoMode] = useState(viewSettings.progressInfoMode);
const cycleProgressInfoModes = () => {
if (!viewSettings.tapToToggleFooter) return;
const hasRemainingInfo = viewSettings.showRemainingTime || viewSettings.showRemainingPages;
const hasProgressInfo = viewSettings.showProgressInfo;
const modeSequence: (typeof progressInfoMode)[] = ['all', 'remaining', 'progress', 'none'];
@@ -113,15 +115,17 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progressInfoMode]);
const isMobile = appService?.isMobile || window.innerWidth < 640;
return (
<div
role='presentation'
className={clsx(
'progressinfo absolute flex items-center justify-between font-sans',
'pointer-events-auto bottom-0',
'progressinfo absolute bottom-0 flex items-center justify-between font-sans',
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
isMobile ? 'pointer-events-auto' : 'pointer-events-none',
)}
onClick={() => cycleProgressInfoModes()}
aria-label={[
@@ -139,7 +143,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
style={
isVertical
? {
bottom: `${contentInsets.bottom * 1.5}px`,
bottom: `${(contentInsets.bottom - gridInsets.bottom) * 1.5}px`,
left: showDoubleBorder
? `calc(${contentInsets.left}px)`
: `calc(${Math.max(0, contentInsets.left - 32)}px)`,
@@ -0,0 +1,327 @@
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { RiEditLine, RiDeleteBin7Line } from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useProofreadStore } from '@/store/proofreadStore';
import { ProofreadRule, ProofreadScope } from '@/types/book';
import { eventDispatcher } from '@/utils/event';
import Dialog from '@/components/Dialog';
const dialogId = 'proofread_rules_window';
export const setProofreadRulesVisibility = (visible: boolean) => {
const dialog = document.getElementById(dialogId);
if (dialog) {
dialog.dispatchEvent(new CustomEvent('setProofreadRulesVisibility', { detail: { visible } }));
}
};
const RuleItem: React.FC<{
rule: ProofreadRule;
scope: ProofreadScope;
isEditing: boolean;
editingData: { pattern: string; replacement: string; enabled: boolean };
onEdit: () => void;
onDelete: () => void;
onSave: () => void;
onCancel: () => void;
onEditChange: (field: 'replacement', value: string) => void;
}> = ({
rule,
scope,
isEditing,
editingData,
onEdit,
onDelete,
onSave,
onCancel,
onEditChange,
}) => {
const _ = useTranslation();
const { sideBarBookKey } = useSidebarStore();
const { getView } = useReaderStore();
const navigateToSelection = () => {
if (!sideBarBookKey || !rule.cfi) return;
eventDispatcher.dispatch('navigate', { bookKey: sideBarBookKey, cfi: rule.cfi });
getView(sideBarBookKey)?.goTo(rule.cfi);
};
if (isEditing) {
return (
<div className='flex flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<label className='text-base-content/70 text-xs font-medium'>{_('Selected text:')}</label>
<input
className='input input-sm bg-base-200 text-sm opacity-60'
value={editingData.pattern}
disabled
/>
</div>
<div className='flex flex-col gap-1.5'>
<label className='text-base-content/70 text-xs font-medium'>{_('Replace with:')}</label>
<input
className='input input-sm text-sm'
value={editingData.replacement}
onChange={(e) => onEditChange('replacement', e.target.value)}
/>
</div>
<div className='mt-1 flex gap-2'>
<button className='btn btn-primary btn-sm flex-1' onClick={onSave}>
{_('Save')}
</button>
<button className='btn btn-sm flex-1' onClick={onCancel}>
{_('Cancel')}
</button>
</div>
</div>
);
}
return (
<div className='relative flex items-start justify-between gap-3 p-3'>
<div className='flex min-w-0 flex-1 flex-col gap-1.5'>
<div className='break-words pe-20 text-base font-medium leading-snug'>{rule.pattern}</div>
<div className='text-base-content/70 break-words text-sm'>
<span className='text-base-content/80 mr-1.5 text-xs font-medium'>
{_('Replace with:')}
</span>
<span className='text-base-content/90 text-xs'>{"'" + rule.replacement + "'"}</span>
</div>
<div className='text-base-content/60 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs'>
<span className='inline-flex items-center gap-1'>
<span className='text-base-content/50'>{_('Scope:')}</span>
<span
role='none'
className={clsx(
'text-base-content/70 font-medium',
scope === 'selection' && 'cursor-pointer text-blue-400 hover:text-blue-500',
)}
onClick={scope === 'selection' ? navigateToSelection : undefined}
>
{scope === 'selection' ? _('Selection') : scope === 'book' ? _('Book') : _('Library')}
</span>
</span>
<span className='text-base-content/30'></span>
<span className='inline-flex items-center gap-1'>
<span className='text-base-content/50'>{_('Case sensitive:')}</span>
<span className='text-base-content/70 font-medium'>
{rule.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</span>
<span className='text-base-content/30'></span>
<span className='inline-flex items-center gap-1'>
<span className='text-base-content/50'>{_('Only for TTS:')}</span>
<span className='text-base-content/70 font-medium'>
{rule.onlyForTTS === true ? _('Yes') : _('No')}
</span>
</span>
</div>
</div>
<div className='absolute right-2 top-2 flex items-center gap-1'>
<button
className='btn btn-ghost btn-sm h-8 w-8 p-0'
onClick={onEdit}
aria-label={_('Edit')}
>
<RiEditLine className='h-4 w-4' />
</button>
<button
className='btn btn-ghost btn-sm h-8 w-8 p-0'
onClick={onDelete}
aria-label={_('Delete')}
>
<RiDeleteBin7Line className='h-4 w-4' />
</button>
</div>
</div>
);
};
// Hook to manage rules logic
const useReplacementRules = (bookKey: string | null) => {
const { settings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const { getConfig } = useBookDataStore();
const viewSettings = bookKey ? getViewSettings(bookKey) : null;
const inMemoryRules = viewSettings?.proofreadRules || [];
const persistedConfig = bookKey ? getConfig(bookKey) : null;
const persistedBookRules = persistedConfig?.viewSettings?.proofreadRules || [];
// Prefer persisted rules; fall back to in-memory
const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules;
const singleRules = bookRuleSource.filter((r: ProofreadRule) => r.scope === 'selection');
const bookScopedRules = bookRuleSource.filter((r: ProofreadRule) => r.scope === 'book');
const globalRules = settings?.globalViewSettings?.proofreadRules || [];
// Merge book-scoped and global rules
const globalRuleIds = new Set(globalRules.map((gr: ProofreadRule) => gr.id));
// Remove orphaned overrides (disabled global rules that no longer exist)
const validBookRules = bookScopedRules.filter(
(br: ProofreadRule) => br.enabled !== false || globalRuleIds.has(br.id),
);
const mergedBookRules = validBookRules.concat(
globalRules.filter(
(gr: ProofreadRule) => !validBookRules.find((br: ProofreadRule) => br.id === gr.id),
),
);
return { singleRules, bookRules: mergedBookRules };
};
export const ProofreadRulesManager: React.FC = () => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { recreateViewer } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { updateRule, removeRule } = useProofreadStore();
const [isOpen, setIsOpen] = useState(false);
const [editing, setEditing] = useState<{
id: string | null;
scope: ProofreadScope | null;
pattern: string;
replacement: string;
enabled: boolean;
onlyForTTS: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true, onlyForTTS: false });
const { singleRules, bookRules } = useReplacementRules(sideBarBookKey);
useEffect(() => {
const handleVisibility = (event: CustomEvent) => setIsOpen(!!event.detail?.visible);
const el = document.getElementById(dialogId);
el?.addEventListener('setProofreadRulesVisibility', handleVisibility as EventListener);
return () =>
el?.removeEventListener('setProofreadRulesVisibility', handleVisibility as EventListener);
}, []);
const startEdit = (rule: ProofreadRule) => {
setEditing({
id: rule.id,
scope: rule.scope,
pattern: rule.pattern,
replacement: rule.replacement,
enabled: !!rule.enabled,
onlyForTTS: !!rule.onlyForTTS,
});
};
const cancelEdit = () => {
setEditing({
id: null,
scope: null,
pattern: '',
replacement: '',
enabled: true,
onlyForTTS: false,
});
};
const saveEdit = async () => {
if (!editing.id || !editing.scope || !sideBarBookKey) return;
await updateRule(envConfig, sideBarBookKey, editing.id, {
scope: editing.scope,
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
onlyForTTS: editing.onlyForTTS,
});
cancelEdit();
if (!editing.onlyForTTS) {
recreateViewer(envConfig, sideBarBookKey);
}
};
const deleteRule = async (rule: ProofreadRule) => {
if (!sideBarBookKey) return;
await removeRule(envConfig, sideBarBookKey, rule.id, rule.scope);
if (!rule.onlyForTTS) {
recreateViewer(envConfig, sideBarBookKey);
}
};
const renderRuleList = (
rules: ProofreadRule[],
scopeType: ProofreadScope,
title: string,
emptyMessage: string,
) => (
<div className='flex flex-col gap-3'>
<h3 className='text-base-content/90 text-sm font-semibold'>{title}</h3>
{rules.length === 0 ? (
<div className='border-base-content/10 bg-base-200/30 rounded-lg border border-dashed p-6 text-center'>
<p className='text-base-content/50 text-sm'>{emptyMessage}</p>
</div>
) : (
<ul className='flex flex-col gap-2'>
{rules.map((rule) => (
<li
key={rule.id}
className='border-base-content/10 bg-base-100 hover:border-base-content/20 rounded-lg border transition-colors'
>
<RuleItem
rule={rule}
scope={scopeType === 'selection' ? 'selection' : rule.scope}
isEditing={
editing.id === rule.id &&
editing.scope === (scopeType === 'selection' ? 'selection' : rule.scope)
}
editingData={editing}
onEdit={() => startEdit(rule)}
onDelete={() => deleteRule(rule)}
onSave={saveEdit}
onCancel={cancelEdit}
onEditChange={(_, value) => setEditing({ ...editing, replacement: value })}
/>
</li>
))}
</ul>
)}
</div>
);
return (
<Dialog
id={dialogId}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Proofread Replacement Rules')}
boxClassName='sm:!min-w-[560px] sm:!max-w-[640px] sm:h-auto'
>
{isOpen && (
<div className='flex max-h-[70vh] flex-col gap-6 p-4 sm:p-6'>
{renderRuleList(
singleRules,
'selection',
_('Selected Text Rules'),
_('No selected text replacement rules'),
)}
{renderRuleList(
bookRules,
'book',
_('Book Specific Rules'),
_('No book-level replacement rules'),
)}
<div className='p-1'></div>
</div>
)}
</Dialog>
);
};
export default ProofreadRulesManager;
@@ -15,6 +15,7 @@ import { useNotebookStore } from '@/store/notebookStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { eventDispatcher } from '@/utils/event';
import { interceptWindowOpen } from '@/utils/open';
import { mountAdditionalFonts } from '@/styles/fonts';
@@ -23,7 +24,7 @@ import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { KOSyncSettingsWindow } from './KOSyncSettings';
import { ReplacementRulesWindow } from './ReplacementRulesWindow';
import { ProofreadRulesManager } from './ProofreadRules';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
import { initDayjs } from '@/utils/time';
@@ -54,18 +55,21 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const router = useRouter();
const { appService } = useEnv();
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();
const { showSystemUI, dismissSystemUI } = useThemeStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const { libraryLoaded } = useLibrary();
const { sideBarBookKey } = useSidebarStore();
const { hoveredBookKey } = useReaderStore();
const { showSystemUI, dismissSystemUI } = useThemeStore();
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const { isSideBarVisible, isSideBarPinned } = useSidebarStore();
const { getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, isNotebookPinned } = useNotebookStore();
const { getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore();
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useTransferQueue(libraryLoaded, 5000);
useEffect(() => {
mountAdditionalFonts(document);
@@ -98,14 +102,11 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}, [appService]);
const handleKeyDown = (event: CustomEvent) => {
const view = getView(sideBarBookKey!);
if (event.detail.keyName === 'Back') {
if (getIsSideBarVisible()) {
if (getIsSideBarVisible() && !isSideBarPinned) {
setSideBarVisible(false);
} else if (getIsNotebookVisible()) {
} else if (getIsNotebookVisible() && !isNotebookPinned) {
setNotebookVisible(false);
} else if (view?.history.canGoBack) {
view.history.back();
} else {
eventDispatcher.dispatch('close-reader');
router.back();
@@ -134,7 +135,14 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.isAndroidApp, sideBarBookKey, isSideBarVisible, isNotebookVisible]);
}, [
appService?.isAndroidApp,
sideBarBookKey,
isSideBarPinned,
isSideBarVisible,
isNotebookPinned,
isNotebookVisible,
]);
useEffect(() => {
if (!appService?.isMobileApp) return;
@@ -161,7 +169,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<AboutWindow />
<UpdaterWindow />
<KOSyncSettingsWindow />
<ReplacementRulesWindow />
<ProofreadRulesManager />
<Toast />
</Suspense>
</div>
@@ -20,6 +20,7 @@ import { uniqueId } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { eventDispatcher } from '@/utils/event';
import { navigateToLibrary } from '@/utils/nav';
import { clearDiscordPresence } from '@/utils/discord';
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
import { BookDetailModal } from '@/components/metadata';
@@ -29,6 +30,7 @@ import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import SettingsDialog from '@/components/settings/SettingsDialog';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
const _ = useTranslation();
@@ -41,6 +43,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { getConfig, getBookData, saveConfig } = useBookDataStore();
const { getView, setBookKeys, getViewSettings } = useReaderStore();
const { initViewState, getViewState, clearViewState } = useReaderStore();
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const isInitiating = useRef(false);
const [loading, setLoading] = useState(false);
@@ -95,8 +98,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
useEffect(() => {
if (bookKeys && bookKeys.length > 0) {
const settings = useSettingsStore.getState().settings;
settings.lastOpenBooks = bookKeys.map((key) => key.split('-')[0]!);
saveSettings(envConfig, settings);
const lastOpenBooks = bookKeys.map((key) => key.split('-')[0]!);
if (settings.lastOpenBooks?.toString() !== lastOpenBooks.toString()) {
settings.lastOpenBooks = lastOpenBooks;
saveSettings(envConfig, settings);
}
}
let unlistenOnCloseWindow: Promise<UnlistenFn>;
@@ -131,6 +137,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const saveConfigAndCloseBook = async (bookKey: string) => {
console.log('Closing book', bookKey);
const viewState = getViewState(bookKey);
if (viewState?.isPrimary && appService?.isDesktopApp) {
await clearDiscordPresence(appService);
}
try {
getView(bookKey)?.close();
getView(bookKey)?.remove();
@@ -212,6 +224,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
<div className='reader-content full-height flex'>
<SideBar onGoToLibrary={handleCloseBooksToLibrary} />
<BooksGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
<Notebook />
{showDetailsBook && (
<BookDetailModal
@@ -1,440 +0,0 @@
import React, { useEffect, useState } from 'react';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ReplacementRule } from '@/types/book';
import environmentConfig from '@/services/environment';
import { updateReplacementRule, removeReplacementRule } from '@/services/transformers/replacement';
import { eventDispatcher } from '@/utils/event';
import { RiEditLine, RiDeleteBin7Line } from 'react-icons/ri';
export const setReplacementRulesWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('replacement_rules_window');
if (dialog) {
const event = new CustomEvent('setReplacementRulesVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const ReplacementRulesWindow: React.FC = () => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { getConfig } = useBookDataStore();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(!!event.detail?.visible);
};
const el = document.getElementById('replacement_rules_window');
el?.addEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener);
};
}, []);
const viewSettings = sideBarBookKey ? getViewSettings(sideBarBookKey) : null;
const inMemoryRules = viewSettings?.replacementRules || [];
const persistedConfig = sideBarBookKey ? getConfig(sideBarBookKey) : null;
const persistedBookRules = persistedConfig?.viewSettings?.replacementRules || [];
// Prefer persisted rules; fall back to in-memory so we show unsaved edits in tests/dev
const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules;
const singleRules = bookRuleSource.filter((r: ReplacementRule) => !!r.singleInstance);
const bookScopedRules = bookRuleSource.filter((r: ReplacementRule) => !r.singleInstance);
// Book rules = book-scoped rules + global rules (merged for display)
// Merge logic:
// 1. Include all book-scoped rules (including disabled overrides of global rules)
// 2. Include global rules that aren't overridden at book level
// 3. Filter out orphaned overrides (disabled global rules that no longer exist globally)
const globalRules = settings?.globalViewSettings?.replacementRules || [];
// Create a map of global rule IDs to identify overridden rules
const globalRuleIds = new Set(globalRules.map((gr: ReplacementRule) => gr.id));
// Filter out book rules that are disabled overrides of non-existent global rules
const validBookRules = bookScopedRules.filter((br: ReplacementRule) => {
// If it's enabled, it's a real book rule
if (br.enabled !== false) return true;
// If it's disabled and the global rule still exists, keep it (it's an override)
// If the global rule doesn't exist, filter it out (orphaned override)
return globalRuleIds.has(br.id);
});
const mergedRules = validBookRules.concat(
globalRules.filter(
(gr: ReplacementRule) => !validBookRules.find((br: ReplacementRule) => br.id === gr.id),
),
);
// Create a map to track the scope of each rule for editing/deleting
const getRuleScope = (rule: ReplacementRule): 'single' | 'book' | 'global' => {
if (rule.singleInstance) return 'single';
// If the rule is in validBookRules and originates from global, it's an override
return globalRuleIds.has(rule.id) ? 'global' : 'book';
};
const bookRules = mergedRules;
const [editing, setEditing] = useState<{
id: string | null;
scope: 'single' | 'book' | 'global' | null;
pattern: string;
replacement: string;
enabled: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
// Track when a delete/edit operation is in progress to prevent rapid successive operations
const [isReloading, setIsReloading] = useState(false);
const startEdit = (r: ReplacementRule, scope: 'single' | 'book' | 'global') => {
setEditing({
id: r.id,
scope,
pattern: r.pattern,
replacement: r.replacement,
enabled: !!r.enabled,
});
};
const cancelEdit = () =>
setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
const saveEdit = async () => {
if (!editing.id || !editing.scope) return;
// Prevent rapid successive operations
if (isReloading) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Please wait for the current operation to complete.'),
timeout: 3000,
});
return;
}
setIsReloading(true);
try {
const bookKey = sideBarBookKey || '';
if (editing.scope === 'global') {
await updateReplacementRule(
environmentConfig,
bookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'global',
);
} else if (editing.scope === 'book' && sideBarBookKey) {
await updateReplacementRule(
environmentConfig,
sideBarBookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'book',
);
} else if (editing.scope === 'single' && sideBarBookKey) {
await updateReplacementRule(
environmentConfig,
sideBarBookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'single',
);
}
cancelEdit();
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Replacement rule updated. Reloading book to apply changes...'),
timeout: 3000,
});
if (sideBarBookKey) {
const { clearViewState, initViewState } = useReaderStore.getState();
const id = sideBarBookKey.split('-')[0]!;
// Hard reload: clear and reinit viewer to load from original source
clearViewState(sideBarBookKey);
await initViewState(environmentConfig, id, sideBarBookKey, true, true);
}
} catch (err) {
console.error('Failed to save replacement rule', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to update replacement rule'),
timeout: 3000,
});
} finally {
setIsReloading(false);
}
};
const deleteRule = async (ruleId: string, scope: 'single' | 'book' | 'global') => {
console.log('Deleting rule', ruleId, 'scope', scope);
// Prevent rapid successive deletions
if (isReloading) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Please wait for the book to finish reloading.'),
timeout: 3000,
});
return;
}
setIsReloading(true);
try {
const bookKey = sideBarBookKey || '';
if (scope === 'global') {
// delete global rule for all books
await removeReplacementRule(environmentConfig, '', ruleId, 'global');
} else {
await removeReplacementRule(environmentConfig, bookKey, ruleId, scope);
}
const successMessage =
scope === 'global'
? _(
'Global replacement rule deleted for all books in the library. Reloading book to apply changes...',
)
: _('Replacement rule deleted. Reloading book to apply changes...');
eventDispatcher.dispatch('toast', {
type: 'success',
message: successMessage,
timeout: 3000,
});
if (sideBarBookKey) {
const { clearViewState, initViewState } = useReaderStore.getState();
const id = sideBarBookKey.split('-')[0]!;
// Hard reload: clear and reinit viewer to load from original source
clearViewState(sideBarBookKey);
await initViewState(environmentConfig, id, sideBarBookKey, true, true);
}
} catch (err) {
console.error('Failed to delete replacement rule', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to delete replacement rule'),
timeout: 3000,
});
} finally {
setIsReloading(false);
}
};
return (
<Dialog
id='replacement_rules_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Replacement Rules')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
<div>
<h3 className='text-sm font-semibold'>{_('Single Instance Rules')}</h3>
{singleRules.length === 0 ? (
<p className='text-base-content/70 mt-2 text-sm'>
{_('No single replacement rules')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{singleRules.map((r) => (
<li key={r.id} className='rounded border p-2'>
{editing.id === r.id && editing.scope === 'single' ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Selected phrase:')}
</label>
<input
className='input input-sm flex-1 text-sm opacity-60'
value={editing.pattern}
disabled
/>
</div>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Replace with:')}
</label>
<input
className='input input-sm flex-1'
value={editing.replacement}
onChange={(e) =>
setEditing({ ...editing, replacement: e.target.value })
}
/>
</div>
<div className='flex gap-2'>
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
{_('Save')}
</button>
<button className='btn btn-sm' onClick={cancelEdit}>
{_('Cancel')}
</button>
</div>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
<div className='text-base-content/70 mt-1 break-all text-sm'>
<span className='text-base-content/80 mr-2 text-xs font-medium'>
{_('Replace with:')}
</span>
{r.replacement}
</div>
<div className='text-base-content/60 mt-1 text-xs'>
{_('Scope:')}&nbsp;<span className='font-medium'>Single Instance</span>
&nbsp;|&nbsp;{_('Case sensitive:')}&nbsp;
<span className='font-medium'>
{r.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</div>
</div>
<div className='flex items-center gap-2'>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => startEdit(r, 'single')}
aria-label={_('Edit')}
>
<RiEditLine />
</button>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => deleteRule(r.id, 'single')}
aria-label={_('Delete')}
>
<RiDeleteBin7Line />
</button>
</div>
</div>
)}
</li>
))}
</ul>
)}
<h3 className='mt-4 text-sm font-semibold'>{_('Book Specific Rules')}</h3>
{bookRules.length === 0 ? (
<p className='text-base-content/70 mt-2 text-sm'>
{_('No book-level replacement rules')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{bookRules.map((r) => {
const ruleScope = getRuleScope(r);
const isEditing = editing.id === r.id && editing.scope === ruleScope;
return (
<li key={r.id} className='rounded border p-2'>
{isEditing ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Selected phrase:')}
</label>
<input
className='input input-sm flex-1 text-sm opacity-60'
value={editing.pattern}
disabled
/>
</div>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Replace with:')}
</label>
<input
className='input input-sm flex-1'
value={editing.replacement}
onChange={(e) =>
setEditing({ ...editing, replacement: e.target.value })
}
/>
</div>
<div className='flex gap-2'>
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
{_('Save')}
</button>
<button className='btn btn-sm' onClick={cancelEdit}>
{_('Cancel')}
</button>
</div>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
<div className='text-base-content/70 mt-1 break-all text-sm'>
<span className='text-base-content/80 mr-2 text-xs font-medium'>
{_('Replace with:')}
</span>
{r.replacement}
</div>
<div className='text-base-content/60 mt-1 text-xs'>
{_('Scope:')}&nbsp;
<span className='font-medium'>
{getRuleScope(r) === 'book' ? _('Book') : _('Global')}
</span>
&nbsp;|&nbsp;{_('Case sensitive:')}&nbsp;
<span className='font-medium'>
{r.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</div>
</div>
<div className='flex items-center gap-2'>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => startEdit(r, getRuleScope(r))}
aria-label={_('Edit')}
>
<RiEditLine />
</button>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => deleteRule(r.id, ruleScope)}
aria-label={_('Delete')}
>
<RiDeleteBin7Line />
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</Dialog>
);
};
export default ReplacementRulesWindow;
@@ -61,7 +61,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
style={
isVertical
? {
top: `${contentInsets.top * 1.5}px`,
top: `${(contentInsets.top - gridInsets.top) * 1.5}px`,
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
@@ -6,12 +6,18 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import Button from '@/components/Button';
const SettingsToggler = () => {
interface SettingsTogglerProps {
bookKey: string;
}
const SettingsToggler: React.FC<SettingsTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { setHoveredBookKey } = useReaderStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const { setSettingsDialogBookKey } = useSettingsStore();
const handleToggleSettings = () => {
setHoveredBookKey('');
setSettingsDialogBookKey(bookKey);
setSettingsDialogOpen(!isSettingsDialogOpen);
};
return (
@@ -38,7 +38,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
const { user } = useAuth();
const { envConfig, appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { setSettingsDialogOpen } = useSettingsStore();
const { setSettingsDialogOpen, setSettingsDialogBookKey } = useSettingsStore();
const { getView, getViewSettings, getViewState, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
@@ -62,6 +62,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
const openFontLayoutMenu = () => {
setIsDropdownOpen?.(false);
setSettingsDialogBookKey(bookKey);
setSettingsDialogOpen(true);
};
@@ -147,7 +148,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
<Menu
className={clsx(
'view-menu dropdown-content dropdown-right no-triangle z-20 mt-1 border',
'bgcolor-base-200 border-base-200 shadow-2xl',
'bgcolor-base-200 shadow-2xl',
)}
style={{
maxWidth: `${window.innerWidth - 40}px`,
@@ -258,7 +259,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
shortcut='Shift+J'
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
disabled={bookData.bookDoc?.rendition?.layout === 'pre-paginated'}
disabled={bookData.isFixedLayout}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
@@ -0,0 +1,146 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import React, { useMemo } from 'react';
import { BookNote } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
interface AnnotationNotesProps {
bookKey: string;
isVertical: boolean;
notes: BookNote[];
toolsVisible: boolean;
triangleDir: 'up' | 'down' | 'left' | 'right';
popupWidth: number;
popupHeight: number;
onDismiss: () => void;
}
const AnnotationNotes: React.FC<AnnotationNotesProps> = ({
bookKey,
isVertical,
notes,
toolsVisible,
triangleDir,
popupWidth,
popupHeight,
onDismiss,
}) => {
const { appService } = useEnv();
const { getConfig, setConfig } = useBookDataStore();
const { setHoveredBookKey } = useReaderStore();
const { setSideBarVisible } = useSidebarStore();
const config = getConfig(bookKey);
const maxSize = useResponsiveSize(250);
const sortedNotes = useMemo(() => {
return [...notes].sort((a, b) => b.updatedAt - a.updatedAt);
}, [notes]);
const handleShowAnnotation = (note: BookNote) => {
if (!note.id) return;
if (appService?.isMobile) {
onDismiss();
}
setHoveredBookKey('');
setSideBarVisible(true);
if (config?.viewSettings) {
setConfig(bookKey, {
viewSettings: { ...config.viewSettings, sideBarTab: 'annotations' },
});
}
};
return (
<div
className={clsx('annotation-notes absolute flex rounded-lg text-white')}
style={{
...(isVertical
? {
right: triangleDir === 'left' ? `${toolsVisible ? popupWidth + 16 : 0}px` : undefined,
left: triangleDir === 'right' ? `${toolsVisible ? popupWidth + 16 : 0}px` : undefined,
height: `${popupHeight}px`,
maxWidth: `${maxSize}px`,
overflowX: 'auto',
}
: {
top: triangleDir === 'down' ? `${toolsVisible ? popupHeight + 16 : 0}px` : undefined,
bottom: triangleDir === 'up' ? `${toolsVisible ? popupHeight + 16 : 0}px` : undefined,
width: `${popupWidth}px`,
maxHeight: `${maxSize}px`,
overflowY: 'auto',
}),
scrollbarWidth: 'thin',
}}
>
<div
className={clsx('flex gap-4', isVertical ? 'h-full flex-row' : 'w-full flex-col')}
style={
isVertical
? {
display: 'grid',
gridAutoFlow: 'column',
gridAutoColumns: 'max-content',
minWidth: 'min-content',
height: `${popupHeight}px`,
maxHeight: `${popupHeight}px`,
}
: {}
}
>
{sortedNotes.map((note, index) => (
<div
role='none'
key={note.id || index}
onClick={() => handleShowAnnotation?.(note)}
className={clsx(
'popup-container cursor-pointer rounded-lg bg-gray-600 shadow-lg transition-colors',
)}
style={
isVertical
? {
minWidth: 'max-content',
height: `${popupHeight}px`,
maxHeight: `${popupHeight}px`,
}
: {}
}
>
{note.note && (
<div
dir='auto'
className={clsx(
'm-4 hyphens-auto text-justify font-sans text-sm',
'not-eink:text-white eink:text-base-content',
isVertical && 'writing-vertical-rl',
)}
style={
isVertical
? {
fontFeatureSettings: "'vrt2' 1, 'vert' 1",
minWidth: 'max-content',
}
: {}
}
>
<div className={clsx('flex flex-col justify-between gap-2')}>
{note.note}
<span className='not-eink:text-white/50 eink:text-base-content/50 text-sm sm:text-xs'>
{dayjs(note.createdAt).fromNow()}
</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
};
export default AnnotationNotes;
@@ -1,13 +1,14 @@
import clsx from 'clsx';
import React from 'react';
import Popup from '@/components/Popup';
import PopupButton from './PopupButton';
import HighlightOptions from './HighlightOptions';
import { Position } from '@/utils/sel';
import { HighlightColor, HighlightStyle } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { BookNote, HighlightColor, HighlightStyle } from '@/types/book';
import Popup from '@/components/Popup';
import AnnotationToolButton from './AnnotationToolButton';
import AnnotationNotes from './AnnotationNotes';
import HighlightOptions from './HighlightOptions';
interface AnnotationPopupProps {
bookKey: string;
dir: 'ltr' | 'rtl';
isVertical: boolean;
buttons: Array<{
@@ -17,6 +18,7 @@ interface AnnotationPopupProps {
disabled?: boolean;
visible?: boolean;
}>;
notes: BookNote[];
position: Position;
trianglePosition: Position;
highlightOptionsVisible: boolean;
@@ -25,16 +27,15 @@ interface AnnotationPopupProps {
popupWidth: number;
popupHeight: number;
onHighlight: (update?: boolean) => void;
onDismiss?: () => void;
onDismiss: () => void;
}
const OPTIONS_HEIGHT_PIX = 28;
const OPTIONS_PADDING_PIX = 16;
const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
bookKey,
dir,
isVertical,
buttons,
notes,
position,
trianglePosition,
highlightOptionsVisible,
@@ -45,8 +46,6 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
onHighlight,
onDismiss,
}) => {
const highlightOptionsHeightPx = useResponsiveSize(OPTIONS_HEIGHT_PIX);
const highlightOptionsPaddingPx = useResponsiveSize(OPTIONS_PADDING_PIX);
return (
<div dir={dir}>
<Popup
@@ -55,61 +54,62 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
minHeight={isVertical ? popupWidth : popupHeight}
position={position}
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 text-white'
className={clsx(
'selection-popup bg-gray-600 text-white',
notes.length > 0 && 'bg-transparent',
)}
triangleClassName='text-gray-600'
onDismiss={onDismiss}
>
<div
className={clsx(
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<PopupButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
<div className={clsx('flex h-full gap-4', isVertical ? 'flex-row' : 'flex-col')}>
<div
className={clsx(
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
notes.length > 0 && 'hidden',
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<AnnotationToolButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
);
})}
</div>
{notes.length > 0 ? (
<AnnotationNotes
bookKey={bookKey}
isVertical={isVertical}
notes={notes}
toolsVisible={false}
triangleDir={trianglePosition.dir!}
popupWidth={isVertical ? popupHeight : popupWidth}
popupHeight={isVertical ? popupWidth : popupHeight}
onDismiss={onDismiss}
/>
) : (
highlightOptionsVisible && (
<HighlightOptions
isVertical={isVertical}
triangleDir={trianglePosition.dir!}
popupWidth={isVertical ? popupHeight : popupWidth}
popupHeight={isVertical ? popupWidth : popupHeight}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
onHandleHighlight={onHighlight}
/>
);
})}
)
)}
</div>
</Popup>
{highlightOptionsVisible && (
<HighlightOptions
isVertical={isVertical}
style={{
width: `${isVertical ? popupHeight : popupWidth}px`,
height: `${isVertical ? popupWidth : popupHeight}px`,
...(isVertical
? {
left: `${
position.point.x +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'left' ? -1 : 1)
}px`,
top: `${position.point.y}px`,
}
: {
left: `${position.point.x}px`,
top: `${
position.point.y +
(highlightOptionsHeightPx + highlightOptionsPaddingPx) *
(trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}),
}}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
onHandleHighlight={onHighlight}
/>
)}
</div>
);
};

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