Compare commits

...

128 Commits

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

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

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

* refactor to reuse some code

* fix: pointer in doc check

---------

Co-authored-by: André Angelantoni <andre.angelantoni@performantlabs.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-01-01 07:12:07 +01:00
Huang Xin 71e97998b6 feat(transfer): add a background transfer queue system for book uploads/downloads (#2821) 2025-12-31 08:40:28 +01:00
Huang Xin 22f9c45232 fix(annotator): delay highlight action to prevent immediate onShowAnnotation trigger (#2820) 2025-12-31 08:35:26 +01:00
Huang Xin a42897ec5c feat(annotator): support quick actions for text selection, closes #2505 (#2813) 2025-12-30 10:32:01 +01:00
Huang Xin 58a5c1625c fix(css): only override background color of hr when it has background image (#2808) 2025-12-30 10:24:37 +01:00
Huang Xin d53fc09e1e chore: bump opennext, wrangler and serwist to the latest versions (#2807) 2025-12-29 05:12:32 +01:00
Huang Xin 11fecb5dc0 chore: bump tauri to latest dev (#2805) 2025-12-29 04:07:58 +01:00
Huang Xin f4587663b5 chore(ios): drop support for iOS below 15.0 (#2804) 2025-12-29 03:41:12 +01:00
Huang Xin b76a2adf61 compat(epub): detect missing writing direction for some EPUBs (#2803) 2025-12-28 19:43:49 +01:00
Huang Xin 988fbc8c85 fix(settings): override book text align of left (#2802) 2025-12-28 18:02:56 +01:00
Huang Xin f944ad9b9f feat(annotator): support rendering vertical annotation bubbles (#2801) 2025-12-28 13:52:12 +01:00
Huang Xin 335d91b9c9 fix(layout): dismiss annotation popup on mobile when navigating (#2799) 2025-12-28 07:18:03 +01:00
Huang Xin 173404eaad feat(annotator): show notes popup when annotation bubble icon is clicked, closes #1845 (#2798) 2025-12-27 17:31:47 +01:00
Huang Xin 674fed5230 fix(annotator): adjust underline position to the center of two lines, closes #2772 (#2793) 2025-12-26 09:51:02 +01:00
Huang Xin 12d284cd22 fix(layout): fixed line clamp for config items on small screens (#2792) 2025-12-26 05:05:37 +01:00
Huang Xin 77037c8adb fix(sw): use NetworkFirst for navigation to prevent blank page on updates (#2784) 2025-12-24 15:19:14 +01:00
Huang Xin 6984393ed1 refactor(proofread): refactor UI and i18n for proofread tool (#2783) 2025-12-24 14:46:33 +01:00
Huang Xin 3abef6ea75 fix(opds): correctly parse file extension for CBZ files from OPDS servers, closes #2765 (#2771) 2025-12-23 08:53:50 +01:00
Huang Xin 4ae1ab7dd0 fix(layout): workaround for hardcoded image container (#2769) 2025-12-23 06:31:09 +01:00
Huang Xin 69fb22119b fix(layout): tweak insets for vertical layout (#2767) 2025-12-23 06:01:06 +01:00
Huang Xin 3a6c18c6d5 feat(font): support OpenType feature of vrt2 and vert for vertical replacement of some punctuations (#2764) 2025-12-22 18:39:09 +01:00
Huang Xin 7db1bc460d chore(pwa): migrate from next-pwa to serwist (#2762) 2025-12-22 16:10:09 +01:00
Huang Xin a460e609fa refactor(file): avoid eviction race of chunks cache (#2761) 2025-12-22 05:59:40 +01:00
Huang Xin 4b4ebdbdaa fix(pdf): avoid frequent eviction of chunk cache from worker thread, closes #2757 (#2759) 2025-12-21 17:47:41 +01:00
Huang Xin 9358a06839 compat(layout): grid layout with fit covers on older browsers, closes #2745 (#2756) 2025-12-21 08:52:57 +01:00
Huang Xin a7baf6cc9f fix(tts): prompt users to log in to use Edge TTS on the web version (#2749) 2025-12-19 17:54:17 +01:00
Huang Xin 15dc669f35 chore: update app metainfo (#2744) 2025-12-19 04:54:24 +01:00
Huang Xin c853957512 release: version 0.9.96 (#2743) 2025-12-19 03:54:17 +01:00
Huang Xin 8a4e22e423 refactor: temporarily disable the proofreading feature for a hotfix release ahead of a major refactor (#2742) 2025-12-19 03:45:42 +01:00
Huang Xin 8a43c58fd4 fix(tts): resolve Edge TTS being blocked in certain regions (#2741)
This should close #2739 and close #1821.
2025-12-19 03:24:51 +01:00
Qianxue Ge 54fdf5f1fd feat(replacement): text replacement feature for EPUB books (#2725)
* add: basic ui replacement menu

* feat(replacement): modified ViewSettings interface and added Replacement type

* add: frontend menu ui to annotation settings
- create replacementoptions file for 4 fix options: fix once, fix in library, fix in book, fix in library
-integrate with annotator.tsx
only frontend changes, but initialzied in backend

* add: delete global option and click gear option to get rid of menu

* docs: add test cases for replacementoptions file

* edits to enable readest to build

* basic changes for rule types

* replacement transformer file added

* additional support code added

* interim updates to replacement.ts file

* adding console log statements to confirm functionality without frontend

* adding more console logs for debugging; i think i got my replacement working, will clean console logs and add actual tests now.

* figured out how to get my transformer to work. replacement doesnt actually work yet. figuring that out rn. committing before i destroy something, lol

* replcement logic working with hard coded tests. code is cleaned up with minimal console logs. actual replacement logic + testing is next :)

* test suite built, and fully passing. made consle log edits too.

* added more replacement rules, but figuring out why they arent being implemented by my code.

* cleaning up test suite to not break when there are 0 rules; test is commited with 1 local rule. not sure if that rule is going to copy over when i merge.

* feat(replacement): Add text field, case sensitivity checkbox, and confirmation dialog to ReplacementOptions

- Add text input field for replacement text with placeholder
- Add 'Case Sensitive' checkbox (default: unchecked/case-insensitive)
- Implement two-step confirmation flow with Back/Confirm buttons
- Show preview of original text, replacement text, scope, and case sensitivity
- Disable scope buttons until replacement text is entered
- Display truncated preview for long selected text (>50 chars)
- Export ReplacementConfig type for use in parent components

* feat(replacement): Add 30-word limit and integrate new ReplacementOptions component

- Add MAX_REPLACEMENT_WORDS constant (30 words)
- Add getWordCount() utility function for word counting
- Show warning toast when word limit exceeded on Text Replacement click
- Replace old fix handlers with single handleReplacementConfirm()
- Integrate with new ReplacementConfig (replacementText, caseSensitive, scope)
- Display success toast with scope and case sensitivity info on confirm

* fix(build): Add ReplacementMenu placeholder component

- Create placeholder component to fix missing import error in reader/page.tsx
- Component returns null for now, to be implemented with global replacement rules

* test(replacement): Add comprehensive tests for ReplacementOptions and word limit

ReplacementOptions.test.tsx:
- Test rendering of text input, checkbox, and scope buttons
- Test case sensitivity checkbox toggle and state
- Test disabled buttons when no replacement text entered
- Test confirmation dialog flow and Back/Confirm buttons
- Test click outside and Cancel button behavior
- Test full replacement flow with all options

wordLimit.test.ts:
- Test word counting with various inputs (spaces, newlines, unicode)
- Test 30-word limit boundary conditions
- Test case-sensitive vs case-insensitive matching logic
- Test edge cases (empty string, long words, punctuation)

* refactor: removed unused initial definition of Replacement

* feat: added replacement rules window in bookmenu

* test: added tests to verify the replacement rules window renders book and global replacement rules, and it opens when bookmenu item is clicked

* feat: added Replacement tab in SettingsDialog, displays global rules

* feat(replacement): connected front-end to functions. todo: fix the automatic reload functionality.

* fix(replacement): simplified re-rendering logic, doesn't fail on epubs anymore.

* test: add integration tests for text replacement functionality

* fix: added single rules section to ReplacementRulesWindow

* fix(replacement): added null checks to some unsafe calls in integration tests

* fix(replacements): added non-null assertion operator for a previously initialized variable

* refactor: created ReplacementPanel and edited style of inputs

* feat: disable the edit feature for selected phrase

* refactor: use toast instead of banner for confirmation msg

* feat: automatically reload the page to apply changes

* feat: disable global rule for book if deleted in book view

* fix(replacement): Improve popup positioning and eliminate ghost animation

- Add viewport boundary detection to keep popup within visible area
- Calculate position only once on mount to prevent jumping when other UI appears
- Use visibility: hidden until position is calculated to eliminate ghost animation
- Add max-height with overflow-y: auto for scrollable content
- Popup now appears directly in correct position without two-step animation

* fix: implement single-instance replacement with persistence

- Add sectionHref to TransformContext for section tracking
- Add singleInstance, sectionHref, occurrenceIndex fields to ReplacementRule
- Pass section name from FoliateViewer to transformer context
- Switch transformer from DOM-based to string-based replacement
- Handle single-instance rules with section matching and occurrence tracking
- Update Annotator to track occurrence index and apply direct DOM changes
- Persist single-instance rules for refresh survival

Single-instance replacements now:
1. Apply immediately via direct DOM modification
2. Store occurrence index and section for precise targeting
3. Persist across page refreshes

* fix: allow multiple single-instance replacements for same word

Single-instance rules now always create new entries instead of merging.
This fixes the issue where replacing multiple occurrences of the same
word would overwrite previous rules.

The transformer applies rules in sequence, so each rule targets
occurrence index 0 of the current (modified) string, allowing
cascading replacements to work correctly after refresh.

* fix: prevent cascading replacements and add wholeWord support

- Add wholeWord field to ReplacementRule for word boundary matching
- Track replaced regions to prevent replacement text from being re-matched
- Fix cascading replacement issue where replacement text was matched again
- Apply replacements from right to left to preserve positions
- Support whole word matching with \b boundaries for both single-instance and regular rules

* Fix whole-word matching for replacement rules

- Auto-enforce whole-word matching for simple word patterns (letters only)
- Add HTML tag boundary checks to prevent matching across tags
- Add double-check validation for whole-word matches
- Prevent matching 'and' inside words like 'England', 'stand', 'understand'
- Add comprehensive logging for debugging replacement issues

* test: added rAF in setup to for ReplacementOptions tests

* fix: only allow replacement for epubs, remove replacement rendering for non-epubs, add test cases

* refactor: refactored replacement logic for case sensitivity and word boundaries

* test: added tests for scope precedence and case sensitivity across scopes

* refactor: removed unnecessary code from testing

* feat: able to display, edit, and delete single-instance rules in book settings

* fix: connected case sensitive checkbox to backend, fixed merge and delete logic

* test: updated test cases to reflect changes on case sensitivity and rules rendering

* test: modified ReplacementOptions test to remove unnecessary case sensitive check from merge

* fix: add logic for grayed out button for non-epubs

* chore: update foliate-js submodule from upstream merge

* fix: resolve all TypeScript/ESLint linting errors

- Fix prefer-const error in ReplacementOptions.tsx
- Fix set-state-in-effect error in ReplacementRulesWindow.tsx (use lazy initializer)
- Replace all @typescript-eslint/no-explicit-any with proper types (ReplacementRule, unknown, etc.)
- Fix unused error variables in replacement.ts (prefix with _)
- Remove unused eslint-disable directives
- Add missing ReplacementRule import in ReplacementPanel.tsx

* fix: add localStorage mock to vitest setup

- Fixes test failures in ReplacementRulesWindow and SettingsDialog tests
- localStorage mock ensures all Storage API methods are available in test environment

* fix: resolve ESLint and TypeScript build errors

- Fix all remaining @typescript-eslint/no-explicit-any errors in test files
- Fix unused error variables in replacement.ts (prefix with _)
- Fix TypeScript error in ReplacementRulesWindow.tsx (move @ts-ignore to correct location)
- All ESLint checks now pass
- Web and Tauri builds compile successfully

* fix: remove lookbehind regex for browser compatibility

- Replace lookbehind assertions (?<!...) with manual boundary checking
- Add isUnicodeWordChar helper function for manual Unicode word boundary detection
- Apply manual boundary checks in applyMultiReplacement and applySingleInstance
- Fixes build_web_app check failures by avoiding lookbehind in compiled output
- Maintains whole-word matching functionality for both ASCII and Unicode patterns

* fix: update tauri-utils version to 2.8.1 to resolve duplicate symbol error

- Update local tauri-utils version from 2.8.0 to 2.8.1 to match crates.io version
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linker error
- Ensures all dependencies use the same tauri-utils version

* fix: use local tauri path directly to resolve version conflicts

- Change tauri dependency to use local path instead of version requirement
- This ensures all dependencies use the same local tauri version (2.9.3)
- Fixes 'links = Tauri' conflict error in Rust linting
- The patch.crates-io should still work for transitive dependencies

* fix: use version requirement with patch for tauri dependency

- Revert to using version requirement '2' instead of direct path
- Rely on [patch.crates-io] to use local tauri version
- Remove Cargo.lock to force fresh dependency resolution
- This should resolve the 'links = Tauri' conflict by ensuring
  all tauri dependencies (direct and transitive) use the patched version

* fix: remove plugin patches that cause resolution errors

- Remove all tauri-plugin git patches from [patch.crates-io]
- Keep only tauri, tauri-utils, and tauri-build patches
- Plugins from crates.io will use the patched tauri via transitive dependencies
- Fixes error: patch for tauri-plugin-oauth failed to resolve

* fix: update tauri submodule with tauri-utils version fixes

* fix: revert tauri submodule and update tauri-utils to 2.8.0

- Revert submodule changes that can't be pushed to remote
- Update local tauri-utils version to 2.8.0 to match other packages
- This avoids the need to modify the submodule

* fix: add tauri-plugin to workspace and patch to resolve duplicate symbol error

- Add packages/tauri/crates/tauri-plugin to workspace members
- Add tauri-plugin patch to [patch.crates-io]
- This ensures all tauri dependencies use local versions
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linking error

* chore: restore Cargo.lock from upstream

- Restore the original Cargo.lock from readest/readest main branch
- This ensures reproducible builds and matches upstream
- The lock file will be updated by cargo when dependencies change

* fix: resolve TypeScript errors in test files

- Fix ReplacementOptions.test.tsx: add optional chaining for possibly undefined values
- Fix ReplacementRulesWindow.test.tsx: use proper type assertions for store setState calls
- Use (store.setState as unknown as (state: unknown) => void) pattern for partial state updates

* fix: prevent race condition when deleting replacement rules rapidly

- Add isReloading state to track ongoing delete/edit operations
- Prevent multiple rapid deletions that cause runtime errors during page reload
- Show warning toast when user tries to delete while reload is in progress
- Add finally blocks to ensure isReloading is always reset
- This prevents the 'book doesn't finish rerendering' error

* fix: allow phrases and lines with quotes for single-instance replacements

- Updated isWholeWord() to allow phrases (text with spaces or punctuation)
- Phrases are always allowed for single-instance replacements
- Only single words are checked for partial word matches
- Fixes issue where lines with quotes couldn't be replaced
- Added detailed logging for debugging phrase detection

* fix: allow selections with boundary punctuation and fix pattern matching for punctuation

- Updated isWholeWord() to explicitly allow selections that start or end with punctuation (e.g., 'tis, off;, look,)
- Fixed normalizePattern() to handle patterns with leading/trailing punctuation correctly
- Word boundaries are now only added around the word part, not the punctuation
- Fixes issue where replacements like 'scholar;' were not matching correctly

* fix: escape HTML entities in replacement text to preserve angle brackets

- Added escapeHtmlEntities() function to escape HTML special characters
- Apply HTML escaping to replacement text in both multi and single-instance replacements
- Fixes issue where replacement text like '<<AND>>' was being interpreted as HTML tags
- Angle brackets and other HTML entities are now properly escaped and displayed correctly

* fix: revert Tauri backend changes and resolve package.json conflict

- Revert Cargo.toml and src-tauri/Cargo.toml to match upstream/main
- Resolve @tauri-apps/cli version conflict (2.9.5 -> 2.9.6)
- These changes are not related to the replacement feature implementation

* fix: update pnpm-lock.yaml to match @tauri-apps/cli 2.9.6

* removed useless tests and backend tests from ReplacementOptions integration testing suite

* chore: revert foliate-js submodule to match readest/readest main

* fix: refactored wordLimit logic into a separate util file

* fix: removed additional pr description

* refactor: rewrite replacement transformer to use DOM-based approach
replace string manipulation with DOMParser and TreeWalker
follow pattern from simpleecc transformer

* style: format code with prettier

* fix: remove unused string-manipulation functions

* fix: refactored display dialog logic to match other dialogs

* fix: enabled global rule deletion in book menu

* fix: removed ReplacementPanel from library settings

* fix: deleted SettingsDialog.replacement.test.tsx since we no longer need to display replacements in library settings

* fix: removed text replacement tab from settings dialog

* fix: applied prettier code formatter to replacement rules window

* chore: fix formatting and remove unused file listed by chrox

* chore: format all changed files from pr 2693 and revert pnpm-lock

* rebased Cargo.lock, package.json, pnpm-lock.yaml to upstream main
edits to enable readest to build

* basic changes for rule types

* replacement transformer file added

* additional support code added

* interim updates to replacement.ts file

* adding console log statements to confirm functionality without frontend

* adding more console logs for debugging; i think i got my replacement working, will clean console logs and add actual tests now.

* figured out how to get my transformer to work. replacement doesnt actually work yet. figuring that out rn. committing before i destroy something, lol

* replcement logic working with hard coded tests. code is cleaned up with minimal console logs. actual replacement logic + testing is next :)

* test suite built, and fully passing. made consle log edits too.

* added more replacement rules, but figuring out why they arent being implemented by my code.

* cleaning up test suite to not break when there are 0 rules; test is commited with 1 local rule. not sure if that rule is going to copy over when i merge.

* add: basic ui replacement menu

* add: frontend menu ui to annotation settings
- create replacementoptions file for 4 fix options: fix once, fix in library, fix in book, fix in library
-integrate with annotator.tsx
only frontend changes, but initialzied in backend

* add: delete global option and click gear option to get rid of menu

* docs: add test cases for replacementoptions file

* feat(replacement): modified ViewSettings interface and added Replacement type

feat(replacement): modified viewsettings interface and added ReplacementRulesConfig

* feat(replacement): Add text field, case sensitivity checkbox, and confirmation dialog to ReplacementOptions

- Add text input field for replacement text with placeholder
- Add 'Case Sensitive' checkbox (default: unchecked/case-insensitive)
- Implement two-step confirmation flow with Back/Confirm buttons
- Show preview of original text, replacement text, scope, and case sensitivity
- Disable scope buttons until replacement text is entered
- Display truncated preview for long selected text (>50 chars)
- Export ReplacementConfig type for use in parent components

* feat(replacement): Add 30-word limit and integrate new ReplacementOptions component

- Add MAX_REPLACEMENT_WORDS constant (30 words)
- Add getWordCount() utility function for word counting
- Show warning toast when word limit exceeded on Text Replacement click
- Replace old fix handlers with single handleReplacementConfirm()
- Integrate with new ReplacementConfig (replacementText, caseSensitive, scope)
- Display success toast with scope and case sensitivity info on confirm

* fix(build): Add ReplacementMenu placeholder component

- Create placeholder component to fix missing import error in reader/page.tsx
- Component returns null for now, to be implemented with global replacement rules

* test(replacement): Add comprehensive tests for ReplacementOptions and word limit

ReplacementOptions.test.tsx:
- Test rendering of text input, checkbox, and scope buttons
- Test case sensitivity checkbox toggle and state
- Test disabled buttons when no replacement text entered
- Test confirmation dialog flow and Back/Confirm buttons
- Test click outside and Cancel button behavior
- Test full replacement flow with all options

wordLimit.test.ts:
- Test word counting with various inputs (spaces, newlines, unicode)
- Test 30-word limit boundary conditions
- Test case-sensitive vs case-insensitive matching logic
- Test edge cases (empty string, long words, punctuation)

* refactor: removed unused initial definition of Replacement

* feat: added replacement rules window in bookmenu

* test: added tests to verify the replacement rules window renders book and global replacement rules, and it opens when bookmenu item is clicked

* feat: added Replacement tab in SettingsDialog, displays global rules

* fix: added single rules section to ReplacementRulesWindow

* refactor: created ReplacementPanel and edited style of inputs

* feat(replacement): connected front-end to functions. todo: fix the automatic reload functionality.

* fix(replacement): simplified re-rendering logic, doesn't fail on epubs anymore.

* test: add integration tests for text replacement functionality

* fix(replacement): added null checks to some unsafe calls in integration tests

* fix(replacements): added non-null assertion operator for a previously initialized variable

* feat: disable the edit feature for selected phrase

* refactor: use toast instead of banner for confirmation msg

* feat: automatically reload the page to apply changes

* feat: disable global rule for book if deleted in book view

* fix(replacement): Improve popup positioning and eliminate ghost animation

- Add viewport boundary detection to keep popup within visible area
- Calculate position only once on mount to prevent jumping when other UI appears
- Use visibility: hidden until position is calculated to eliminate ghost animation
- Add max-height with overflow-y: auto for scrollable content
- Popup now appears directly in correct position without two-step animation

* fix: only allow replacement for epubs, remove replacement rendering for non-epubs, add test cases

* fix: add logic for grayed out button for non-epubs

* fix: resolve all TypeScript/ESLint linting errors

- Fix prefer-const error in ReplacementOptions.tsx
- Fix set-state-in-effect error in ReplacementRulesWindow.tsx (use lazy initializer)
- Replace all @typescript-eslint/no-explicit-any with proper types (ReplacementRule, unknown, etc.)
- Fix unused error variables in replacement.ts (prefix with _)
- Remove unused eslint-disable directives
- Add missing ReplacementRule import in ReplacementPanel.tsx

* fix: add localStorage mock to vitest setup

- Fixes test failures in ReplacementRulesWindow and SettingsDialog tests
- localStorage mock ensures all Storage API methods are available in test environment

* fix: implement single-instance replacement with persistence

- Add sectionHref to TransformContext for section tracking
- Add singleInstance, sectionHref, occurrenceIndex fields to ReplacementRule
- Pass section name from FoliateViewer to transformer context
- Switch transformer from DOM-based to string-based replacement
- Handle single-instance rules with section matching and occurrence tracking
- Update Annotator to track occurrence index and apply direct DOM changes
- Persist single-instance rules for refresh survival

Single-instance replacements now:
1. Apply immediately via direct DOM modification
2. Store occurrence index and section for precise targeting
3. Persist across page refreshes

* fix: allow multiple single-instance replacements for same word

Single-instance rules now always create new entries instead of merging.
This fixes the issue where replacing multiple occurrences of the same
word would overwrite previous rules.

The transformer applies rules in sequence, so each rule targets
occurrence index 0 of the current (modified) string, allowing
cascading replacements to work correctly after refresh.

* fix: prevent cascading replacements and add wholeWord support

- Add wholeWord field to ReplacementRule for word boundary matching
- Track replaced regions to prevent replacement text from being re-matched
- Fix cascading replacement issue where replacement text was matched again
- Apply replacements from right to left to preserve positions
- Support whole word matching with \b boundaries for both single-instance and regular rules

* Fix whole-word matching for replacement rules

- Auto-enforce whole-word matching for simple word patterns (letters only)
- Add HTML tag boundary checks to prevent matching across tags
- Add double-check validation for whole-word matches
- Prevent matching 'and' inside words like 'England', 'stand', 'understand'
- Add comprehensive logging for debugging replacement issues

* refactor: refactored replacement logic for case sensitivity and word boundaries

* test: added tests for scope precedence and case sensitivity across scopes

* refactor: removed unnecessary code from testing

* feat: able to display, edit, and delete single-instance rules in book settings

* fix: connected case sensitive checkbox to backend, fixed merge and delete logic

* test: updated test cases to reflect changes on case sensitivity and rules rendering

* test: modified ReplacementOptions test to remove unnecessary case sensitive check from merge

* fix: resolve ESLint and TypeScript build errors

- Fix all remaining @typescript-eslint/no-explicit-any errors in test files
- Fix unused error variables in replacement.ts (prefix with _)
- Fix TypeScript error in ReplacementRulesWindow.tsx (move @ts-ignore to correct location)
- All ESLint checks now pass
- Web and Tauri builds compile successfully

* fix: update tauri-utils version to 2.8.1 to resolve duplicate symbol error

- Update local tauri-utils version from 2.8.0 to 2.8.1 to match crates.io version
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linker error
- Ensures all dependencies use the same tauri-utils version

* fix: use local tauri path directly to resolve version conflicts

- Change tauri dependency to use local path instead of version requirement
- This ensures all dependencies use the same local tauri version (2.9.3)
- Fixes 'links = Tauri' conflict error in Rust linting
- The patch.crates-io should still work for transitive dependencies

* fix: use version requirement with patch for tauri dependency

- Revert to using version requirement '2' instead of direct path
- Rely on [patch.crates-io] to use local tauri version
- Remove Cargo.lock to force fresh dependency resolution
- This should resolve the 'links = Tauri' conflict by ensuring
  all tauri dependencies (direct and transitive) use the patched version

* fix: remove plugin patches that cause resolution errors

- Remove all tauri-plugin git patches from [patch.crates-io]
- Keep only tauri, tauri-utils, and tauri-build patches
- Plugins from crates.io will use the patched tauri via transitive dependencies
- Fixes error: patch for tauri-plugin-oauth failed to resolve

* fix: add tauri-plugin to workspace and patch to resolve duplicate symbol error

- Add packages/tauri/crates/tauri-plugin to workspace members
- Add tauri-plugin patch to [patch.crates-io]
- This ensures all tauri dependencies use local versions
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linking error

* chore: restore Cargo.lock from upstream

- Restore the original Cargo.lock from readest/readest main branch
- This ensures reproducible builds and matches upstream
- The lock file will be updated by cargo when dependencies change

* fix: resolve TypeScript errors in test files

- Fix ReplacementOptions.test.tsx: add optional chaining for possibly undefined values
- Fix ReplacementRulesWindow.test.tsx: use proper type assertions for store setState calls
- Use (store.setState as unknown as (state: unknown) => void) pattern for partial state updates

* fix: allow selections with boundary punctuation and fix pattern matching for punctuation

- Updated isWholeWord() to explicitly allow selections that start or end with punctuation (e.g., 'tis, off;, look,)
- Fixed normalizePattern() to handle patterns with leading/trailing punctuation correctly
- Word boundaries are now only added around the word part, not the punctuation
- Fixes issue where replacements like 'scholar;' were not matching correctly

* fix: prevent race condition when deleting replacement rules rapidly

- Add isReloading state to track ongoing delete/edit operations
- Prevent multiple rapid deletions that cause runtime errors during page reload
- Show warning toast when user tries to delete while reload is in progress
- Add finally blocks to ensure isReloading is always reset
- This prevents the 'book doesn't finish rerendering' error

* fix: escape HTML entities in replacement text to preserve angle brackets

- Added escapeHtmlEntities() function to escape HTML special characters
- Apply HTML escaping to replacement text in both multi and single-instance replacements
- Fixes issue where replacement text like '<<AND>>' was being interpreted as HTML tags
- Angle brackets and other HTML entities are now properly escaped and displayed correctly

* fix: revert Tauri backend changes and resolve package.json conflict

- Revert Cargo.toml and src-tauri/Cargo.toml to match upstream/main
- Resolve @tauri-apps/cli version conflict (2.9.5 -> 2.9.6)
- These changes are not related to the replacement feature implementation

* fix: update pnpm-lock.yaml to match @tauri-apps/cli 2.9.6

* removed useless tests and backend tests from ReplacementOptions integration testing suite

* chore: revert foliate-js submodule to match readest/readest main

* fix: refactored display dialog logic to match other dialogs

* fix: enabled global rule deletion in book menu

* fix: removed ReplacementPanel from library settings

* fix: deleted SettingsDialog.replacement.test.tsx since we no longer need to display replacements in library settings

* fix: removed text replacement tab from settings dialog

* fix: applied prettier code formatter to replacement rules window

* fix: refactored wordLimit logic into a separate util file

* fix: removed additional pr description

* style: format code with prettier

* chore: fix formatting and remove unused file listed by chrox

* chore: format all changed files from pr 2693 and revert pnpm-lock

* fix: fixed inconsistencies from rebase

* refactor: removed unused code

* refactor: removed unintentional formatting changes

* fix: set upstream for packages/tauri-plugins to the readest branch

* fix: used original Cargo.lock file

* fix: got Cargo.lock from upstream

* fix: fetched SettingsDialog from upstream main

* fix: pointed tauri-plugins to the same commit as upstream

* chore: remove unnecssary comments from replacement.ts

* chore: fixed more unnecessary comments

---------

Co-authored-by: fatbiscuit247 <fatbiscuit247@github.com>
Co-authored-by: joon <your.email@example.com>
Co-authored-by: jarchenn <jerryc2@andrew.cmu.edu>
Co-authored-by: joon0429 <68578999+joon0429@users.noreply.github.com>
Co-authored-by: Jerry Chen <50bmg@Jerrys-MacBook-Pro-9.local>
Co-authored-by: Alicia Chen <aliciach@andrew.cmu.edu>
Co-authored-by: Jerry Chen <50bmg@MacBook-Pro-7.local>
Co-authored-by: Jerry Chen <50bmg@macbook-pro-158.wifi.local.cmu.edu>
Co-authored-by: fatbiscuit247 <136537548+fatbiscuit247@users.noreply.github.com>
2025-12-17 10:06:59 +08:00
Huang Xin fe50b513b3 fix(layout): line clamp opds url, closes #2726 (#2731) 2025-12-16 17:03:15 +01:00
Huang Xin 17c7fa8f41 fix: make sidebar and notebook pin states persist after refresh (#2730) 2025-12-16 16:16:21 +01:00
Huang Xin 2533560d11 fix(layout): fix bleed layout for images (#2729) 2025-12-16 15:46:13 +01:00
Huang Xin 5850a16afd fix: add stats API and fix fd leak, closes #2323 (#2723) 2025-12-16 06:51:48 +01:00
Huang Xin 7063d62b13 fix(settings): screen brightness setting only applies to the reader page, closes #2717 (#2720) 2025-12-15 06:04:29 +01:00
dependabot[bot] 0bd6a217ae chore(deps): bump actions/cache from 4 to 5 in the github-actions group (#2719)
Bumps the github-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 05:14:57 +01:00
Huang Xin b7df294d78 feat(bookshelf): add group books button in context menu, closes #2698 (#2718) 2025-12-15 05:02:52 +01:00
Huang Xin 5aa78f2554 fix(opds): expose X-Content-Length header for CORS requests (#2715) 2025-12-14 19:06:09 +01:00
Huang Xin e740571c33 feat(opds): instant search bar for opds catalog, closes #2707 (#2714) 2025-12-14 18:25:47 +01:00
Huang Xin e6d9913f4e fix(layout): make sure annotation popups can be accessible in some edge cases, closes #2704 (#2713) 2025-12-14 05:52:55 +01:00
Huang Xin 1869a863a3 fix(layout): fixed max inline width not applied for EPUBs, closes #2706 (#2711) 2025-12-13 18:52:26 +01:00
Huang Xin 524de92f5e feat(macOS): add open file global menu for macOS, closes #2692 (#2708) 2025-12-13 17:36:25 +01:00
Huang Xin c1530cc5c4 feat(iOS): support open file with Readest in Files App, closes #2334 (#2705) 2025-12-13 13:28:03 +01:00
Huang Xin 730fadb834 fix(web): fixed router glitches for library page after returned from reader (#2703) 2025-12-13 08:08:42 +01:00
Huang Xin 383e5c61b1 chore: bump next.js to version 16.0.10 (#2702) 2025-12-13 07:54:15 +01:00
Huang Xin 6d42086fa7 fix(layout): fixed the layout of the selector of the translator providers (#2701) 2025-12-13 05:14:12 +01:00
mikepmiller 5a20fae204 feat(ui): progress info with cycleable display modes (#2682)
* Hideable Progress View
* feat: cycle between progress info modes

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2025-12-12 08:19:07 +01:00
Huang Xin 34fd64c5c4 fix(sync): handle special characters in filenames when downloading (#2694) 2025-12-11 19:20:27 +01:00
Huang Xin 0874fb0764 chore: bump tauri to the latest dev branch (#2690) 2025-12-11 13:46:41 +01:00
Huang Xin e03ed5b604 fix(layout): fix responsive layout for footnote popup (#2688) 2025-12-11 10:15:56 +01:00
Huang Xin 41edc89ac7 fix(pwa): don't cache api requests and cache client-side navigation routes (#2687) 2025-12-11 07:30:19 +01:00
Huang Xin f0a470398d chore(pwa): more aggressive offline cache for the web version (#2686) 2025-12-11 05:24:45 +01:00
Huang Xin 51008d81fb fix(opds): select proper opds search link (#2683) 2025-12-10 19:56:27 +01:00
Huang Xin 9828904674 feat(opds): added books catalog from standardebooks.org (#2681) 2025-12-10 18:20:56 +01:00
Huang Xin 2670d835b3 fix(comic): fixed layout for comic books, closes #2672 (#2680) 2025-12-10 18:08:11 +01:00
Huang Xin 8b7bafc4b6 chore(koplugin): add version info in the meta file (#2679) 2025-12-10 16:52:28 +01:00
Huang Xin ca759e0246 fix(tts): avoid false default en language code for TTS (#2678) 2025-12-10 16:24:01 +01:00
Huang Xin 5141be1c3f fix(iap): don't initialize billing on Android without google play service, closes #2630 (#2677) 2025-12-10 15:34:23 +01:00
Huang Xin 669d3950e2 chore: repackaging readest koplugin for updater, closes #2669 (#2676) 2025-12-10 13:37:32 +01:00
Huang Xin b95895cecf compat(opds): fallback to Basic auth if no WWW-Authenticate challenge in the response headers, closes #2656 (#2673) 2025-12-10 09:57:44 +01:00
Huang Xin 80e11bb0ce refactor(layout): refactor page margins for pixel precision, closes #2652 (#2663) 2025-12-09 16:19:14 +01:00
Huang Xin de3a539621 fix(footnote): add custom attributes for footnote in sanitizer, closes #2651 (#2657) 2025-12-09 05:27:52 +01:00
jacobi petrucciani b425bfdc89 chore: add rust and node deps to the nix devshell (#2655) 2025-12-09 04:44:53 +01:00
Huang Xin 1d1fbdffdb fix(macOS): delay writing to clipboard to ensure it won't be overridden by system clipboard actions, closes #2647 (#2649) 2025-12-08 14:05:27 +01:00
Huang Xin 50cd7f80c6 feat: refresh account info after managing cloud storage (#2648) 2025-12-08 13:13:20 +01:00
Huang Xin 6eb7d91122 release: version 0.9.95 (#2646) 2025-12-08 08:59:21 +01:00
Huang Xin 1fb468b3a6 fix(epub): support SVG cover for ebooks from standardebooks.org (#2645) 2025-12-08 08:52:17 +01:00
Huang Xin 3c7d95cf10 fix(footnote): responsive popup size so that on small screen it won't overflow (#2644) 2025-12-08 07:40:30 +01:00
Huang Xin ba3f060cc4 feat: add support for importing from a directory recursively, closes #179 (#2642) 2025-12-08 07:16:57 +01:00
Huang Xin 11bc7497e8 feat: add support for renaming bookshelf groups (#2639) 2025-12-07 10:04:23 +01:00
Huang Xin fb5d149413 fix: enable shared-intent event listener only on Android for now (#2638) 2025-12-07 06:45:56 +01:00
Huang Xin 42b47d73b7 feat: support cloud storage management (#2636) 2025-12-06 20:25:40 +01:00
Huang Xin 00f36af03a feat(opds): add support to search in OPDS, closes #2598 (#2634) 2025-12-06 12:13:45 +01:00
Huang Xin b78466ca93 fix(opds): relax img-src CSP to support images served from arbitrary HTTP/HTTPS hosts and ports, closes #2631 (#2633) 2025-12-06 04:22:15 +01:00
Huang Xin 4e6f146b8f feat(android): support opening shared files from other apps, closes #2484 (#2628) 2025-12-05 18:13:06 +01:00
Huang Xin cbdd4940d0 fix(android): intercept back button press for Android 15+, closes #2454 (#2626) 2025-12-05 16:27:09 +01:00
Huang Xin d022cb984a chore: bump various dependencies (#2624) 2025-12-05 07:48:03 +01:00
Huang Xin 8de6fa267e fix(cache): invalidate config and doc cache, closes #2595 and closes #2572 (#2623) 2025-12-05 07:03:57 +01:00
Huang Xin b08b7de8e9 fix(tts): fixed highlighting of current sentence for native tts on Android, closes #2620 (#2621) 2025-12-05 04:05:18 +01:00
Huang Xin a232a39f0e fix(pdf): Fixed zoomed layout and hand tool event handling, closes #2596 (#2617) 2025-12-04 19:22:01 +01:00
Huang Xin fad7966fc4 fix(layout): auto two-column layout for unfolded screen, closes #2588 (#2615) 2025-12-04 06:56:23 +01:00
Huang Xin a1487fd60c fix: get rid of the context menu for touch screen or stylus device when selecting text, closes #2579 (#2614) 2025-12-04 06:04:06 +01:00
Huang Xin 9606e315d4 fix(layout): hide overflow of children elements in duokan bleed, closes #2597 (#2613) 2025-12-04 03:40:43 +01:00
Huang Xin 978673268b chore: bump next.js to version 16.0.7 (#2612) 2025-12-04 02:34:10 +01:00
Huang Xin 70158a7f15 refactor(opds): use catalog id instead of credentials in url params, closes #2599 (#2606) 2025-12-03 08:50:55 +01:00
Huang Xin 18d65a2c5b fix(annotator): don't copy selection to notebook with keyboard shortcut by default, closes #2603 (#2605) 2025-12-03 07:08:00 +01:00
Huang Xin 1b0c2afad7 fix(layout): fixed scrollable layout in the about readest window, closes #2593 (#2604) 2025-12-03 06:23:02 +01:00
Huang Xin cef444d374 fix: disable saving last book cover with playstore variant, closes #2600 (#2602) 2025-12-03 05:41:44 +01:00
Huang Xin 75f6efe27a compat(opds): add User-Agent header to fix downloads from Calibre Web OPDS (#2592) 2025-12-02 10:27:33 +01:00
Huang Xin 852f9f40ec chore: fix cross compiling of thumbnail extension (#2587) 2025-12-02 02:27:03 +08:00
Huang Xin b9dadc0f4f chore: update flathub metainfo (#2586) 2025-12-01 18:07:29 +01:00
250 changed files with 17356 additions and 4574 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
cache: pnpm
- name: cache Next.js build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: apps/readest-app/.next/cache
key: nextjs-${{ matrix.config.platform }}-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
+7 -5
View File
@@ -69,7 +69,7 @@ jobs:
repo: context.repo.repo,
tag_name: process.env.release_tag,
})
const notes = process.env.release_note.split(/(?:\d\.\s)/).filter(Boolean);
const notes = process.env.release_note.split(/\d+\.\s/).filter(Boolean);
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
github.rest.repos.updateRelease({
@@ -95,10 +95,12 @@ jobs:
run: |
version=${{ needs.get-release.outputs.release_version }}
plugin_zip="Readest-${version}-1.koplugin.zip"
meta_file="apps/readest.koplugin/_meta.lua"
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
cd apps/readest.koplugin
zip -r ../../${plugin_zip} .
cd ../..
cd apps
zip -r ../${plugin_zip} readest.koplugin
cd ..
echo "Uploading ${plugin_zip} to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
@@ -321,7 +323,7 @@ jobs:
echo "Building Portable Binaries"
pushd apps/readest-app/
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
pnpm tauri build
pnpm tauri build ${{ matrix.config.args }}
popd
echo "Uploading Portable Binaries"
+2
View File
@@ -43,3 +43,5 @@ fastlane/report.xml
*.koplugin.zip
# nix
result*
Generated
+256 -175
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -61,6 +61,7 @@
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
## Planned Features
@@ -72,7 +73,6 @@
| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ------------ |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🛠 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
+14 -35
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',
@@ -64,39 +63,19 @@ const nextConfig = {
},
};
const withPWA = withPWAInit({
dest: 'public',
disable: isDev || appPlatform !== 'web',
cacheStartUrl: false,
dynamicStartUrl: false,
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
swcMinify: true,
fallbacks: {
document: '/offline',
},
workboxOptions: {
disableDevLogs: true,
manifestTransforms: [
(manifestEntries) => {
const manifest = manifestEntries.filter((entry) => {
const url = entry.url;
return (
!url.includes('dynamic-css-manifest.json') &&
!url.includes('middleware-manifest.json') &&
!url.includes('react-loadable-manifest.json') &&
!url.includes('build-manifest.json') &&
!url.includes('_buildManifest.js') &&
!url.includes('_ssgManifest.js') &&
!url.includes('_headers')
);
});
return { manifest };
},
],
},
});
const pwaDisabled = isDev || appPlatform !== 'web';
const withPWA = pwaDisabled
? (config) => config
: withSerwistInit({
swSrc: 'src/sw.ts',
swDest: 'public/sw.js',
cacheOnNavigation: true,
reloadOnOnline: true,
disable: false,
register: true,
scope: '/',
});
const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
+18 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.94",
"version": "0.9.97",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -37,7 +37,7 @@
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0",
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0 --port 3001",
"deploy": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare deploy",
"upload": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
@@ -53,15 +53,15 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.735.0",
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.13.1",
"@opennextjs/cloudflare": "^1.14.7",
"@serwist/next": "^9.4.2",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.76.1",
"@tauri-apps/api": "2.9.0",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-cli": "^2.4.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
@@ -74,6 +74,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "~2.3.3",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-websocket": "~2.4.1",
"@zip.js/zip.js": "^2.7.53",
"abortcontroller-polyfill": "^1.7.8",
"app-store-server-api": "^0.17.1",
@@ -84,7 +85,7 @@
"dompurify": "^3.3.0",
"foliate-js": "workspace:*",
"franc-min": "^6.2.0",
"google-auth-library": "^10.4.1",
"google-auth-library": "^10.5.0",
"googleapis": "^164.1.0",
"highlight.js": "^11.11.1",
"i18next": "^24.2.0",
@@ -92,10 +93,11 @@
"i18next-http-backend": "^3.0.1",
"iso-639-2": "^3.0.2",
"iso-639-3": "^3.0.1",
"isomorphic-ws": "^5.0.0",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"marked": "^15.0.12",
"next": "16.0.3",
"next": "16.0.10",
"overlayscrollbars": "^2.11.4",
"overlayscrollbars-react": "^0.5.6",
"posthog-js": "^1.246.0",
@@ -105,19 +107,21 @@
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-responsive": "^10.0.0",
"react-virtuoso": "^4.17.0",
"react-window": "^1.8.11",
"semver": "^7.7.1",
"stripe": "^18.2.1",
"styled-jsx": "^5.1.7",
"tinycolor2": "^1.6.0",
"uuid": "^11.1.0",
"ws": "^8.18.3",
"zod": "^4.0.8",
"zustand": "5.0.6"
},
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.9.4",
"@tauri-apps/cli": "2.9.6",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/cors": "^2.8.17",
@@ -130,9 +134,10 @@
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@vitejs/plugin-react": "^4.7.0",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.20",
"caniuse-lite": "^1.0.30001746",
"cpx2": "^8.0.0",
@@ -149,10 +154,11 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"serwist": "^9.3.0",
"tailwindcss": "^3.4.18",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"wrangler": "^4.50.0"
"vitest": "^4.0.15",
"wrangler": "^4.56.0"
}
}
@@ -715,9 +715,7 @@
"Validating...": "جارٍ التحقق...",
"View All": "عرض الكل",
"Forward": "إلى الأمام",
"OPDS Catalog": "كتالوج OPDS",
"Home": "الصفحة الرئيسية",
"Library": "المكتبة",
"{{count}} items_zero": "{{count}} عناصر",
"{{count}} items_one": "{{count}} عنصر",
"{{count}} items_two": "{{count}} عنصران",
@@ -741,5 +739,142 @@
"Last": "الأخير",
"Cannot Load Page": "تعذر تحميل الصفحة",
"An error occurred": "حدث خطأ ما",
"Online Library": "المكتبة عبر الإنترنت"
"Online Library": "المكتبة عبر الإنترنت",
"URL must start with http:// or https://": "يجب أن يبدأ عنوان URL بـ http:// أو https://",
"Title, Author, Tag, etc...": "العنوان، المؤلف، العلامة، إلخ...",
"Query": "استعلام",
"Subject": "موضوع",
"Enter {{terms}}": "أدخل {{terms}}",
"No search results found": "لم يتم العثور على نتائج بحث",
"Failed to load OPDS feed: {{status}} {{statusText}}": "فشل في تحميل تغذية OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "البحث في {{title}}",
"Manage Storage": "إدارة التخزين",
"Failed to load files": "فشل في تحميل الملفات",
"Deleted {{count}} file(s)_zero": "لم يتم حذف أي ملفات",
"Deleted {{count}} file(s)_one": "تم حذف ملف واحد",
"Deleted {{count}} file(s)_two": "تم حذف ملفين",
"Deleted {{count}} file(s)_few": "تم حذف {{count}} ملفات",
"Deleted {{count}} file(s)_many": "تم حذف {{count}} ملفًا",
"Deleted {{count}} file(s)_other": "تم حذف {{count}} ملف",
"Failed to delete {{count}} file(s)_zero": "فشل حذف أي ملفات",
"Failed to delete {{count}} file(s)_one": "فشل حذف ملف واحد",
"Failed to delete {{count}} file(s)_two": "فشل حذف ملفين",
"Failed to delete {{count}} file(s)_few": "فشل حذف {{count}} ملفات",
"Failed to delete {{count}} file(s)_many": "فشل حذف {{count}} ملفًا",
"Failed to delete {{count}} file(s)_other": "فشل حذف {{count}} ملف",
"Failed to delete files": "فشل حذف الملفات",
"Total Files": "إجمالي الملفات",
"Total Size": "إجمالي الحجم",
"Quota": "الحصة",
"Used": "المستخدم",
"Files": "الملفات",
"Search files...": "ابحث في الملفات...",
"Newest First": "الأحدث أولاً",
"Oldest First": "الأقدم أولاً",
"Largest First": "الأكبر أولاً",
"Smallest First": "الأصغر أولاً",
"Name A-Z": "الاسم من أ إلى ي",
"Name Z-A": "الاسم من ي إلى أ",
"{{count}} selected_zero": "لا يوجد عناصر محددة",
"{{count}} selected_one": "عنصر واحد محدد",
"{{count}} selected_two": "عنصران محددان",
"{{count}} selected_few": "{{count}} عناصر محددة",
"{{count}} selected_many": "{{count}} عنصرًا محددًا",
"{{count}} selected_other": "{{count}} عنصر محدد",
"Delete Selected": "حذف المحدد",
"Created": "تاريخ الإنشاء",
"No files found": "لا توجد ملفات",
"No files uploaded yet": "لم يتم رفع أي ملفات بعد",
"files": "الملفات",
"Page {{current}} of {{total}}": "الصفحة {{current}} من {{total}}",
"Are you sure to delete {{count}} selected file(s)?_zero": "هل أنت متأكد من حذف العناصر المحددة؟ (لا توجد عناصر)",
"Are you sure to delete {{count}} selected file(s)?_one": "هل أنت متأكد من حذف ملف واحد محدد؟",
"Are you sure to delete {{count}} selected file(s)?_two": "هل أنت متأكد من حذف ملفين محددين؟",
"Are you sure to delete {{count}} selected file(s)?_few": "هل أنت متأكد من حذف {{count}} ملفات محددة؟",
"Are you sure to delete {{count}} selected file(s)?_many": "هل أنت متأكد من حذف {{count}} ملفًا محددًا؟",
"Are you sure to delete {{count}} selected file(s)?_other": "هل أنت متأكد من حذف {{count}} ملف محدد؟",
"Cloud Storage Usage": "استخدام التخزين السحابي",
"Rename Group": "إعادة تسمية المجموعة",
"From Directory": "من الدليل",
"Successfully imported {{count}} book(s)_zero": "لم يتم استيراد أي كتب",
"Successfully imported {{count}} book(s)_one": "تم استيراد كتاب واحد",
"Successfully imported {{count}} book(s)_two": "تم استيراد كتابين",
"Successfully imported {{count}} book(s)_few": "تم استيراد {{count}} كتب",
"Successfully imported {{count}} book(s)_many": "تم استيراد {{count}} كتابًا",
"Successfully imported {{count}} book(s)_other": "تم استيراد {{count}} كتاب",
"Count": "العدد",
"Start Page": "الصفحة الأولى",
"Search in OPDS Catalog...": "البحث في كتالوج OPDS...",
"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": "انقر لتبديل التذييل"
}
@@ -699,9 +699,7 @@
"Validating...": "যাচাই করা হচ্ছে...",
"View All": "সব দেখুন",
"Forward": "ফরোয়ার্ড",
"OPDS Catalog": "OPDS ক্যাটালগ",
"Home": "হোম",
"Library": "লাইব্রেরি",
"{{count}} items_one": "{{count}} আইটেম",
"{{count}} items_other": "{{count}} আইটেম",
"Download completed": "ডাউনলোড সম্পন্ন হয়েছে",
@@ -721,5 +719,122 @@
"Last": "শেষ",
"Cannot Load Page": "পৃষ্ঠা লোড করা যায়নি",
"An error occurred": "একটি ত্রুটি ঘটেছে",
"Online Library": "অনলাইন লাইব্রেরি"
"Online Library": "অনলাইন লাইব্রেরি",
"URL must start with http:// or https://": "URL অবশ্যই http:// বা https:// দিয়ে শুরু হতে হবে",
"Title, Author, Tag, etc...": "শিরোনাম, লেখক, ট্যাগ, ইত্যাদি...",
"Query": "কোয়েরি",
"Subject": "বিষয়",
"Enter {{terms}}": "{{terms}} লিখুন",
"No search results found": "কোনও অনুসন্ধান ফলাফল পাওয়া যায়নি",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ফিড লোড করতে ব্যর্থ: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} এ অনুসন্ধান করুন",
"Manage Storage": "স্টোরেজ ম্যানেজমেন্ট",
"Failed to load files": "ফাইল লোড করতে ব্যর্থ",
"Deleted {{count}} file(s)_one": "{{count}}টি ফাইল মুছে ফেলা হয়েছে",
"Deleted {{count}} file(s)_other": "{{count}}টি ফাইল মুছে ফেলা হয়েছে",
"Failed to delete {{count}} file(s)_one": "{{count}}টি ফাইল মুছতে ব্যর্থ",
"Failed to delete {{count}} file(s)_other": "{{count}}টি ফাইল মুছতে ব্যর্থ",
"Failed to delete files": "ফাইল মুছতে ব্যর্থ",
"Total Files": "মোট ফাইল",
"Total Size": "মোট আকার",
"Quota": "কোটা",
"Used": "ব্যবহৃত",
"Files": "ফাইল",
"Search files...": "ফাইল খুঁজুন...",
"Newest First": "নতুন আগে",
"Oldest First": "পুরনো আগে",
"Largest First": "বড় আগে",
"Smallest First": "ছোট আগে",
"Name A-Z": "নাম A-Z",
"Name Z-A": "নাম Z-A",
"{{count}} selected_one": "{{count}}টি নির্বাচিত",
"{{count}} selected_other": "{{count}}টি নির্বাচিত",
"Delete Selected": "নির্বাচিত মুছুন",
"Created": "তৈরি হয়েছে",
"No files found": "কোনো ফাইল পাওয়া যায়নি",
"No files uploaded yet": "এখনও কোনো ফাইল আপলোড করা হয়নি",
"files": "ফাইল",
"Page {{current}} of {{total}}": "{{total}}টির মধ্যে {{current}} পৃষ্ঠা",
"Are you sure to delete {{count}} selected file(s)?_one": "আপনি কি নিশ্চিত যে {{count}}টি নির্বাচিত ফাইল মুছতে চান?",
"Are you sure to delete {{count}} selected file(s)?_other": "আপনি কি নিশ্চিত যে {{count}}টি নির্বাচিত ফাইল মুছতে চান?",
"Cloud Storage Usage": "ক্লাউড স্টোরেজ ব্যবহৃত",
"Rename Group": "গ্রুপের নাম পরিবর্তন করুন",
"From Directory": "ডিরেক্টরি থেকে",
"Successfully imported {{count}} book(s)_one": "সফলভাবে ১টি বই আমদানি করা হয়েছে",
"Successfully imported {{count}} book(s)_other": "সফলভাবে {{count}}টি বই আমদানি করা হয়েছে",
"Count": "গণনা",
"Start Page": "শুরু পৃষ্ঠা",
"Search in OPDS Catalog...": "OPDS ক্যাটালগে অনুসন্ধান করুন...",
"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": "ফুটার টগল করতে আলতো চাপুন"
}
@@ -695,9 +695,7 @@
"Validating...": "བདེན་སྦྱོར་བཞིན་...",
"View All": "ཡོངས་ལྟ་བ་",
"Forward": "མདུན་དུ་",
"OPDS Catalog": "OPDS དཀར་ཆག",
"Home": "གཙོ་ངོས་",
"Library": "དེབ་མཛོད་",
"{{count}} items_other": "{{count}} རྣམ་གྲངས་",
"Download completed": "ཕབ་ལེན་རྫོགས་སོང་།",
"Download failed": "ཕབ་ལེན་ཕམ་པ་",
@@ -716,5 +714,117 @@
"Last": "མཐའ་མ།",
"Cannot Load Page": "ཤོག་ངོས་འགུལ་སྐྱོང་བྱེད་ཐུབ་མེད།",
"An error occurred": "ནོར་འཁྲུལ་ཞིག་བྱུང་སོང་།",
"Online Library": "དྲ་རྒྱུན་དེབ་མཛོད།"
"Online Library": "དྲ་རྒྱུན་དེབ་མཛོད།",
"URL must start with http:// or https://": "URL ནི་ http:// ཡང་ https:// ནས་འགོ་བཙུགས་དགོ།",
"Title, Author, Tag, etc...": "མིང་།, རྩོམ་པ།, མཚོན་འགྲེལ།, དེ་ལས་སྐུགས་...",
"Query": "འཚོལ་ཞིབ་",
"Subject": "དོན་ཚན་",
"Enter {{terms}}": "{{terms}} ལ་འགྲོ།",
"No search results found": "འཚོལ་ཞིབ་རྫོགས་མ་ཐུབ།",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS འཕྲིན་འདེམས་བྱས་མ་ཐུབ།: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} ནང་འཚོལ།",
"Manage Storage": "སྣོད་གསོག་དོ་དམ་བྱེད་པ",
"Failed to load files": "ཡིག་ཆ་སྣོན་པ་ཕམ་པ",
"Deleted {{count}} file(s)_other": "ཡིག་ཆ་ {{count}} བསུབས་ཟིན་པ",
"Failed to delete {{count}} file(s)_other": "ཡིག་ཆ་ {{count}} བསུབས་པ་ཕམ་པ",
"Failed to delete files": "ཡིག་ཆ་བསུབས་པ་ཕམ་པ",
"Total Files": "ཡིག་ཆ་ཡོངས་བསྡོམས",
"Total Size": "ཆེས་ཆེར་ཆེ་ཆུང",
"Quota": "ཁུལ་ཚད",
"Used": "ལག་ལེན་བྱས་ཟིན་པ",
"Files": "ཡིག་ཆ",
"Search files...": "ཡིག་ཆ་འཚོལ...",
"Newest First": "གསར་ཤོས་སྔོན་དུ",
"Oldest First": "རྙིང་ཤོས་སྔོན་དུ",
"Largest First": "ཆེ་ཤོས་སྔོན་དུ",
"Smallest First": "ཆུང་ཤོས་སྔོན་དུ",
"Name A-Z": "མིང་ A-Z",
"Name Z-A": "མིང་ Z-A",
"{{count}} selected_other": "{{count}} ཡིག་ཆ་འདེམས་ཟིན་པ",
"Delete Selected": "འདེམས་པ་བསུབས་པ",
"Created": "སྤེལ་བྱས་ཟིན་པ",
"No files found": "ཡིག་ཆ་མ་རྙེད་པ",
"No files uploaded yet": "ད་ཚུན་ཡིག་ཆ་སྣོན་མི་འདུག",
"files": "ཡིག་ཆ",
"Page {{current}} of {{total}}": "ཤོག་ངོས་ {{total}} ནས་ {{current}}",
"Are you sure to delete {{count}} selected file(s)?_other": "ཁྱེད་ཀྱིས་འདེམས་པའི་ཡིག་ཆ་ {{count}} བསུབས་དགོས་པ་ངེས་ཡིན་ན?",
"Cloud Storage Usage": "སྤྲིན་གནས་སྣོད་གསོག་ལུས་སྐོར།",
"Rename Group": "ཚོགས་མིང་བསྒྱུར་བ།",
"From Directory": "སྐོར་འདེམས་པ་ནས།",
"Successfully imported {{count}} book(s)_other": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་ {{count}} དེབ་འདེམས་སོང་",
"Count": "ཨང་",
"Start Page": "ཤོག་ངོས་དང་པོ",
"Search in OPDS Catalog...": "OPDS དཀར་ཆག་ནང་འཚོལ།...",
"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": "ཞབས་མཇུག་སྒོ་འབྱེད་བྱེད་པར་གནོན།"
}
@@ -699,9 +699,7 @@
"Validating...": "Wird überprüft...",
"View All": "Alle anzeigen",
"Forward": "Weiter",
"OPDS Catalog": "OPDS-Katalog",
"Home": "Start",
"Library": "Bibliothek",
"{{count}} items_one": "{{count}} Element",
"{{count}} items_other": "{{count}} Elemente",
"Download completed": "Download abgeschlossen",
@@ -721,5 +719,122 @@
"Last": "Letzte",
"Cannot Load Page": "Seite kann nicht geladen werden",
"An error occurred": "Ein Fehler ist aufgetreten",
"Online Library": "Online-Bibliothek"
"Online Library": "Online-Bibliothek",
"URL must start with http:// or https://": "Die URL muss mit http:// oder https:// beginnen",
"Title, Author, Tag, etc...": "Titel, Autor, Tag, etc...",
"Query": "Suchbegriff",
"Subject": "Thema",
"Enter {{terms}}": "Gib {{terms}} ein",
"No search results found": "Keine Suchergebnisse gefunden",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS-Feed konnte nicht geladen werden: {{status}} {{statusText}}",
"Search in {{title}}": "Suche in {{title}}",
"Manage Storage": "Speicher verwalten",
"Failed to load files": "Dateien konnten nicht geladen werden",
"Deleted {{count}} file(s)_one": "{{count}} Datei gelöscht",
"Deleted {{count}} file(s)_other": "{{count}} Dateien gelöscht",
"Failed to delete {{count}} file(s)_one": "Löschen von {{count}} Datei fehlgeschlagen",
"Failed to delete {{count}} file(s)_other": "Löschen von {{count}} Dateien fehlgeschlagen",
"Failed to delete files": "Dateien konnten nicht gelöscht werden",
"Total Files": "Gesamtdateien",
"Total Size": "Gesamtgröße",
"Quota": "Kontingent",
"Used": "Verwendet",
"Files": "Dateien",
"Search files...": "Dateien suchen...",
"Newest First": "Neueste zuerst",
"Oldest First": "Älteste zuerst",
"Largest First": "Größte zuerst",
"Smallest First": "Kleinste zuerst",
"Name A-Z": "Name AZ",
"Name Z-A": "Name ZA",
"{{count}} selected_one": "{{count}} ausgewählt",
"{{count}} selected_other": "{{count}} ausgewählt",
"Delete Selected": "Ausgewählte löschen",
"Created": "Erstellt",
"No files found": "Keine Dateien gefunden",
"No files uploaded yet": "Noch keine Dateien hochgeladen",
"files": "Dateien",
"Page {{current}} of {{total}}": "Seite {{current}} von {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Möchten Sie {{count}} ausgewählte Datei wirklich löschen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Möchten Sie {{count}} ausgewählte Dateien wirklich löschen?",
"Cloud Storage Usage": "Cloud-Speichernutzung",
"Rename Group": "Gruppe umbenennen",
"From Directory": "Aus Verzeichnis",
"Successfully imported {{count}} book(s)_one": "Erfolgreich 1 Buch importiert",
"Successfully imported {{count}} book(s)_other": "Erfolgreich {{count}} Bücher importiert",
"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",
"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"
}
@@ -699,9 +699,7 @@
"Validating...": "Γίνεται έλεγχος...",
"View All": "Προβολή όλων",
"Forward": "Μπροστά",
"OPDS Catalog": "Κατάλογος OPDS",
"Home": "Αρχική",
"Library": "Βιβλιοθήκη",
"{{count}} items_one": "{{count}} στοιχείο",
"{{count}} items_other": "{{count}} στοιχεία",
"Download completed": "Η λήψη ολοκληρώθηκε",
@@ -721,5 +719,122 @@
"Last": "Τελευταίο",
"Cannot Load Page": "Δεν είναι δυνατή η φόρτωση της σελίδας",
"An error occurred": "Προέκυψε σφάλμα",
"Online Library": "Online Βιβλιοθήκη"
"Online Library": "Online Βιβλιοθήκη",
"URL must start with http:// or https://": "Το URL πρέπει να ξεκινά με http:// ή https://",
"Title, Author, Tag, etc...": "Τίτλος, Συγγραφέας, Ετικέτα, κ.λπ...",
"Query": "Ερώτημα",
"Subject": "Θέμα",
"Enter {{terms}}": "Εισαγάγετε {{terms}}",
"No search results found": "Δεν βρέθηκαν αποτελέσματα αναζήτησης",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Αποτυχία φόρτωσης ροής OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Αναζήτηση στο {{title}}",
"Manage Storage": "Διαχείριση αποθήκευσης",
"Failed to load files": "Αποτυχία φόρτωσης αρχείων",
"Deleted {{count}} file(s)_one": "Διαγράφηκε {{count}} αρχείο",
"Deleted {{count}} file(s)_other": "Διαγράφηκαν {{count}} αρχεία",
"Failed to delete {{count}} file(s)_one": "Αποτυχία διαγραφής {{count}} αρχείου",
"Failed to delete {{count}} file(s)_other": "Αποτυχία διαγραφής {{count}} αρχείων",
"Failed to delete files": "Αποτυχία διαγραφής αρχείων",
"Total Files": "Σύνολο αρχείων",
"Total Size": "Συνολικό μέγεθος",
"Quota": "Όριο",
"Used": "Χρησιμοποιήθηκε",
"Files": "Αρχεία",
"Search files...": "Αναζήτηση αρχείων...",
"Newest First": "Νεότερα πρώτα",
"Oldest First": "Παλαιότερα πρώτα",
"Largest First": "Μεγαλύτερα πρώτα",
"Smallest First": "Μικρότερα πρώτα",
"Name A-Z": "Όνομα AZ",
"Name Z-A": "Όνομα ZA",
"{{count}} selected_one": "{{count}} επιλεγμένο",
"{{count}} selected_other": "{{count}} επιλεγμένα",
"Delete Selected": "Διαγραφή επιλεγμένων",
"Created": "Δημιουργήθηκε",
"No files found": "Δεν βρέθηκαν αρχεία",
"No files uploaded yet": "Δεν έχουν ανέβει αρχεία ακόμη",
"files": "αρχεία",
"Page {{current}} of {{total}}": "Σελίδα {{current}} από {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Σίγουρα θέλετε να διαγράψετε {{count}} επιλεγμένο αρχείο;",
"Are you sure to delete {{count}} selected file(s)?_other": "Σίγουρα θέλετε να διαγράψετε {{count}} επιλεγμένα αρχεία;",
"Cloud Storage Usage": "Χρήση αποθήκευσης cloud",
"Rename Group": "Μετονομασία ομάδας",
"From Directory": "Από κατάλογο",
"Successfully imported {{count}} book(s)_one": "Επιτυχής εισαγωγή 1 βιβλίου",
"Successfully imported {{count}} book(s)_other": "Επιτυχής εισαγωγή {{count}} βιβλίων",
"Count": "Πλήθος",
"Start Page": "Αρχική Σελίδα",
"Search in OPDS Catalog...": "Αναζήτηση στον κατάλογο OPDS...",
"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": "Πατήστε για εναλλαγή υποσέλιδου"
}
@@ -9,5 +9,15 @@
"Search in {{count}} Book(s)..._one": "Search in {{count}} book...",
"Search in {{count}} Book(s)..._other": "Search in {{count}} books...",
"{{count}} pages left in chapter_one": "{{count}} page left in chapter",
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter"
"{{count}} pages left in chapter_other": "{{count}} pages left in chapter",
"Deleted {{count}} file(s)_one": "Deleted {{count}} file",
"Deleted {{count}} file(s)_other": "Deleted {{count}} files",
"Failed to delete {{count}} file(s)_one": "Failed to delete {{count}} file",
"Failed to delete {{count}} file(s)_other": "Failed to delete {{count}} files",
"{{count}} selected_one": "{{count}} selected",
"{{count}} selected_other": "{{count}} selected",
"Are you sure to delete {{count}} selected file(s)?_one": "Are you sure to delete {{count}} selected file?",
"Are you sure to delete {{count}} selected file(s)?_other": "Are you sure to delete {{count}} selected files?",
"Successfully imported {{count}} book(s)_one": "Successfully imported {{count}} book",
"Successfully imported {{count}} book(s)_other": "Successfully imported {{count}} books"
}
@@ -703,9 +703,7 @@
"Validating...": "Validando...",
"View All": "Ver todo",
"Forward": "Adelante",
"OPDS Catalog": "Catálogo OPDS",
"Home": "Inicio",
"Library": "Biblioteca",
"{{count}} items_one": "{{count}} elemento",
"{{count}} items_many": "{{count}} elementos",
"{{count}} items_other": "{{count}} elementos",
@@ -726,5 +724,127 @@
"Last": "Último",
"Cannot Load Page": "No se puede cargar la página",
"An error occurred": "Ocurrió un error",
"Online Library": "Biblioteca en línea"
"Online Library": "Biblioteca en línea",
"URL must start with http:// or https://": "URL debe comenzar con http:// o https://",
"Title, Author, Tag, etc...": "Titulo, Autor, Etiqueta, etc...",
"Query": "Consulta",
"Subject": "Asunto",
"Enter {{terms}}": "Ingrese {{terms}}",
"No search results found": "No se encontraron resultados de búsqueda",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Error al cargar el feed OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Buscar en {{title}}",
"Manage Storage": "Administrar almacenamiento",
"Failed to load files": "Error al cargar los archivos",
"Deleted {{count}} file(s)_one": "Se eliminó {{count}} archivo",
"Deleted {{count}} file(s)_many": "Se eliminaron {{count}} archivos",
"Deleted {{count}} file(s)_other": "Se eliminaron {{count}} archivos",
"Failed to delete {{count}} file(s)_one": "Error al eliminar {{count}} archivo",
"Failed to delete {{count}} file(s)_many": "Error al eliminar {{count}} archivos",
"Failed to delete {{count}} file(s)_other": "Error al eliminar {{count}} archivos",
"Failed to delete files": "Error al eliminar los archivos",
"Total Files": "Total de archivos",
"Total Size": "Tamaño total",
"Quota": "Cuota",
"Used": "Usado",
"Files": "Archivos",
"Search files...": "Buscar archivos...",
"Newest First": "Más nuevos primero",
"Oldest First": "Más antiguos primero",
"Largest First": "Más grandes primero",
"Smallest First": "Más pequeños primero",
"Name A-Z": "Nombre AZ",
"Name Z-A": "Nombre ZA",
"{{count}} selected_one": "{{count}} seleccionado",
"{{count}} selected_many": "{{count}} seleccionados",
"{{count}} selected_other": "{{count}} seleccionados",
"Delete Selected": "Eliminar seleccionados",
"Created": "Creado",
"No files found": "No se encontraron archivos",
"No files uploaded yet": "Aún no se han subido archivos",
"files": "archivos",
"Page {{current}} of {{total}}": "Página {{current}} de {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "¿Seguro que deseas eliminar {{count}} archivo seleccionado?",
"Are you sure to delete {{count}} selected file(s)?_many": "¿Seguro que deseas eliminar {{count}} archivos seleccionados?",
"Are you sure to delete {{count}} selected file(s)?_other": "¿Seguro que deseas eliminar {{count}} archivos seleccionados?",
"Cloud Storage Usage": "Uso de almacenamiento en la nube",
"Rename Group": "Renombrar grupo",
"From Directory": "Desde el directorio",
"Successfully imported {{count}} book(s)_one": "Se importó correctamente 1 libro",
"Successfully imported {{count}} book(s)_many": "Se importaron correctamente {{count}} libros",
"Successfully imported {{count}} book(s)_other": "Se importaron correctamente {{count}} libros",
"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",
"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"
}
@@ -699,9 +699,7 @@
"Validating...": "در حال بررسی...",
"View All": "مشاهده همه",
"Forward": "بعدی",
"OPDS Catalog": "فهرست OPDS",
"Home": "خانه",
"Library": "کتابخانه",
"{{count}} items_one": "{{count}} مورد",
"{{count}} items_other": "{{count}} مورد",
"Download completed": "دانلود کامل شد",
@@ -721,5 +719,122 @@
"Last": "آخرین",
"Cannot Load Page": "بارگذاری صفحه امکان‌پذیر نیست",
"An error occurred": "خطایی رخ داد",
"Online Library": "کتابخانه آنلاین"
"Online Library": "کتابخانه آنلاین",
"URL must start with http:// or https://": "آدرس باید با http:// یا https:// شروع شود",
"Title, Author, Tag, etc...": "عنوان، نویسنده، برچسب و غیره...",
"Query": "پرس‌وجو",
"Subject": "موضوع",
"Enter {{terms}}": "وارد کردن {{terms}}",
"No search results found": "هیچ نتیجه‌ای یافت نشد",
"Failed to load OPDS feed: {{status}} {{statusText}}": "بارگذاری فید OPDS ناموفق بود: {{status}} {{statusText}}",
"Search in {{title}}": "جستجو در {{title}}",
"Manage Storage": "مدیریت فضای ذخیره‌سازی",
"Failed to load files": "بارگیری فایل‌ها ناموفق بود",
"Deleted {{count}} file(s)_one": "{{count}} فایل حذف شد",
"Deleted {{count}} file(s)_other": "{{count}} فایل حذف شدند",
"Failed to delete {{count}} file(s)_one": "حذف {{count}} فایل ناموفق بود",
"Failed to delete {{count}} file(s)_other": "حذف {{count}} فایل ناموفق بود",
"Failed to delete files": "حذف فایل‌ها ناموفق بود",
"Total Files": "کل فایل‌ها",
"Total Size": "اندازه کل",
"Quota": "سهمیه",
"Used": "استفاده‌شده",
"Files": "فایل‌ها",
"Search files...": "جستجوی فایل‌ها...",
"Newest First": "جدیدترین‌ها",
"Oldest First": "قدیمی‌ترین‌ها",
"Largest First": "بزرگ‌ترین‌ها",
"Smallest First": "کوچک‌ترین‌ها",
"Name A-Z": "نام AZ",
"Name Z-A": "نام ZA",
"{{count}} selected_one": "{{count}} مورد انتخاب شد",
"{{count}} selected_other": "{{count}} مورد انتخاب شدند",
"Delete Selected": "حذف موارد انتخاب‌شده",
"Created": "ایجاد شده",
"No files found": "هیچ فایلی پیدا نشد",
"No files uploaded yet": "هنوز فایلی بارگذاری نشده است",
"files": "فایل‌ها",
"Page {{current}} of {{total}}": "صفحه {{current}} از {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "آیا از حذف {{count}} فایل انتخاب‌شده مطمئن هستید؟",
"Are you sure to delete {{count}} selected file(s)?_other": "آیا از حذف {{count}} فایل انتخاب‌شده مطمئن هستید؟",
"Cloud Storage Usage": "استفاده از فضای ذخیره‌سازی ابری",
"Rename Group": "تغییر نام گروه",
"From Directory": "از مسیر",
"Successfully imported {{count}} book(s)_one": "با موفقیت 1 کتاب وارد شد",
"Successfully imported {{count}} book(s)_other": "با موفقیت {{count}} کتاب وارد شد",
"Count": "تعداد",
"Start Page": "صفحه شروع",
"Search in OPDS Catalog...": "جستجو در فهرست OPDS...",
"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": "برای تغییر پاورقی ضربه بزنید"
}
@@ -703,9 +703,7 @@
"Validating...": "Validation...",
"View All": "Tout afficher",
"Forward": "Suivant",
"OPDS Catalog": "Catalogue OPDS",
"Home": "Accueil",
"Library": "Bibliothèque",
"{{count}} items_one": "{{count}} élément",
"{{count}} items_many": "{{count}} éléments",
"{{count}} items_other": "{{count}} éléments",
@@ -726,5 +724,127 @@
"Last": "Dernier",
"Cannot Load Page": "Impossible de charger la page",
"An error occurred": "Une erreur est survenue",
"Online Library": "Bibliothèque en ligne"
"Online Library": "Bibliothèque en ligne",
"URL must start with http:// or https://": "URL doit commencer par http:// ou https://",
"Title, Author, Tag, etc...": "Titre, Auteur, Tag, etc...",
"Query": "Requête",
"Subject": "Sujet",
"Enter {{terms}}": "Entrez {{terms}}",
"No search results found": "Aucun résultat trouvé",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Échec du chargement du flux OPDS : {{status}} {{statusText}}",
"Search in {{title}}": "Rechercher dans {{title}}",
"Manage Storage": "Gérer le stockage",
"Failed to load files": "Échec du chargement des fichiers",
"Deleted {{count}} file(s)_one": "{{count}} fichier supprimé",
"Deleted {{count}} file(s)_many": "{{count}} fichiers supprimés",
"Deleted {{count}} file(s)_other": "{{count}} fichiers supprimés",
"Failed to delete {{count}} file(s)_one": "Échec de la suppression de {{count}} fichier",
"Failed to delete {{count}} file(s)_many": "Échec de la suppression de {{count}} fichiers",
"Failed to delete {{count}} file(s)_other": "Échec de la suppression de {{count}} fichiers",
"Failed to delete files": "Échec de la suppression des fichiers",
"Total Files": "Nombre total de fichiers",
"Total Size": "Taille totale",
"Quota": "Quota",
"Used": "Utilisé",
"Files": "Fichiers",
"Search files...": "Rechercher des fichiers...",
"Newest First": "Les plus récents",
"Oldest First": "Les plus anciens",
"Largest First": "Les plus volumineux",
"Smallest First": "Les moins volumineux",
"Name A-Z": "Nom A-Z",
"Name Z-A": "Nom Z-A",
"{{count}} selected_one": "{{count}} sélectionné",
"{{count}} selected_many": "{{count}} sélectionnés",
"{{count}} selected_other": "{{count}} sélectionnés",
"Delete Selected": "Supprimer la sélection",
"Created": "Créé",
"No files found": "Aucun fichier trouvé",
"No files uploaded yet": "Aucun fichier téléchargé pour le moment",
"files": "fichiers",
"Page {{current}} of {{total}}": "Page {{current}} sur {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Voulez-vous vraiment supprimer {{count}} fichier sélectionné ?",
"Are you sure to delete {{count}} selected file(s)?_many": "Voulez-vous vraiment supprimer {{count}} fichiers sélectionnés ?",
"Are you sure to delete {{count}} selected file(s)?_other": "Voulez-vous vraiment supprimer {{count}} fichiers sélectionnés ?",
"Cloud Storage Usage": "Utilisation du stockage cloud",
"Rename Group": "Renommer le groupe",
"From Directory": "Depuis le répertoire",
"Successfully imported {{count}} book(s)_one": "Importation réussie de 1 livre",
"Successfully imported {{count}} book(s)_many": "Importation réussie de {{count}} livres",
"Successfully imported {{count}} book(s)_other": "Importation réussie de {{count}} livres",
"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",
"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"
}
@@ -699,9 +699,7 @@
"Validating...": "मान्य किया जा रहा है...",
"View All": "सभी देखें",
"Forward": "आगे",
"OPDS Catalog": "OPDS कैटलॉग",
"Home": "होम",
"Library": "लाइब्रेरी",
"{{count}} items_one": "{{count}} आइटम",
"{{count}} items_other": "{{count}} आइटम",
"Download completed": "डाउनलोड पूरा हुआ",
@@ -721,5 +719,122 @@
"Last": "अंतिम",
"Cannot Load Page": "पृष्ठ लोड नहीं किया जा सका",
"An error occurred": "एक त्रुटि हुई",
"Online Library": "ऑनलाइन लाइब्रेरी"
"Online Library": "ऑनलाइन लाइब्रेरी",
"URL must start with http:// or https://": "URL http:// या https:// से शुरू होना चाहिए",
"Title, Author, Tag, etc...": "शीर्षक, लेखक, टैग, आदि...",
"Query": "प्रश्न",
"Subject": "विषय",
"Enter {{terms}}": "{{terms}} दर्ज करें",
"No search results found": "कोई खोज परिणाम नहीं मिला",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS फ़ीड लोड करने में विफल: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} में खोजें",
"Manage Storage": "स्टोरेज प्रबंधित करें",
"Failed to load files": "फ़ाइलें लोड करने में विफल",
"Deleted {{count}} file(s)_one": "{{count}} फ़ाइल हटाई गई",
"Deleted {{count}} file(s)_other": "{{count}} फ़ाइलें हटाई गईं",
"Failed to delete {{count}} file(s)_one": "{{count}} फ़ाइल हटाने में विफल",
"Failed to delete {{count}} file(s)_other": "{{count}} फ़ाइलें हटाने में विफल",
"Failed to delete files": "फ़ाइलें हटाने में विफल",
"Total Files": "कुल फ़ाइलें",
"Total Size": "कुल आकार",
"Quota": "कोटा",
"Used": "उपयोग किया गया",
"Files": "फ़ाइलें",
"Search files...": "फ़ाइलें खोजें...",
"Newest First": "नवीनतम पहले",
"Oldest First": "सबसे पुराने पहले",
"Largest First": "सबसे बड़ी पहले",
"Smallest First": "सबसे छोटी पहले",
"Name A-Z": "नाम A-Z",
"Name Z-A": "नाम Z-A",
"{{count}} selected_one": "{{count}} चयनित",
"{{count}} selected_other": "{{count}} चयनित",
"Delete Selected": "चयनित हटाएँ",
"Created": "बनाई गई",
"No files found": "कोई फ़ाइल नहीं मिली",
"No files uploaded yet": "अभी तक कोई फ़ाइल अपलोड नहीं की गई",
"files": "फ़ाइलें",
"Page {{current}} of {{total}}": "पृष्ठ {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "क्या आप वाकई {{count}} चयनित फ़ाइल हटाना चाहते हैं?",
"Are you sure to delete {{count}} selected file(s)?_other": "क्या आप वाकई {{count}} चयनित फ़ाइलें हटाना चाहते हैं?",
"Cloud Storage Usage": "क्लाउड स्टोरेज उपयोग",
"Rename Group": "समूह का नाम बदलें",
"From Directory": "निर्देशिका से",
"Successfully imported {{count}} book(s)_one": "सफलतापूर्वक 1 पुस्तक आयात की गई",
"Successfully imported {{count}} book(s)_other": "सफलतापूर्वक {{count}} पुस्तकों का आयात किया गया",
"Count": "गणना",
"Start Page": "प्रारंभ पृष्ठ",
"Search in OPDS Catalog...": "OPDS कैटलॉग में खोजें...",
"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": "फुटर टॉगल करने के लिए टैप करें"
}
@@ -695,9 +695,7 @@
"Validating...": "Memvalidasi...",
"View All": "Lihat Semua",
"Forward": "Maju",
"OPDS Catalog": "Katalog OPDS",
"Home": "Beranda",
"Library": "Perpustakaan",
"{{count}} items_other": "{{count}} item",
"Download completed": "Unduhan selesai",
"Download failed": "Unduhan gagal",
@@ -716,5 +714,117 @@
"Last": "Terakhir",
"Cannot Load Page": "Tidak dapat memuat halaman",
"An error occurred": "Terjadi kesalahan",
"Online Library": "Perpustakaan Online"
"Online Library": "Perpustakaan Online",
"URL must start with http:// or https://": "URL harus diawali dengan http:// atau https://",
"Title, Author, Tag, etc...": "Judul, Penulis, Tag, dll...",
"Query": "Kueri",
"Subject": "Subjek",
"Enter {{terms}}": "Masukkan {{terms}}",
"No search results found": "Tidak ada hasil pencarian ditemukan",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Gagal memuat feed OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Cari di {{title}}",
"Manage Storage": "Kelola Penyimpanan",
"Failed to load files": "Gagal memuat file",
"Deleted {{count}} file(s)_other": "{{count}} file dihapus",
"Failed to delete {{count}} file(s)_other": "Gagal menghapus {{count}} file",
"Failed to delete files": "Gagal menghapus file",
"Total Files": "Total File",
"Total Size": "Total Ukuran",
"Quota": "Kuota",
"Used": "Digunakan",
"Files": "File",
"Search files...": "Cari file...",
"Newest First": "Terbaru",
"Oldest First": "Terlama",
"Largest First": "Terbesar",
"Smallest First": "Terkecil",
"Name A-Z": "Nama A-Z",
"Name Z-A": "Nama Z-A",
"{{count}} selected_other": "{{count}} dipilih",
"Delete Selected": "Hapus yang Dipilih",
"Created": "Dibuat",
"No files found": "Tidak ada file ditemukan",
"No files uploaded yet": "Belum ada file diunggah",
"files": "file",
"Page {{current}} of {{total}}": "Halaman {{current}} dari {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Yakin ingin menghapus {{count}} file yang dipilih?",
"Cloud Storage Usage": "Penggunaan Penyimpanan Cloud",
"Rename Group": "Ganti Nama Grup",
"From Directory": "Dari Direktori",
"Successfully imported {{count}} book(s)_other": "Berhasil mengimpor {{count}} buku",
"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",
"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"
}
@@ -703,9 +703,7 @@
"Validating...": "Convalida in corso...",
"View All": "Vedi tutto",
"Forward": "Avanti",
"OPDS Catalog": "Catalogo OPDS",
"Home": "Home",
"Library": "Libreria",
"{{count}} items_one": "{{count}} elemento",
"{{count}} items_many": "{{count}} elementi",
"{{count}} items_other": "{{count}} elementi",
@@ -726,5 +724,127 @@
"Last": "Ultimo",
"Cannot Load Page": "Impossibile caricare la pagina",
"An error occurred": "Si è verificato un errore",
"Online Library": "Libreria online"
"Online Library": "Libreria online",
"URL must start with http:// or https://": "URL deve iniziare con http:// o https://",
"Title, Author, Tag, etc...": "Titolo, Autore, Tag, ecc...",
"Query": "Query",
"Subject": "Soggetto",
"Enter {{terms}}": "Inserisci {{terms}}",
"No search results found": "Nessun risultato di ricerca trovato",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Impossibile caricare il feed OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Cerca in {{title}}",
"Manage Storage": "Gestisci archiviazione",
"Failed to load files": "Impossibile caricare i file",
"Deleted {{count}} file(s)_one": "È stato eliminato {{count}} file",
"Deleted {{count}} file(s)_many": "Sono stati eliminati {{count}} file",
"Deleted {{count}} file(s)_other": "Sono stati eliminati {{count}} file",
"Failed to delete {{count}} file(s)_one": "Impossibile eliminare {{count}} file",
"Failed to delete {{count}} file(s)_many": "Impossibile eliminare {{count}} file",
"Failed to delete {{count}} file(s)_other": "Impossibile eliminare {{count}} file",
"Failed to delete files": "Impossibile eliminare i file",
"Total Files": "File totali",
"Total Size": "Dimensione totale",
"Quota": "Quota",
"Used": "Utilizzato",
"Files": "File",
"Search files...": "Cerca file...",
"Newest First": "Più recenti",
"Oldest First": "Più vecchi",
"Largest First": "Più grandi",
"Smallest First": "Più piccoli",
"Name A-Z": "Nome A-Z",
"Name Z-A": "Nome Z-A",
"{{count}} selected_one": "{{count}} selezionato",
"{{count}} selected_many": "{{count}} selezionati",
"{{count}} selected_other": "{{count}} selezionati",
"Delete Selected": "Elimina selezionati",
"Created": "Creato",
"No files found": "Nessun file trovato",
"No files uploaded yet": "Nessun file caricato",
"files": "file",
"Page {{current}} of {{total}}": "Pagina {{current}} di {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Sei sicuro di voler eliminare {{count}} file selezionato?",
"Are you sure to delete {{count}} selected file(s)?_many": "Sei sicuro di voler eliminare {{count}} file selezionati?",
"Are you sure to delete {{count}} selected file(s)?_other": "Sei sicuro di voler eliminare {{count}} file selezionati?",
"Cloud Storage Usage": "Utilizzo archiviazione cloud",
"Rename Group": "Rinomina gruppo",
"From Directory": "Da directory",
"Successfully imported {{count}} book(s)_one": "Importato con successo 1 libro",
"Successfully imported {{count}} book(s)_many": "Importati con successo {{count}} libri",
"Successfully imported {{count}} book(s)_other": "Importati con successo {{count}} libri",
"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",
"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"
}
@@ -695,9 +695,7 @@
"Validating...": "検証中...",
"View All": "すべて表示",
"Forward": "進む",
"OPDS Catalog": "OPDSカタログ",
"Home": "ホーム",
"Library": "ライブラリ",
"{{count}} items_other": "{{count}} 件",
"Download completed": "ダウンロード完了",
"Download failed": "ダウンロード失敗",
@@ -716,5 +714,117 @@
"Last": "最後",
"Cannot Load Page": "ページを読み込めません",
"An error occurred": "エラーが発生しました",
"Online Library": "オンラインライブラリ"
"Online Library": "オンラインライブラリ",
"URL must start with http:// or https://": "URLはhttp://またはhttps://で始まる必要があります",
"Title, Author, Tag, etc...": "タイトル、著者、タグなど...",
"Query": "クエリ",
"Subject": "件名",
"Enter {{terms}}": "{{terms}}を入力してください",
"No search results found": "検索結果が見つかりません",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDSフィードの読み込みに失敗しました: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}}内を検索",
"Manage Storage": "ストレージ管理",
"Failed to load files": "ファイルの読み込みに失敗しました",
"Deleted {{count}} file(s)_other": "{{count}} 件のファイルを削除しました",
"Failed to delete {{count}} file(s)_other": "{{count}} 件のファイルの削除に失敗しました",
"Failed to delete files": "ファイルの削除に失敗しました",
"Total Files": "ファイル合計",
"Total Size": "合計サイズ",
"Quota": "容量制限",
"Used": "使用済み",
"Files": "ファイル",
"Search files...": "ファイルを検索...",
"Newest First": "新しい順",
"Oldest First": "古い順",
"Largest First": "大きい順",
"Smallest First": "小さい順",
"Name A-Z": "名前 A-Z",
"Name Z-A": "名前 Z-A",
"{{count}} selected_other": "{{count}} 件選択済み",
"Delete Selected": "選択した項目を削除",
"Created": "作成日",
"No files found": "ファイルが見つかりません",
"No files uploaded yet": "まだファイルがアップロードされていません",
"files": "ファイル",
"Page {{current}} of {{total}}": "ページ {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "選択した {{count}} 件のファイルを削除してもよろしいですか?",
"Cloud Storage Usage": "クラウドストレージ使用量",
"Rename Group": "グループ名を変更",
"From Directory": "ディレクトリから",
"Successfully imported {{count}} book(s)_other": "成功裏に{{count}}冊の本をインポートしました",
"Count": "件数",
"Start Page": "開始ページ",
"Search in OPDS Catalog...": "OPDSカタログ内を検索...",
"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": "タップでフッターを切り替え"
}
@@ -695,9 +695,7 @@
"Validating...": "검증 중...",
"View All": "모두 보기",
"Forward": "다음",
"OPDS Catalog": "OPDS 카탈로그",
"Home": "홈",
"Library": "라이브러리",
"{{count}} items_other": "{{count}}개 항목",
"Download completed": "다운로드 완료",
"Download failed": "다운로드 실패",
@@ -716,5 +714,117 @@
"Last": "마지막",
"Cannot Load Page": "페이지를 불러올 수 없습니다",
"An error occurred": "오류가 발생했습니다",
"Online Library": "온라인 라이브러리"
"Online Library": "온라인 라이브러리",
"URL must start with http:// or https://": "URL은 http:// 또는 https://로 시작해야 합니다",
"Title, Author, Tag, etc...": "제목, 저자, 태그 등...",
"Query": "쿼리",
"Subject": "주제",
"Enter {{terms}}": "{{terms}} 입력",
"No search results found": "검색 결과가 없습니다",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS 피드를 불러오지 못했습니다: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}}에서 검색",
"Manage Storage": "저장소 관리",
"Failed to load files": "파일 로드 실패",
"Deleted {{count}} file(s)_other": "{{count}}개의 파일이 삭제되었습니다",
"Failed to delete {{count}} file(s)_other": "{{count}}개의 파일 삭제 실패",
"Failed to delete files": "파일 삭제 실패",
"Total Files": "총 파일",
"Total Size": "총 용량",
"Quota": "쿼터",
"Used": "사용됨",
"Files": "파일",
"Search files...": "파일 검색...",
"Newest First": "최신순",
"Oldest First": "오래된순",
"Largest First": "큰 파일순",
"Smallest First": "작은 파일순",
"Name A-Z": "이름 A-Z",
"Name Z-A": "이름 Z-A",
"{{count}} selected_other": "{{count}}개 선택됨",
"Delete Selected": "선택 삭제",
"Created": "생성됨",
"No files found": "파일이 없습니다",
"No files uploaded yet": "아직 업로드된 파일이 없습니다",
"files": "파일",
"Page {{current}} of {{total}}": "페이지 {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "선택한 {{count}}개의 파일을 삭제하시겠습니까?",
"Cloud Storage Usage": "클라우드 저장소 사용량",
"Rename Group": "그룹 이름 바꾸기",
"From Directory": "디렉토리에서",
"Successfully imported {{count}} book(s)_other": "성공적으로 {{count}} 권의 책이 가져와졌습니다",
"Count": "개수",
"Start Page": "시작 페이지",
"Search in OPDS Catalog...": "OPDS 카탈로그에서 검색...",
"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": "탭하여 바닥글 전환"
}
@@ -695,9 +695,7 @@
"Validating...": "Mengesahkan...",
"View All": "Lihat Semua",
"Forward": "Maju",
"OPDS Catalog": "Katalog OPDS",
"Home": "Laman Utama",
"Library": "Perpustakaan",
"{{count}} items_other": "{{count}} item",
"Download completed": "Muat turun selesai",
"Download failed": "Muat turun gagal",
@@ -716,5 +714,117 @@
"Last": "Terakhir",
"Cannot Load Page": "Tidak dapat memuatkan halaman",
"An error occurred": "Ralat telah berlaku",
"Online Library": "Perpustakaan Dalam Talian"
"Online Library": "Perpustakaan Dalam Talian",
"URL must start with http:// or https://": "URL mesti bermula dengan http:// atau https://",
"Title, Author, Tag, etc...": "Tajuk, Pengarang, Tag, dll...",
"Query": "Pertanyaan",
"Subject": "Subjek",
"Enter {{terms}}": "Masukkan {{terms}}",
"No search results found": "Tiada hasil carian ditemui",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Gagal memuatkan suapan OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Cari dalam {{title}}",
"Manage Storage": "Urus Penyimpanan",
"Failed to load files": "Gagal memuatkan fail",
"Deleted {{count}} file(s)_other": "{{count}} fail telah dipadam",
"Failed to delete {{count}} file(s)_other": "Gagal memadam {{count}} fail",
"Failed to delete files": "Gagal memadam fail",
"Total Files": "Jumlah Fail",
"Total Size": "Jumlah Saiz",
"Quota": "Kuota",
"Used": "Digunakan",
"Files": "Fail",
"Search files...": "Cari fail...",
"Newest First": "Terbaru dahulu",
"Oldest First": "Tertua dahulu",
"Largest First": "Terbesar dahulu",
"Smallest First": "Terkecil dahulu",
"Name A-Z": "Nama A-Z",
"Name Z-A": "Nama Z-A",
"{{count}} selected_other": "{{count}} dipilih",
"Delete Selected": "Padam Dipilih",
"Created": "Dicipta",
"No files found": "Tiada fail dijumpai",
"No files uploaded yet": "Belum ada fail dimuat naik",
"files": "fail",
"Page {{current}} of {{total}}": "Halaman {{current}} daripada {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Adakah anda pasti mahu memadam {{count}} fail yang dipilih?",
"Cloud Storage Usage": "Penggunaan Storan Awan",
"Rename Group": "Namakan Semula Kumpulan",
"From Directory": "Dari Direktori",
"Successfully imported {{count}} book(s)_other": "Berjaya mengimport {{count}} buku",
"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",
"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"
}
@@ -699,9 +699,7 @@
"Validating...": "Valideren...",
"View All": "Alles bekijken",
"Forward": "Verder",
"OPDS Catalog": "OPDS-catalogus",
"Home": "Startpagina",
"Library": "Bibliotheek",
"{{count}} items_one": "{{count}} item",
"{{count}} items_other": "{{count}} items",
"Download completed": "Download voltooid",
@@ -721,5 +719,122 @@
"Last": "Laatste",
"Cannot Load Page": "Pagina kan niet worden geladen",
"An error occurred": "Er is een fout opgetreden",
"Online Library": "Online Bibliotheek"
"Online Library": "Online Bibliotheek",
"URL must start with http:// or https://": "URL moet beginnen met http:// of https://",
"Title, Author, Tag, etc...": "Titel, Auteur, Tag, enzovoort...",
"Query": "Zoekopdracht",
"Subject": "Onderwerp",
"Enter {{terms}}": "Voer {{terms}} in",
"No search results found": "Geen zoekresultaten gevonden",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Het laden van de OPDS-feed is mislukt: {{status}} {{statusText}}",
"Search in {{title}}": "Zoeken in {{title}}",
"Manage Storage": "Opslag beheren",
"Failed to load files": "Bestanden laden mislukt",
"Deleted {{count}} file(s)_one": "{{count}} bestand verwijderd",
"Deleted {{count}} file(s)_other": "{{count}} bestanden verwijderd",
"Failed to delete {{count}} file(s)_one": "Kon {{count}} bestand niet verwijderen",
"Failed to delete {{count}} file(s)_other": "Kon {{count}} bestanden niet verwijderen",
"Failed to delete files": "Bestanden verwijderen mislukt",
"Total Files": "Totaal aantal bestanden",
"Total Size": "Totale grootte",
"Quota": "Quota",
"Used": "Gebruikt",
"Files": "Bestanden",
"Search files...": "Bestanden zoeken...",
"Newest First": "Nieuwste eerst",
"Oldest First": "Oudste eerst",
"Largest First": "Grootste eerst",
"Smallest First": "Kleinste eerst",
"Name A-Z": "Naam A-Z",
"Name Z-A": "Naam Z-A",
"{{count}} selected_one": "{{count}} geselecteerd",
"{{count}} selected_other": "{{count}} geselecteerd",
"Delete Selected": "Verwijder geselecteerde",
"Created": "Gemaakt",
"No files found": "Geen bestanden gevonden",
"No files uploaded yet": "Nog geen bestanden geüpload",
"files": "bestanden",
"Page {{current}} of {{total}}": "Pagina {{current}} van {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Weet je zeker dat je {{count}} geselecteerd bestand wilt verwijderen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Weet je zeker dat je {{count}} geselecteerde bestanden wilt verwijderen?",
"Cloud Storage Usage": "Cloudopslaggebruik",
"Rename Group": "Groep hernoemen",
"From Directory": "Vanuit map",
"Successfully imported {{count}} book(s)_one": "Succesvol 1 boek geïmporteerd",
"Successfully imported {{count}} book(s)_other": "Succesvol {{count}} boeken geïmporteerd",
"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",
"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"
}
@@ -707,9 +707,7 @@
"Validating...": "Weryfikacja...",
"View All": "Pokaż wszystkie",
"Forward": "Dalej",
"OPDS Catalog": "Katalog OPDS",
"Home": "Strona główna",
"Library": "Biblioteka",
"{{count}} items_one": "{{count}} element",
"{{count}} items_few": "{{count}} elementy",
"{{count}} items_many": "{{count}} elementów",
@@ -731,5 +729,132 @@
"Last": "Ostatnia",
"Cannot Load Page": "Nie można załadować strony",
"An error occurred": "Wystąpił błąd",
"Online Library": "Biblioteka online"
"Online Library": "Biblioteka online",
"URL must start with http:// or https://": "URL musi zaczynać się od http:// lub https://",
"Title, Author, Tag, etc...": "Tytuł, Autor, Tag, itp...",
"Query": "Zapytanie",
"Subject": "Temat",
"Enter {{terms}}": "Wprowadź {{terms}}",
"No search results found": "Nie znaleziono wyników wyszukiwania",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Nie udało się załadować kanału OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Szukaj w {{title}}",
"Manage Storage": "Zarządzaj pamięcią",
"Failed to load files": "Nie udało się wczytać plików",
"Deleted {{count}} file(s)_one": "Usunięto {{count}} plik",
"Deleted {{count}} file(s)_few": "Usunięto {{count}} pliki",
"Deleted {{count}} file(s)_many": "Usunięto {{count}} plików",
"Deleted {{count}} file(s)_other": "Usunięto {{count}} pliku",
"Failed to delete {{count}} file(s)_one": "Nie udało się usunąć {{count}} pliku",
"Failed to delete {{count}} file(s)_few": "Nie udało się usunąć {{count}} plików",
"Failed to delete {{count}} file(s)_many": "Nie udało się usunąć {{count}} plików",
"Failed to delete {{count}} file(s)_other": "Nie udało się usunąć {{count}} pliku",
"Failed to delete files": "Nie udało się usunąć plików",
"Total Files": "Łączna liczba plików",
"Total Size": "Łączny rozmiar",
"Quota": "Limit",
"Used": "Użyto",
"Files": "Pliki",
"Search files...": "Szukaj plików...",
"Newest First": "Najnowsze najpierw",
"Oldest First": "Najstarsze najpierw",
"Largest First": "Największe najpierw",
"Smallest First": "Najmniejsze najpierw",
"Name A-Z": "Nazwa A-Z",
"Name Z-A": "Nazwa Z-A",
"{{count}} selected_one": "Wybrano {{count}} plik",
"{{count}} selected_few": "Wybrano {{count}} pliki",
"{{count}} selected_many": "Wybrano {{count}} plików",
"{{count}} selected_other": "Wybrano {{count}} pliku",
"Delete Selected": "Usuń wybrane",
"Created": "Utworzono",
"No files found": "Nie znaleziono plików",
"No files uploaded yet": "Nie przesłano jeszcze żadnych plików",
"files": "pliki",
"Page {{current}} of {{total}}": "Strona {{current}} z {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Czy na pewno chcesz usunąć {{count}} wybrany plik?",
"Are you sure to delete {{count}} selected file(s)?_few": "Czy na pewno chcesz usunąć {{count}} wybrane pliki?",
"Are you sure to delete {{count}} selected file(s)?_many": "Czy na pewno chcesz usunąć {{count}} wybranych plików?",
"Are you sure to delete {{count}} selected file(s)?_other": "Czy na pewno chcesz usunąć {{count}} wybrany plik?",
"Cloud Storage Usage": "Użycie pamięci w chmurze",
"Rename Group": "Zmień nazwę grupy",
"From Directory": "Z katalogu",
"Successfully imported {{count}} book(s)_one": "Pomyślnie zaimportowano 1 książkę",
"Successfully imported {{count}} book(s)_few": "Pomyślnie zaimportowano {{count}} książki",
"Successfully imported {{count}} book(s)_many": "Pomyślnie zaimportowano {{count}} książek",
"Successfully imported {{count}} book(s)_other": "Pomyślnie zaimportowano {{count}} książek",
"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",
"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ę"
}
@@ -703,9 +703,7 @@
"Validating...": "Validando...",
"View All": "Ver todos",
"Forward": "Avançar",
"OPDS Catalog": "Catálogo OPDS",
"Home": "Início",
"Library": "Biblioteca",
"{{count}} items_one": "{{count}} item",
"{{count}} items_many": "{{count}} itens",
"{{count}} items_other": "{{count}} item",
@@ -726,5 +724,127 @@
"Last": "Ostatnia",
"Cannot Load Page": "Nie można załadować strony",
"An error occurred": "Wystąpił błąd",
"Online Library": "Biblioteka online"
"Online Library": "Biblioteca online",
"URL must start with http:// or https://": "URL deve começar com http:// ou https://",
"Title, Author, Tag, etc...": "Título, Autor, Etiqueta, etc...",
"Query": "Consulta",
"Subject": "Assunto",
"Enter {{terms}}": "Insira {{terms}}",
"No search results found": "Nenhum resultado encontrado",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Falha ao carregar o feed OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Pesquisar em {{title}}",
"Manage Storage": "Gerir Armazenamento",
"Failed to load files": "Falha ao carregar arquivos",
"Deleted {{count}} file(s)_one": "Apagado {{count}} ficheiro",
"Deleted {{count}} file(s)_many": "Apagados {{count}} ficheiros",
"Deleted {{count}} file(s)_other": "Apagado {{count}} ficheiro",
"Failed to delete {{count}} file(s)_one": "Falha ao apagar {{count}} ficheiro",
"Failed to delete {{count}} file(s)_many": "Falha ao apagar {{count}} ficheiros",
"Failed to delete {{count}} file(s)_other": "Falha ao apagar {{count}} ficheiro",
"Failed to delete files": "Falha ao apagar arquivos",
"Total Files": "Total de Arquivos",
"Total Size": "Tamanho Total",
"Quota": "Quota",
"Used": "Usado",
"Files": "Arquivos",
"Search files...": "Procurar arquivos...",
"Newest First": "Mais Recentes Primeiro",
"Oldest First": "Mais Antigos Primeiro",
"Largest First": "Maiores Primeiro",
"Smallest First": "Menores Primeiro",
"Name A-Z": "Nome A-Z",
"Name Z-A": "Nome Z-A",
"{{count}} selected_one": "{{count}} selecionado",
"{{count}} selected_many": "{{count}} selecionados",
"{{count}} selected_other": "{{count}} selecionado",
"Delete Selected": "Apagar Selecionados",
"Created": "Criado",
"No files found": "Nenhum arquivo encontrado",
"No files uploaded yet": "Nenhum arquivo enviado ainda",
"files": "arquivos",
"Page {{current}} of {{total}}": "Página {{current}} de {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Tem certeza de que deseja apagar {{count}} ficheiro selecionado?",
"Are you sure to delete {{count}} selected file(s)?_many": "Tem certeza de que deseja apagar {{count}} ficheiros selecionados?",
"Are you sure to delete {{count}} selected file(s)?_other": "Tem certeza de que deseja apagar {{count}} ficheiro selecionado?",
"Cloud Storage Usage": "Uso de Armazenamento na Nuvem",
"Rename Group": "Renomear Grupo",
"From Directory": "Do Diretório",
"Successfully imported {{count}} book(s)_one": "Importado com sucesso 1 livro",
"Successfully imported {{count}} book(s)_many": "Importados com sucesso {{count}} livros",
"Successfully imported {{count}} book(s)_other": "Importados com sucesso {{count}} livros",
"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",
"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é"
}
@@ -707,9 +707,7 @@
"Validating...": "Проверка...",
"View All": "Посмотреть все",
"Forward": "Вперёд",
"OPDS Catalog": "Каталог OPDS",
"Home": "Главная",
"Library": "Библиотека",
"{{count}} items_one": "{{count}} элемент",
"{{count}} items_few": "{{count}} элемента",
"{{count}} items_many": "{{count}} элементов",
@@ -731,5 +729,132 @@
"Last": "Последняя",
"Cannot Load Page": "Не удалось загрузить страницу",
"An error occurred": "Произошла ошибка",
"Online Library": "Онлайн библиотека"
"Online Library": "Онлайн библиотека",
"URL must start with http:// or https://": "URL должен начинаться с http:// или https://",
"Title, Author, Tag, etc...": "Название, Автор, Тег и т.д...",
"Query": "Запрос",
"Subject": "Тема",
"Enter {{terms}}": "Введите {{terms}}",
"No search results found": "Результаты поиска не найдены",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Не удалось загрузить ленту OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Поиск в {{title}}",
"Manage Storage": "Управление хранилищем",
"Failed to load files": "Не удалось загрузить файлы",
"Deleted {{count}} file(s)_one": "Удалён {{count}} файл",
"Deleted {{count}} file(s)_few": "Удалено {{count}} файла",
"Deleted {{count}} file(s)_many": "Удалено {{count}} файлов",
"Deleted {{count}} file(s)_other": "Удалён {{count}} файл",
"Failed to delete {{count}} file(s)_one": "Не удалось удалить {{count}} файл",
"Failed to delete {{count}} file(s)_few": "Не удалось удалить {{count}} файла",
"Failed to delete {{count}} file(s)_many": "Не удалось удалить {{count}} файлов",
"Failed to delete {{count}} file(s)_other": "Не удалось удалить {{count}} файл",
"Failed to delete files": "Не удалось удалить файлы",
"Total Files": "Всего файлов",
"Total Size": "Общий размер",
"Quota": "Квота",
"Used": "Использовано",
"Files": "Файлы",
"Search files...": "Поиск файлов...",
"Newest First": "Сначала новые",
"Oldest First": "Сначала старые",
"Largest First": "Сначала крупные",
"Smallest First": "Сначала мелкие",
"Name A-Z": "Имя A-Z",
"Name Z-A": "Имя Z-A",
"{{count}} selected_one": "{{count}} выбранный",
"{{count}} selected_few": "{{count}} выбранных",
"{{count}} selected_many": "{{count}} выбранных",
"{{count}} selected_other": "{{count}} выбранный",
"Delete Selected": "Удалить выбранные",
"Created": "Создано",
"No files found": "Файлы не найдены",
"No files uploaded yet": "Файлы ещё не загружены",
"files": "файлы",
"Page {{current}} of {{total}}": "Страница {{current}} из {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Вы уверены, что хотите удалить {{count}} выбранный файл?",
"Are you sure to delete {{count}} selected file(s)?_few": "Вы уверены, что хотите удалить {{count}} выбранных файла?",
"Are you sure to delete {{count}} selected file(s)?_many": "Вы уверены, что хотите удалить {{count}} выбранных файлов?",
"Are you sure to delete {{count}} selected file(s)?_other": "Вы уверены, что хотите удалить {{count}} выбранный файл?",
"Cloud Storage Usage": "Использование облачного хранилища",
"Rename Group": "Переименовать группу",
"From Directory": "Из каталога",
"Successfully imported {{count}} book(s)_one": "Успешно импортирован 1 книга",
"Successfully imported {{count}} book(s)_few": "Успешно импортировано {{count}} книги",
"Successfully imported {{count}} book(s)_many": "Успешно импортировано {{count}} книг",
"Successfully imported {{count}} book(s)_other": "Успешно импортировано {{count}} книг",
"Count": "Количество",
"Start Page": "Начальная страница",
"Search in OPDS Catalog...": "Поиск в каталоге OPDS...",
"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": "Нажмите для переключения нижнего колонтитула"
}
@@ -699,9 +699,7 @@
"Validating...": "තහවුරු කරමින්...",
"View All": "සියල්ල බලන්න",
"Forward": "ඉදිරියට",
"OPDS Catalog": "OPDS දත්තසමුදා",
"Home": "මුල් පිටුව",
"Library": "පුස්තකාලය",
"{{count}} items_one": "{{count}} අයිතමය",
"{{count}} items_other": "{{count}} අයිතම",
"Download completed": "බාගත කිරීම සම්පූර්ණයි",
@@ -721,5 +719,122 @@
"Last": "අවසාන",
"Cannot Load Page": "පිටුවට ප්‍රවේශ විය නොහැක",
"An error occurred": "දෝෂයක් සිදු විය",
"Online Library": "ඔන්ලයින් පුස්තකාලය"
"Online Library": "ඔන්ලයින් පුස්තකාලය",
"URL must start with http:// or https://": "URL එක http:// හෝ https:// සමඟ ආරම්භ විය යුතුය",
"Title, Author, Tag, etc...": "ශීර්ෂය, කතුවරයා, ටැග්, ආදිය...",
"Query": "විමසුම",
"Subject": "විෂය",
"Enter {{terms}}": "{{terms}} ඇතුළත් කරන්න",
"No search results found": "සෙවුම් ප්‍රතිඵල නොමැත",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ආහාරය පූරණය කිරීමට අසමත් විය: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} තුළ සෙවීම",
"Manage Storage": "ගබඩා කළමනාකරණය කරන්න",
"Failed to load files": "ගොනු උඩුගත කිරීමට නොහැකි විය",
"Deleted {{count}} file(s)_one": "ගොනුව මකා දමා ඇත",
"Deleted {{count}} file(s)_other": "ගොනු මකා දමා ඇත",
"Failed to delete {{count}} file(s)_one": "ගොනුව මකා දැමිය නොහැකි විය",
"Failed to delete {{count}} file(s)_other": "ගොනු මකා දැමිය නොහැකි විය",
"Failed to delete files": "ගොනු මකා දැමිය නොහැකි විය",
"Total Files": "මුළු ගොනු",
"Total Size": "මුළු ප්‍රමාණය",
"Quota": "කොටස",
"Used": "භාවිතා කරන ලදී",
"Files": "ගොනු",
"Search files...": "ගොනු සොයන්න...",
"Newest First": "නවතම පළමුව",
"Oldest First": "පැරණිම පළමුව",
"Largest First": "විශාලතම පළමුව",
"Smallest First": "කුඩාතම පළමුව",
"Name A-Z": "නම A-Z",
"Name Z-A": "නම Z-A",
"{{count}} selected_one": "තෝරාගත් ගොනුව",
"{{count}} selected_other": "තෝරාගත් ගොනු",
"Delete Selected": "තෝරාගත් මකා දමන්න",
"Created": "තනන ලදී",
"No files found": "ගොනු හමු නොවීය",
"No files uploaded yet": "ගොනු තවම උඩුගත කර නැත",
"files": "ගොනු",
"Page {{current}} of {{total}}": "පිටුව {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "තෝරාගත් ගොනුව මකන්නට විශ්වාසද?",
"Are you sure to delete {{count}} selected file(s)?_other": "තෝරාගත් ගොනු මකන්නට විශ්වාසද?",
"Cloud Storage Usage": "කලාප ගබඩා භාවිතය",
"Rename Group": "කණ්ඩායම නැවත නම් කරන්න",
"From Directory": "ෆෝල්ඩරයෙන්",
"Successfully imported {{count}} book(s)_one": "සාර්ථකව ආයාත කළ 1 පොත",
"Successfully imported {{count}} book(s)_other": "සාර්ථකව ආයාත කළ {{count}} පොත්",
"Count": "ගණන",
"Start Page": "ආරම්භක පිටුව",
"Search in OPDS Catalog...": "OPDS දත්තසමුදා තුළ සෙවීම...",
"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": "පාදකය ටොගල් කිරීමට තට්ටු කරන්න"
}
@@ -699,9 +699,7 @@
"Validating...": "Verifierar...",
"View All": "Visa alla",
"Forward": "Framåt",
"OPDS Catalog": "OPDS-katalog",
"Home": "Start",
"Library": "Bibliotek",
"{{count}} items_one": "{{count}} objekt",
"{{count}} items_other": "{{count}} objekt",
"Download completed": "Nedladdning klar",
@@ -721,5 +719,122 @@
"Last": "Sista",
"Cannot Load Page": "Kan inte ladda sidan",
"An error occurred": "Ett fel uppstod",
"Online Library": "Onlinebibliotek"
"Online Library": "Onlinebibliotek",
"URL must start with http:// or https://": "URL måste börja med http:// eller https://",
"Title, Author, Tag, etc...": "Titel, författare, tagg, etc...",
"Query": "Fråga",
"Subject": "Ämne",
"Enter {{terms}}": "Ange {{terms}}",
"No search results found": "Inga sökresultat hittades",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Misslyckades med att ladda OPDS-flöde: {{status}} {{statusText}}",
"Search in {{title}}": "Sök i {{title}}",
"Manage Storage": "Hantera lagring",
"Failed to load files": "Misslyckades att ladda filer",
"Deleted {{count}} file(s)_one": "Raderade filen",
"Deleted {{count}} file(s)_other": "Raderade filerna",
"Failed to delete {{count}} file(s)_one": "Kunde inte radera filen",
"Failed to delete {{count}} file(s)_other": "Kunde inte radera filerna",
"Failed to delete files": "Kunde inte radera filer",
"Total Files": "Totalt antal filer",
"Total Size": "Total storlek",
"Quota": "Kvot",
"Used": "Använd",
"Files": "Filer",
"Search files...": "Sök filer...",
"Newest First": "Nyast först",
"Oldest First": "Äldst först",
"Largest First": "Störst först",
"Smallest First": "Minskst först",
"Name A-Z": "Namn A-Ö",
"Name Z-A": "Namn Ö-A",
"{{count}} selected_one": "Vald fil",
"{{count}} selected_other": "Valda filer",
"Delete Selected": "Radera valda",
"Created": "Skapad",
"No files found": "Inga filer hittades",
"No files uploaded yet": "Inga filer har laddats upp än",
"files": "filer",
"Page {{current}} of {{total}}": "Sida {{current}} av {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Är du säker på att du vill radera filen?",
"Are you sure to delete {{count}} selected file(s)?_other": "Är du säker på att du vill radera filerna?",
"Cloud Storage Usage": "Användning av molnlagring",
"Rename Group": "Byt namn på grupp",
"From Directory": "Från katalog",
"Successfully imported {{count}} book(s)_one": "Importerat 1 bok",
"Successfully imported {{count}} book(s)_other": "Importerat {{count}} böcker",
"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",
"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"
}
@@ -699,9 +699,7 @@
"Validating...": "சரிபார்க்கப்படுகிறது...",
"View All": "அனைத்தையும் பார்க்கவும்",
"Forward": "முன்னேற்று",
"OPDS Catalog": "OPDS பட்டியல்",
"Home": "முகப்பு",
"Library": "நூலகம்",
"{{count}} items_one": "{{count}} பொருள்",
"{{count}} items_other": "{{count}} பொருட்கள்",
"Download completed": "பதிவிறக்கம் முடிந்தது",
@@ -721,5 +719,122 @@
"Last": "இறுதி",
"Cannot Load Page": "பக்கம் ஏற்ற முடியவில்லை",
"An error occurred": "ஒரு பிழை ஏற்பட்டது",
"Online Library": "ஆன்லைன் நூலகம்"
"Online Library": "ஆன்லைன் நூலகம்",
"URL must start with http:// or https://": "URL http:// அல்லது https:// கொண்டு தொடங்க வேண்டும்",
"Title, Author, Tag, etc...": "தலைப்பு, ஆசிரியர், குறிச்சொல், மற்றும் பல...",
"Query": "கேள்வி",
"Subject": "பொருள்",
"Enter {{terms}}": "{{terms}} உள்ளிடவும்",
"No search results found": "தேடல் முடிவுகள் இல்லை",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS ஊட்டத்தை ஏற்ற முடியவில்லை: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} இல் தேடவும்",
"Manage Storage": "சேமிப்பை நிர்வகி",
"Failed to load files": "கோப்புகளை ஏற்ற முடியவில்லை",
"Deleted {{count}} file(s)_one": "கோப்பு நீக்கப்பட்டது",
"Deleted {{count}} file(s)_other": "கோப்புகள் நீக்கப்பட்டன",
"Failed to delete {{count}} file(s)_one": "கோப்பை நீக்க முடியவில்லை",
"Failed to delete {{count}} file(s)_other": "கோப்புகளை நீக்க முடியவில்லை",
"Failed to delete files": "கோப்புகளை நீக்க முடியவில்லை",
"Total Files": "மொத்த கோப்புகள்",
"Total Size": "மொத்த அளவு",
"Quota": "கோட்டா",
"Used": "பயன்படுத்தப்பட்டது",
"Files": "கோப்புகள்",
"Search files...": "கோப்புகளைத் தேடு...",
"Newest First": "புதியவை முதலில்",
"Oldest First": "பழையவை முதலில்",
"Largest First": "பெரியவை முதலில்",
"Smallest First": "சிறியவை முதலில்",
"Name A-Z": "பெயர் A-ஆல்",
"Name Z-A": "பெயர் ஆல்-A",
"{{count}} selected_one": "தேர்ந்தெடுக்கப்பட்ட கோப்பு",
"{{count}} selected_other": "தேர்ந்தெடுக்கப்பட்ட கோப்புகள்",
"Delete Selected": "தேர்ந்தெடுத்தவை நீக்கு",
"Created": "உருவாக்கப்பட்டது",
"No files found": "கோப்புகள் இல்லை",
"No files uploaded yet": "இன்னும் எந்த கோப்பும் பதிவேற்றப்படவில்லை",
"files": "கோப்புகள்",
"Page {{current}} of {{total}}": "பக்கம் {{current}} / {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "இந்த கோப்பை நீக்க விரும்புகிறீர்களா?",
"Are you sure to delete {{count}} selected file(s)?_other": "இந்த கோப்புகளை நீக்க விரும்புகிறீர்களா?",
"Cloud Storage Usage": "மேக சேமிப்பு பயன்பாடு",
"Rename Group": "குழுவை மறுபெயரிடவும்",
"From Directory": "கோப்புறையிலிருந்து",
"Successfully imported {{count}} book(s)_one": "வெற்றிகரமாக 1 புத்தகம் இறக்குமதி செய்யப்பட்டது",
"Successfully imported {{count}} book(s)_other": "வெற்றிகரமாக {{count}} புத்தகங்கள் இறக்குமதி செய்யப்பட்டது",
"Count": "எண்ணிக்கை",
"Start Page": "தொடக்கப் பக்கம்",
"Search in OPDS Catalog...": "OPDS பட்டியலில் தேடவும்...",
"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": "அடிக்குறிப்பை மாற்ற தட்டவும்"
}
@@ -695,9 +695,7 @@
"Validating...": "กำลังตรวจสอบ...",
"View All": "ดูทั้งหมด",
"Forward": "ไปข้างหน้า",
"OPDS Catalog": "แคตตาล็อก OPDS",
"Home": "หน้าแรก",
"Library": "ห้องสมุด",
"{{count}} items_other": "{{count}} รายการ",
"Download completed": "ดาวน์โหลดเสร็จสิ้น",
"Download failed": "ดาวน์โหลดล้มเหลว",
@@ -716,5 +714,117 @@
"Last": "สุดท้าย",
"Cannot Load Page": "ไม่สามารถโหลดหน้าหน้าได้",
"An error occurred": "เกิดข้อผิดพลาด",
"Online Library": "ห้องสมุดออนไลน์"
"Online Library": "ห้องสมุดออนไลน์",
"URL must start with http:// or https://": "URL ต้องเริ่มต้นด้วย http:// หรือ https://",
"Title, Author, Tag, etc...": "ชื่อเรื่อง ผู้แต่ง แท็ก ฯลฯ...",
"Query": "แบบสอบถาม",
"Subject": "หัวข้อ",
"Enter {{terms}}": "ป้อน {{terms}}",
"No search results found": "ไม่พบผลการค้นหา",
"Failed to load OPDS feed: {{status}} {{statusText}}": "ไม่สามารถโหลดฟีด OPDS ได้: {{status}} {{statusText}}",
"Search in {{title}}": "ค้นหาใน {{title}}",
"Manage Storage": "จัดการพื้นที่เก็บข้อมูล",
"Failed to load files": "ไม่สามารถโหลดไฟล์ได้",
"Deleted {{count}} file(s)_other": "ลบไฟล์เรียบร้อยแล้ว",
"Failed to delete {{count}} file(s)_other": "ลบไฟล์ไม่สำเร็จ",
"Failed to delete files": "ลบไฟล์ไม่สำเร็จ",
"Total Files": "จำนวนไฟล์ทั้งหมด",
"Total Size": "ขนาดรวมทั้งหมด",
"Quota": "โควต้า",
"Used": "ใช้ไปแล้ว",
"Files": "ไฟล์",
"Search files...": "ค้นหาไฟล์...",
"Newest First": "ใหม่ที่สุดก่อน",
"Oldest First": "เก่าที่สุดก่อน",
"Largest First": "ใหญ่ที่สุดก่อน",
"Smallest First": "เล็กที่สุดก่อน",
"Name A-Z": "ชื่อ A-ฮ",
"Name Z-A": "ชื่อ ฮ-A",
"{{count}} selected_other": "เลือกไฟล์แล้ว",
"Delete Selected": "ลบไฟล์ที่เลือก",
"Created": "สร้างเมื่อ",
"No files found": "ไม่พบไฟล์",
"No files uploaded yet": "ยังไม่มีการอัปโหลดไฟล์",
"files": "ไฟล์",
"Page {{current}} of {{total}}": "หน้า {{current}} จาก {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "คุณแน่ใจหรือว่าต้องการลบไฟล์ที่เลือก?",
"Cloud Storage Usage": "การใช้งานพื้นที่เก็บข้อมูลคลาวด์",
"Rename Group": "เปลี่ยนชื่อกลุ่ม",
"From Directory": "จากไดเรกทอรี",
"Successfully imported {{count}} book(s)_other": "นำเข้า {{count}} หนังสือเรียบร้อยแล้ว",
"Count": "นับ",
"Start Page": "หน้าเริ่มต้น",
"Search in OPDS Catalog...": "ค้นหาในแคตตาล็อก OPDS...",
"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": "แตะเพื่อสลับส่วนท้าย"
}
@@ -699,9 +699,7 @@
"Validating...": "Doğrulanıyor...",
"View All": "Tümünü Görüntüle",
"Forward": "İleri",
"OPDS Catalog": "OPDS Kataloğu",
"Home": "Ana Sayfa",
"Library": "Kütüphane",
"{{count}} items_one": "{{count}} öğe",
"{{count}} items_other": "{{count}} öğe",
"Download completed": "İndirme tamamlandı",
@@ -721,5 +719,122 @@
"Last": "Son",
"Cannot Load Page": "Sayfa yüklenemiyor",
"An error occurred": "Bir hata oluştu",
"Online Library": "Çevrimiçi Kütüphane"
"Online Library": "Çevrimiçi Kütüphane",
"URL must start with http:// or https://": "URL http:// veya https:// ile başlamalıdır",
"Title, Author, Tag, etc...": "Başlık, Yazar, Etiket, vb...",
"Query": "Sorgu",
"Subject": "Konu",
"Enter {{terms}}": "{{terms}} girin",
"No search results found": "Arama sonucu bulunamadı",
"Failed to load OPDS feed: {{status}} {{statusText}}": "OPDS beslemesi yüklenemedi: {{status}} {{statusText}}",
"Search in {{title}}": "{{title}} içinde ara",
"Manage Storage": "Depolamayı Yönet",
"Failed to load files": "Dosyalar yüklenemedi",
"Deleted {{count}} file(s)_one": "{{count}} dosya silindi",
"Deleted {{count}} file(s)_other": "{{count}} dosya silindi",
"Failed to delete {{count}} file(s)_one": "{{count}} dosya silinemedi",
"Failed to delete {{count}} file(s)_other": "{{count}} dosya silinemedi",
"Failed to delete files": "Dosyalar silinemedi",
"Total Files": "Toplam Dosya",
"Total Size": "Toplam Boyut",
"Quota": "Kota",
"Used": "Kullanıldı",
"Files": "Dosyalar",
"Search files...": "Dosyaları ara...",
"Newest First": "En Yeni Önce",
"Oldest First": "En Eski Önce",
"Largest First": "En Büyük Önce",
"Smallest First": "En Küçük Önce",
"Name A-Z": "İsim A-Z",
"Name Z-A": "İsim Z-A",
"{{count}} selected_one": "{{count}} seçili dosya",
"{{count}} selected_other": "{{count}} seçili dosya",
"Delete Selected": "Seçilenleri Sil",
"Created": "Oluşturulma Tarihi",
"No files found": "Dosya bulunamadı",
"No files uploaded yet": "Henüz dosya yüklenmedi",
"files": "dosyalar",
"Page {{current}} of {{total}}": "{{total}} sayfa içinde {{current}}. sayfa",
"Are you sure to delete {{count}} selected file(s)?_one": "Seçilen {{count}} dosyayı silmek istediğinizden emin misiniz?",
"Are you sure to delete {{count}} selected file(s)?_other": "Seçilen {{count}} dosyayı silmek istediğinizden emin misiniz?",
"Cloud Storage Usage": "Bulut Depolama Kullanımı",
"Rename Group": "Grubu Yeniden Adlandır",
"From Directory": "Dizinden",
"Successfully imported {{count}} book(s)_one": "Başarıyla 1 kitap içe aktarıldı",
"Successfully imported {{count}} book(s)_other": "Başarıyla {{count}} kitap içe aktarıldı",
"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",
"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"
}
@@ -707,9 +707,7 @@
"Validating...": "Перевірка...",
"View All": "Переглянути все",
"Forward": "Вперед",
"OPDS Catalog": "Каталог OPDS",
"Home": "Головна",
"Library": "Бібліотека",
"{{count}} items_one": "{{count}} елемент",
"{{count}} items_few": "{{count}} елементи",
"{{count}} items_many": "{{count}} елементів",
@@ -731,5 +729,132 @@
"Last": "Остання",
"Cannot Load Page": "Не вдалося завантажити сторінку",
"An error occurred": "Сталася помилка",
"Online Library": "Онлайн бібліотека"
"Online Library": "Онлайн бібліотека",
"URL must start with http:// or https://": "URL повинен починатися з http:// або https://",
"Title, Author, Tag, etc...": "Назва, автор, тег тощо...",
"Query": "Запит",
"Subject": "Тема",
"Enter {{terms}}": "Введіть {{terms}}",
"No search results found": "Результатів пошуку не знайдено",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Не вдалося завантажити стрічку OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Пошук у {{title}}",
"Manage Storage": "Керування сховищем",
"Failed to load files": "Не вдалося завантажити файли",
"Deleted {{count}} file(s)_one": "Видалено {{count}} файл",
"Deleted {{count}} file(s)_few": "Видалено {{count}} файли",
"Deleted {{count}} file(s)_many": "Видалено {{count}} файлів",
"Deleted {{count}} file(s)_other": "Видалено {{count}} файлів",
"Failed to delete {{count}} file(s)_one": "Не вдалося видалити {{count}} файл",
"Failed to delete {{count}} file(s)_few": "Не вдалося видалити {{count}} файли",
"Failed to delete {{count}} file(s)_many": "Не вдалося видалити {{count}} файлів",
"Failed to delete {{count}} file(s)_other": "Не вдалося видалити {{count}} файлів",
"Failed to delete files": "Не вдалося видалити файли",
"Total Files": "Всього файлів",
"Total Size": "Загальний розмір",
"Quota": "Квота",
"Used": "Використано",
"Files": "Файли",
"Search files...": "Пошук файлів...",
"Newest First": "Спершу новіші",
"Oldest First": "Спершу старіші",
"Largest First": "Спершу найбільші",
"Smallest First": "Спершу найменші",
"Name A-Z": "Ім'я A-Z",
"Name Z-A": "Ім'я Z-A",
"{{count}} selected_one": "Вибрано {{count}} файл",
"{{count}} selected_few": "Вибрано {{count}} файли",
"{{count}} selected_many": "Вибрано {{count}} файлів",
"{{count}} selected_other": "Вибрано {{count}} файлів",
"Delete Selected": "Видалити вибране",
"Created": "Створено",
"No files found": "Файли не знайдено",
"No files uploaded yet": "Файли ще не завантажені",
"files": "файли",
"Page {{current}} of {{total}}": "Сторінка {{current}} з {{total}}",
"Are you sure to delete {{count}} selected file(s)?_one": "Ви впевнені, що хочете видалити {{count}} файл?",
"Are you sure to delete {{count}} selected file(s)?_few": "Ви впевнені, що хочете видалити {{count}} файли?",
"Are you sure to delete {{count}} selected file(s)?_many": "Ви впевнені, що хочете видалити {{count}} файлів?",
"Are you sure to delete {{count}} selected file(s)?_other": "Ви впевнені, що хочете видалити {{count}} файлів?",
"Cloud Storage Usage": "Використання хмарного сховища",
"Rename Group": "Перейменувати групу",
"From Directory": "З каталогу",
"Successfully imported {{count}} book(s)_one": "Успішно імпортовано 1 книгу",
"Successfully imported {{count}} book(s)_few": "Успішно імпортовано {{count}} книги",
"Successfully imported {{count}} book(s)_many": "Успішно імпортовано {{count}} книг",
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книг",
"Count": "Кількість",
"Start Page": "Початкова сторінка",
"Search in OPDS Catalog...": "Пошук у каталозі OPDS...",
"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": "Торкніться, щоб перемкнути нижній колонтитул"
}
@@ -695,9 +695,7 @@
"Validating...": "Đang xác thực...",
"View All": "Xem tất cả",
"Forward": "Tiếp",
"OPDS Catalog": "Danh mục OPDS",
"Home": "Trang chủ",
"Library": "Thư viện",
"{{count}} items_other": "{{count}} mục",
"Download completed": "Tải xuống hoàn tất",
"Download failed": "Tải xuống thất bại",
@@ -716,5 +714,117 @@
"Last": "Cuối cùng",
"Cannot Load Page": "Không thể tải trang",
"An error occurred": "Đã xảy ra lỗi",
"Online Library": "Thư viện trực tuyến"
"Online Library": "Thư viện trực tuyến",
"URL must start with http:// or https://": "URL phải bắt đầu bằng http:// hoặc https://",
"Title, Author, Tag, etc...": "Tiêu đề, Tác giả, Thẻ, v.v...",
"Query": "Truy vấn",
"Subject": "Chủ đề",
"Enter {{terms}}": "Nhập {{terms}}",
"No search results found": "Không tìm thấy kết quả tìm kiếm",
"Failed to load OPDS feed: {{status}} {{statusText}}": "Không tải được nguồn cấp OPDS: {{status}} {{statusText}}",
"Search in {{title}}": "Tìm kiếm trong {{title}}",
"Manage Storage": "Quản lý bộ nhớ",
"Failed to load files": "Không tải được tệp",
"Deleted {{count}} file(s)_other": "Đã xóa {{count}} tệp",
"Failed to delete {{count}} file(s)_other": "Không xóa được {{count}} tệp",
"Failed to delete files": "Không xóa được tệp",
"Total Files": "Tổng số tệp",
"Total Size": "Tổng dung lượng",
"Quota": "Dung lượng cho phép",
"Used": "Đã sử dụng",
"Files": "Tệp",
"Search files...": "Tìm tệp...",
"Newest First": "Mới nhất trước",
"Oldest First": "Cũ nhất trước",
"Largest First": "Lớn nhất trước",
"Smallest First": "Nhỏ nhất trước",
"Name A-Z": "Tên A-Z",
"Name Z-A": "Tên Z-A",
"{{count}} selected_other": "Đã chọn {{count}} tệp",
"Delete Selected": "Xóa các mục đã chọn",
"Created": "Đã tạo",
"No files found": "Không tìm thấy tệp",
"No files uploaded yet": "Chưa tải lên tệp nào",
"files": "tệp",
"Page {{current}} of {{total}}": "Trang {{current}} trên {{total}}",
"Are you sure to delete {{count}} selected file(s)?_other": "Bạn có chắc muốn xóa {{count}} tệp đã chọn không?",
"Cloud Storage Usage": "Sử dụng lưu trữ đám mây",
"Rename Group": "Đổi tên nhóm",
"From Directory": "Từ thư mục",
"Successfully imported {{count}} book(s)_other": "Đã nhập thành công {{count}} sách",
"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",
"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"
}
@@ -695,9 +695,7 @@
"Validating...": "验证中...",
"View All": "查看全部",
"Forward": "前进",
"OPDS Catalog": "OPDS 目录",
"Home": "首页",
"Library": "图书馆",
"{{count}} items_other": "{{count}} 项",
"Download completed": "下载完成",
"Download failed": "下载失败",
@@ -716,5 +714,117 @@
"Last": "末页",
"Cannot Load Page": "无法加载页面",
"An error occurred": "发生错误",
"Online Library": "在线书库"
"Online Library": "在线书库",
"URL must start with http:// or https://": "URL 必须以 http:// 或 https:// 开头",
"Title, Author, Tag, etc...": "标题,作者,标签等...",
"Query": "查询",
"Subject": "主题",
"Enter {{terms}}": "输入 {{terms}}",
"No search results found": "未找到搜索结果",
"Failed to load OPDS feed: {{status}} {{statusText}}": "加载 OPDS 源失败:{{status}} {{statusText}}",
"Search in {{title}}": "在 {{title}} 中搜索",
"Manage Storage": "管理存储",
"Failed to load files": "加载文件失败",
"Deleted {{count}} file(s)_other": "已删除 {{count}} 个文件",
"Failed to delete {{count}} file(s)_other": "删除 {{count}} 个文件失败",
"Failed to delete files": "删除文件失败",
"Total Files": "文件总数",
"Total Size": "总大小",
"Quota": "配额",
"Used": "已使用",
"Files": "文件",
"Search files...": "搜索文件...",
"Newest First": "最新优先",
"Oldest First": "最旧优先",
"Largest First": "最大优先",
"Smallest First": "最小优先",
"Name A-Z": "名称 A-Z",
"Name Z-A": "名称 Z-A",
"{{count}} selected_other": "已选择 {{count}} 个文件",
"Delete Selected": "删除选中项",
"Created": "创建时间",
"No files found": "未找到文件",
"No files uploaded yet": "尚未上传文件",
"files": "文件",
"Page {{current}} of {{total}}": "第 {{current}} 页,共 {{total}} 页",
"Are you sure to delete {{count}} selected file(s)?_other": "确定要删除已选择的 {{count}} 个文件吗?",
"Cloud Storage Usage": "云存储使用情况",
"Rename Group": "重命名分组",
"From Directory": "从文件夹导入",
"Successfully imported {{count}} book(s)_other": "成功导入 {{count}} 本书",
"Count": "数量",
"Start Page": "起始页",
"Search in OPDS Catalog...": "在 OPDS 目录中搜索...",
"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": "点击切换页脚"
}
@@ -695,9 +695,7 @@
"Validating...": "驗證中...",
"View All": "檢視全部",
"Forward": "前往",
"OPDS Catalog": "OPDS 目錄",
"Home": "首頁",
"Library": "圖書館",
"{{count}} items_other": "{{count}} 項",
"Download completed": "下載完成",
"Download failed": "下載失敗",
@@ -716,5 +714,117 @@
"Last": "最後一頁",
"Cannot Load Page": "無法載入頁面",
"An error occurred": "發生錯誤",
"Online Library": "線上書庫"
"Online Library": "線上書庫",
"URL must start with http:// or https://": "URL 必須以 http:// 或 https:// 開頭",
"Title, Author, Tag, etc...": "標題、作者、標籤等...",
"Query": "查詢",
"Subject": "主題",
"Enter {{terms}}": "輸入 {{terms}}",
"No search results found": "未找到搜尋結果",
"Failed to load OPDS feed: {{status}} {{statusText}}": "載入 OPDS 資料源失敗:{{status}} {{statusText}}",
"Search in {{title}}": "在 {{title}} 中搜尋",
"Manage Storage": "管理儲存",
"Failed to load files": "載入檔案失敗",
"Deleted {{count}} file(s)_other": "已刪除 {{count}} 個檔案",
"Failed to delete {{count}} file(s)_other": "刪除 {{count}} 個檔案失敗",
"Failed to delete files": "刪除檔案失敗",
"Total Files": "檔案總數",
"Total Size": "總大小",
"Quota": "配額",
"Used": "已使用",
"Files": "檔案",
"Search files...": "搜尋檔案...",
"Newest First": "最新優先",
"Oldest First": "最舊優先",
"Largest First": "最大優先",
"Smallest First": "最小優先",
"Name A-Z": "名稱 A-Z",
"Name Z-A": "名稱 Z-A",
"{{count}} selected_other": "已選擇 {{count}} 個檔案",
"Delete Selected": "刪除所選",
"Created": "建立時間",
"No files found": "找不到檔案",
"No files uploaded yet": "尚未上傳檔案",
"files": "檔案",
"Page {{current}} of {{total}}": "第 {{current}} 頁,共 {{total}} 頁",
"Are you sure to delete {{count}} selected file(s)?_other": "確定要刪除已選擇的 {{count}} 個檔案嗎?",
"Cloud Storage Usage": "雲端儲存使用情況",
"Rename Group": "重新命名",
"From Directory": "從目錄導入",
"Successfully imported {{count}} book(s)_other": "成功導入 {{count}} 本書",
"Count": "數量",
"Start Page": "起始頁",
"Search in OPDS Catalog...": "在 OPDS 目錄中搜尋...",
"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": "點擊切換頁尾"
}
+56
View File
@@ -1,5 +1,61 @@
{
"releases": {
"0.9.97": {
"date": "2026-01-10",
"notes": [
"Annotations: Added annotation bubble icons directly on text with instant note popups",
"Annotations: Enabled quick highlighting when selecting text",
"Annotations: Added the ability to edit the text range of existing highlights",
"Annotations: Added an annotation navigation bar for easier browsing",
"OPDS: Fixed an issue where CBZ files could not be downloaded from OPDS catalogs",
"OPDS: Improved compatibility with Komga OPDS servers",
"OPDS: Added support for OPDS servers using self-signed SSL certificates",
"PDF: Fixed an issue where some PDF files failed to open",
"E-ink: Optimized colors and layout for improved e-ink device readability",
"Search: Added search term history for quicker repeated searches",
"Text-to-Speech: Reduced excessive pauses between sentences for smoother playback",
"File Transfer: Introduced a background transfer queue for uploading and downloading books",
"Proofreading: Added a proofread tool that can replace text directly within books"
]
},
"0.9.96": {
"date": "2025-12-19",
"notes": [
"TTS: Resolved an issue where Edge TTS was blocked in certain regions",
"OPDS: Improved compatibility with older versions of Calibre Web",
"OPDS: Added an instant search bar for faster browsing in OPDS catalogs",
"OPDS: Added a curated book catalog from Standard Ebooks",
"Bookshelf: Added a “Group Books” action to the context menu",
"Comics: Fixed layout issues to improve comic book reading",
"Layout: Footnote popups now adapt correctly to different screen sizes",
"Layout: Fixed an issue where the maximum inline width was not applied to EPUBs",
"Sync: Improved file downloading by correctly handling special characters in filenames",
"UI: Added an option to temporarily dismiss the reading progress bar",
"Settings: Screen brightness adjustments now apply only to the reader view",
"iOS: You can now open files directly in Readest from the Files app",
"macOS: Added a global menu option to open files with Readest"
]
},
"0.9.95": {
"date": "2025-12-08",
"notes": [
"OPDS: You can now search, browse, and download ebooks directly from OPDS catalogs",
"OPDS: Improved compatibility with Calibre Web for reliable downloads",
"OPDS: Fixed an issue where book covers from self-hosted Calibre OPDS servers did not display correctly",
"Cloud Storage: You can view and delete files stored in your cloud storage directly from the app",
"Bookshelf: Added support for importing books from a folder recursively",
"Bookshelf: You can now rename your bookshelf groups",
"EPUB: Added support for SVG covers from Standard Ebooks",
"Annotations: Keyboard shortcuts no longer copy selected text into the notebook",
"Footnotes: Footnote popups now scale properly on small screens",
"Touch/Styli: Removed the context menu for smoother interaction on touch and stylus devices",
"Layout: Devices with unfoldable screens now automatically switch to a two-column layout",
"PDF: Improved zoomed-in navigation and hand-tool behavior for smoother page navigation",
"Android: Fixed highlighting of the current sentence when using native Android text-to-speech",
"Android: Back button handling now works correctly on Android 15 and above",
"Android: You can now open files shared from other apps"
]
},
"0.9.94": {
"date": "2025-12-02",
"notes": [
+1
View File
@@ -52,6 +52,7 @@ tauri-plugin-haptics = "2"
tauri-plugin-persisted-scope = "2"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
tauri-plugin-websocket = "2"
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
</dict>
</plist>
+161
View File
@@ -19,6 +19,8 @@
<string>EPUB Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>org.idpf.epub-container</string>
@@ -30,6 +32,8 @@
<string>PDF Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.pdf</string>
@@ -41,6 +45,8 @@
<string>FB2 Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.readest.fb2</string>
@@ -52,6 +58,8 @@
<string>CBZ Archive</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.readest.cbz</string>
@@ -63,6 +71,8 @@
<string>MOBI Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>org.mobipocket.mobi</string>
@@ -74,6 +84,8 @@
<string>AZW Document</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>com.amazon.azw</string>
@@ -86,6 +98,8 @@
<string>Text File</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
@@ -93,6 +107,153 @@
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.idpf.epub-container</string>
<key>UTTypeDescription</key>
<string>EPUB Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.composite-content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>epub</string>
</array>
<key>public.mime-type</key>
<string>application/epub+zip</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.adobe.pdf</string>
<key>UTTypeDescription</key>
<string>PDF Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.composite-content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>pdf</string>
</array>
<key>public.mime-type</key>
<string>application/pdf</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.readest.fb2</string>
<key>UTTypeDescription</key>
<string>FB2 Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>fb2</string>
</array>
<key>public.mime-type</key>
<string>application/xml</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.readest.cbz</string>
<key>UTTypeDescription</key>
<string>CBZ Archive</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.archive</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>cbz</string>
</array>
<key>public.mime-type</key>
<string>application/x-cbz</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>org.mobipocket.mobi</string>
<key>UTTypeDescription</key>
<string>MOBI Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mobi</string>
</array>
<key>public.mime-type</key>
<string>application/x-mobipocket-ebook</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.amazon.azw</string>
<key>UTTypeDescription</key>
<string>AZW Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>azw</string>
<string>azw3</string>
</array>
<key>public.mime-type</key>
<string>application/vnd.amazon.ebook</string>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>public.plain-text</string>
<key>UTTypeDescription</key>
<string>Text File</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>txt</string>
</array>
<key>public.mime-type</key>
<string>text/plain</string>
</dict>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
+16 -3
View File
@@ -44,9 +44,22 @@ fn build_windows_thumbnail() {
}
let dll_name = "windows_thumbnail.dll";
let dll_src = dll_crate_dir.join("target").join(&profile).join(dll_name);
let dll_dest = dll_crate_dir.join("target").join(dll_name);
let candidate_paths = [
dll_crate_dir.join("target").join(&profile).join(dll_name),
dll_crate_dir
.join("target")
.join(&target_triple)
.join(&profile)
.join(dll_name),
];
fs::copy(&dll_src, &dll_dest).expect("Failed to copy windows_thumbnail DLL");
let dll_src = candidate_paths
.iter()
.find(|p| p.exists())
.expect("Failed to find built windows_thumbnail DLL");
let dll_dest = &dll_crate_dir.join("target").join(dll_name);
fs::copy(dll_src, dll_dest).expect("Failed to copy windows_thumbnail DLL");
println!("cargo:rerun-if-changed={}", dll_dest.display());
}
@@ -116,6 +116,7 @@
}
]
},
"websocket:default",
"dialog:default",
"os:default",
"core:window:default",
@@ -17,6 +17,7 @@
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:largeHeap="true"
android:enableOnBackInvokedCallback="false"
android:label="@string/app_name"
android:theme="@style/Theme.readest"
android:hardwareAccelerated="true"
@@ -2,17 +2,24 @@ package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import android.view.KeyEvent
import android.view.MotionEvent
import android.webkit.WebView
import android.net.Uri
import android.util.Log
import android.content.Intent
import android.graphics.Color
import android.app.ActivityManager
import android.content.res.Configuration
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.activity.enableEdgeToEdge
import androidx.activity.OnBackPressedCallback
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import com.readest.native_bridge.KeyDownInterceptor
import com.readest.native_bridge.NativeBridgePlugin
@@ -41,6 +48,74 @@ 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
val keyName = keyEventMap[keyCode]
if (keyName != null) {
val shouldIntercept = when (keyCode) {
KeyEvent.KEYCODE_BACK -> interceptBackKeyEnabled
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> interceptVolumeKeysEnabled
else -> false
}
if (shouldIntercept) {
wv.evaluateJavascript(
"""
try { window.onNativeKeyDown("$keyName", $keyCode); } catch (_) {}
""".trimIndent(),
null
)
return true
}
}
}
return super.dispatchKeyEvent(event)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val keyName = keyEventMap[keyCode]
if (keyName != null) {
@@ -84,6 +159,8 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
handleIncomingIntent(intent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
setTaskDescription(
ActivityManager.TaskDescription(
@@ -93,6 +170,41 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
)
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
onBackInvokedDispatcher.registerOnBackInvokedCallback(
OnBackInvokedDispatcher.PRIORITY_DEFAULT,
OnBackInvokedCallback {
Log.d("MainActivity", "Back invoked callback triggered ${interceptBackKeyEnabled}")
if (interceptBackKeyEnabled) {
Log.d("MainActivity", "Back intercepted (OnBackInvokedCallback)")
wv.evaluateJavascript(
"""window.onNativeKeyDown("Back", ${KeyEvent.KEYCODE_BACK});""",
null
)
} else {
finish()
}
}
)
}
onBackPressedDispatcher.addCallback(this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (interceptBackKeyEnabled) {
Log.d("MainActivity", "Back intercepted (OnBackPressedDispatcher)")
wv.evaluateJavascript(
"""window.onNativeKeyDown("Back", ${KeyEvent.KEYCODE_BACK});""",
null
)
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
}
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -100,4 +212,48 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
NativeBridgePlugin.getInstance()?.handleActivityResult(requestCode, resultCode, data)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent?.let { handleIncomingIntent(it) }
}
private fun handleIncomingIntent(intent: Intent) {
when (intent.action) {
Intent.ACTION_SEND -> {
if (intent.type != null) {
handleSingleFile(intent)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
if (intent.type != null) {
handleMultipleFiles(intent)
}
}
}
}
private fun handleSingleFile(intent: Intent) {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
uri?.let { fileUri ->
val payload = JSObject().apply {
var urls = JSArray()
urls.put(fileUri.toString())
put("urls", urls)
}
NativeBridgePlugin.getInstance()?.triggerEvent("shared-intent", payload)
}
}
private fun handleMultipleFiles(intent: Intent) {
val uris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
uris?.let { fileUris ->
val payload = JSObject().apply {
var urls = JSArray()
fileUris.forEach { urls.put(it.toString()) }
put("urls", urls)
}
NativeBridgePlugin.getInstance()?.triggerEvent("shared-intent", payload)
}
}
}
@@ -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
@@ -8,6 +8,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.ConnectionResult
import java.text.SimpleDateFormat
import java.util.*
@@ -16,12 +18,27 @@ class BillingManager(private val activity: Activity) : PurchasesUpdatedListener
private val productsCache = mutableMapOf<String, ProductDetails>()
private var purchaseCallback: ((PurchaseData?) -> Unit)? = null
private val scope = CoroutineScope(Dispatchers.Main)
private val isGooglePlayAvailable: Boolean by lazy {
val availability = GoogleApiAvailability.getInstance()
val resultCode = availability.isGooglePlayServicesAvailable(activity)
resultCode == ConnectionResult.SUCCESS
}
companion object {
private const val TAG = "BillingManager"
}
fun isBillingAvailable(): Boolean {
return isGooglePlayAvailable
}
fun initialize(callback: (Boolean) -> Unit) {
if (!isGooglePlayAvailable) {
Log.d(TAG, "Google Play Services not available, skipping billing setup")
callback(false)
return
}
billingClient = BillingClient.newBuilder(activity)
.setListener(this)
.enablePendingPurchases()
@@ -495,13 +495,19 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
val args = invoke.parseArgs(SetScreenBrightnessRequestArgs::class.java)
val ret = JSObject()
try {
val brightness = (args.brightness ?: 0.5).toFloat()
if (brightness < 0.0 || brightness > 1.0) {
invoke.reject("Brightness must be between 0.0 and 1.0")
return
}
val brightness = args.brightness?.toFloat()
val layoutParams = activity.window.attributes
layoutParams.screenBrightness = brightness
if (brightness == null || brightness < 0.0) {
layoutParams.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
} else {
if (brightness > 1.0) {
invoke.reject("Brightness must be between 0.0 and 1.0, or null to use system brightness")
return
}
layoutParams.screenBrightness = brightness
}
activity.window.attributes = layoutParams
ret.put("success", true)
} catch (e: Exception) {
@@ -511,6 +517,14 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
invoke.resolve(ret)
}
@Command
fun iap_is_available(invoke: Invoke) {
val isAvailable = billingManager.isBillingAvailable()
val result = JSObject()
result.put("available", isAvailable)
invoke.resolve(result)
}
@Command
fun iap_initialize(invoke: Invoke) {
billingManager.initialize { success ->
@@ -773,4 +787,10 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
path
}
}
fun triggerEvent(eventName: String, payload: JSObject) {
activity.runOnUiThread {
trigger(eventName, payload)
}
}
}
@@ -9,6 +9,7 @@ const COMMANDS: &[&str] = &[
"get_sys_fonts_list",
"intercept_keys",
"lock_screen_orientation",
"iap_is_available",
"iap_initialize",
"iap_fetch_products",
"iap_purchase_product",
@@ -20,6 +21,8 @@ const COMMANDS: &[&str] = &[
"get_external_sdcard_path",
"open_external_url",
"select_directory",
"register_listener",
"remove_listener",
"request_manage_storage_permission",
"check_permissions",
"request_permissions",
@@ -53,6 +53,11 @@ class SetScreenBrightnessRequestArgs: Decodable {
let brightness: Float?
}
class CopyUriToPathRequestArgs: Decodable {
let uri: String?
let dst: String?
}
struct InitializeRequest: Decodable {
let publicKey: String?
}
@@ -706,6 +711,10 @@ class NativeBridgePlugin: Plugin {
}
}
@objc public func iap_is_available(_ invoke: Invoke) {
invoke.resolve(["available": true])
}
@objc public func iap_initialize(_ invoke: Invoke) {
StoreKitManager.shared.initialize()
invoke.resolve(["success": true])
@@ -803,7 +812,13 @@ class NativeBridgePlugin: Plugin {
let brightness = args.brightness ?? 0.5
if brightness < 0.0 || brightness > 1.0 {
if brightness < 0.0 {
// Revert to system brightness - iOS doesn't have a direct "system brightness" setting
// We will restore the brightness that was set before the app modified it
return invoke.resolve(["success": true])
}
if brightness > 1.0 {
return invoke.reject("Brightness must be between 0.0 and 1.0")
}
@@ -812,6 +827,59 @@ class NativeBridgePlugin: Plugin {
}
invoke.resolve(["success": true])
}
@objc public func copy_uri_to_path(_ invoke: Invoke) {
guard let args = try? invoke.parseArgs(CopyUriToPathRequestArgs.self) else {
return invoke.reject("Failed to parse arguments")
}
guard let uriString = args.uri, let dstPath = args.dst else {
return invoke.reject("URI and destination path must be provided")
}
guard let uri = URL(string: uriString) else {
return invoke.reject("Invalid URI")
}
let fileManager = FileManager.default
let dstURL = URL(fileURLWithPath: dstPath)
do {
let didStartAccessing = uri.startAccessingSecurityScopedResource()
defer {
if didStartAccessing {
uri.stopAccessingSecurityScopedResource()
}
}
var shouldCopy = false
if fileManager.fileExists(atPath: dstURL.path) {
let srcAttributes = try fileManager.attributesOfItem(atPath: uri.path)
let dstAttributes = try fileManager.attributesOfItem(atPath: dstURL.path)
let srcModDate = srcAttributes[.modificationDate] as? Date ?? Date.distantPast
let dstModDate = dstAttributes[.modificationDate] as? Date ?? Date.distantPast
if srcModDate > dstModDate {
try fileManager.removeItem(at: dstURL)
shouldCopy = true
} else {
shouldCopy = false
}
} else {
shouldCopy = true
}
if shouldCopy {
try fileManager.copyItem(at: uri, to: dstURL)
}
invoke.resolve(["success": true])
} catch {
invoke.reject("Failed to copy file: \(error.localizedDescription)")
}
}
}
@_cdecl("init_plugin_native_bridge")
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-is-available"
description = "Enables the iap_is_available command without any pre-configured scope."
commands.allow = ["iap_is_available"]
[[permission]]
identifier = "deny-iap-is-available"
description = "Denies the iap_is_available command without any pre-configured scope."
commands.deny = ["iap_is_available"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-register-listener"
description = "Enables the register_listener command without any pre-configured scope."
commands.allow = ["register_listener"]
[[permission]]
identifier = "deny-register-listener"
description = "Denies the register_listener command without any pre-configured scope."
commands.deny = ["register_listener"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-remove-listener"
description = "Enables the remove_listener command without any pre-configured scope."
commands.allow = ["remove_listener"]
[[permission]]
identifier = "deny-remove-listener"
description = "Denies the remove_listener command without any pre-configured scope."
commands.deny = ["remove_listener"]
@@ -14,6 +14,7 @@ Default permissions for the plugin
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
- `allow-lock-screen-orientation`
- `allow-iap-is-available`
- `allow-iap-initialize`
- `allow-iap-fetch-products`
- `allow-iap-purchase-product`
@@ -26,6 +27,8 @@ Default permissions for the plugin
- `allow-open-external-url`
- `allow-select-directory`
- `allow-request-manage-storage-permission`
- `allow-register-listener`
- `allow-remove-listener`
- `allow-check-permissions`
- `allow-request-permissions`
- `allow-checkPermissions`
@@ -407,6 +410,32 @@ Denies the iap_initialize command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-iap-is-available`
</td>
<td>
Enables the iap_is_available command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-is-available`
</td>
<td>
Denies the iap_is_available command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-purchase-product`
</td>
@@ -563,6 +592,58 @@ Denies the open_external_url command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-register-listener`
</td>
<td>
Enables the register_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-register-listener`
</td>
<td>
Denies the register_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-remove-listener`
</td>
<td>
Enables the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-remove-listener`
</td>
<td>
Denies the remove_listener command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-request-permissions`
</td>
@@ -11,6 +11,7 @@ permissions = [
"allow-get-sys-fonts-list",
"allow-intercept-keys",
"allow-lock-screen-orientation",
"allow-iap-is-available",
"allow-iap-initialize",
"allow-iap-fetch-products",
"allow-iap-purchase-product",
@@ -23,6 +24,8 @@ permissions = [
"allow-open-external-url",
"allow-select-directory",
"allow-request-manage-storage-permission",
"allow-register-listener",
"allow-remove-listener",
"allow-check-permissions",
"allow-request-permissions",
"allow-checkPermissions",
@@ -462,6 +462,18 @@
"const": "deny-iap-initialize",
"markdownDescription": "Denies the iap_initialize command without any pre-configured scope."
},
{
"description": "Enables the iap_is_available command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-is-available",
"markdownDescription": "Enables the iap_is_available command without any pre-configured scope."
},
{
"description": "Denies the iap_is_available command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-is-available",
"markdownDescription": "Denies the iap_is_available command without any pre-configured scope."
},
{
"description": "Enables the iap_purchase_product command without any pre-configured scope.",
"type": "string",
@@ -534,6 +546,30 @@
"const": "deny-open-external-url",
"markdownDescription": "Denies the open_external_url command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-listener",
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_listener command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the request-permissions command without any pre-configured scope.",
"type": "string",
@@ -631,10 +667,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}
@@ -1,6 +1,8 @@
use tauri::{command, AppHandle, Runtime};
use std::path::PathBuf;
use tauri::{command, AppHandle, Runtime, State};
use crate::models::*;
use crate::DirectoryCallbackState;
use crate::NativeBridgeExt;
use crate::Result;
@@ -82,6 +84,13 @@ pub(crate) async fn lock_screen_orientation<R: Runtime>(
app.native_bridge().lock_screen_orientation(payload)
}
#[command]
pub(crate) async fn iap_is_available<R: Runtime>(
app: AppHandle<R>,
) -> Result<IAPIsAvailableResponse> {
app.native_bridge().iap_is_available()
}
#[command]
pub(crate) async fn iap_initialize<R: Runtime>(
app: AppHandle<R>,
@@ -160,8 +169,21 @@ pub(crate) async fn open_external_url<R: Runtime>(
#[command]
pub(crate) async fn select_directory<R: Runtime>(
app: AppHandle<R>,
callback_state: State<'_, DirectoryCallbackState<R>>,
) -> Result<SelectDirectoryResponse> {
app.native_bridge().select_directory()
let result = app.native_bridge().select_directory()?;
if let Some(dir_path) = &result.path {
let path = PathBuf::from(dir_path);
if let Ok(callback_guard) = callback_state.callback.lock() {
if let Some(callback) = callback_guard.as_ref() {
callback(&app, &path);
}
}
}
Ok(result)
}
#[command]
@@ -74,6 +74,10 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_initialize(
&self,
_payload: IAPInitializeRequest,
@@ -1,3 +1,4 @@
use std::sync::{Arc, Mutex};
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
@@ -17,6 +18,9 @@ mod platform;
pub use error::{Error, Result};
use std::path::PathBuf;
use tauri::AppHandle;
#[cfg(desktop)]
use desktop::NativeBridge;
#[cfg(mobile)]
@@ -33,6 +37,20 @@ impl<R: Runtime, T: Manager<R>> crate::NativeBridgeExt<R> for T {
}
}
type DirectoryCallback<R> = Box<dyn Fn(&AppHandle<R>, &PathBuf) + Send + Sync>;
pub struct DirectoryCallbackState<R: Runtime> {
pub callback: Arc<Mutex<Option<DirectoryCallback<R>>>>,
}
impl<R: Runtime> Default for DirectoryCallbackState<R> {
fn default() -> Self {
Self {
callback: Arc::new(Mutex::new(None)),
}
}
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("native-bridge")
@@ -47,6 +65,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::get_sys_fonts_list,
commands::intercept_keys,
commands::lock_screen_orientation,
commands::iap_is_available,
commands::iap_initialize,
commands::iap_fetch_products,
commands::iap_purchase_product,
@@ -66,7 +85,18 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
#[cfg(desktop)]
let native_bridge = desktop::init(app, api)?;
app.manage(native_bridge);
app.manage(DirectoryCallbackState::<R>::default());
Ok(())
})
.build()
}
pub fn register_select_directory_callback<R: Runtime>(
app: &AppHandle<R>,
callback: impl Fn(&AppHandle<R>, &PathBuf) + Send + Sync + 'static,
) {
if let Some(state) = app.try_state::<DirectoryCallbackState<R>>() {
let mut cb = state.callback.lock().unwrap();
*cb = Some(Box::new(callback));
}
}
@@ -113,6 +113,14 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
self.0
.run_mobile_plugin("iap_is_available", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_initialize(
&self,
@@ -113,6 +113,12 @@ pub struct Purchase {
pub purchase_state: String, // "purchased", "pending", "cancelled", "restored"
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPIsAvailableResponse {
pub available: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPInitializeRequest {
@@ -383,7 +383,10 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
val args = invoke.parseArgs(SetVoiceArgs::class.java)
try {
val voices = textToSpeech?.voices
val targetVoice = voices?.find { it.name == args.voice }
val targetVoice = voices?.find { voice ->
val languageTag = voice.locale.toLanguageTag()
voice.name == args.voice || (languageTag.contains(voice.name) && languageTag == args.voice)
}
if (targetVoice != null) {
val result = textToSpeech?.setVoice(targetVoice)
@@ -404,10 +407,17 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
fun get_all_voices(invoke: Invoke) {
try {
val voices = textToSpeech?.voices?.map { voice ->
val voiceName = voice.name
val language = voice.locale.toLanguageTag()
val (id, name) = if (language.contains(voiceName)) {
language to language
} else {
voiceName to voiceName
}
JSObject().apply {
put("id", voice.name)
put("name", voice.name)
put("lang", voice.locale.toLanguageTag())
put("id", id)
put("name", name)
put("lang", language)
put("disabled", false)
}
} ?: emptyList()
@@ -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;
+61 -32
View File
@@ -9,22 +9,26 @@ 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;
#[cfg(desktop)]
use std::path::PathBuf;
#[cfg(desktop)]
use tauri::{AppHandle, Listener, Manager, Url};
#[cfg(desktop)]
use tauri::{AppHandle, Manager};
use tauri_plugin_fs::FsExt;
#[cfg(desktop)]
use tauri::{Listener, Url};
#[cfg(target_os = "macos")]
mod macos;
mod transfer_file;
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
#[cfg(target_os = "android")]
use tauri_plugin_native_bridge::register_select_directory_callback;
#[cfg(target_os = "android")]
use tauri_plugin_native_bridge::{NativeBridgeExt, OpenExternalUrlRequest};
use tauri_plugin_oauth::start;
#[cfg(not(target_os = "android"))]
@@ -49,7 +53,6 @@ fn allow_file_in_scopes(app: &AppHandle, files: Vec<PathBuf>) {
}
}
#[cfg(desktop)]
fn allow_dir_in_scopes(app: &AppHandle, dir: &PathBuf) {
let fs_scope = app.fs_scope();
let asset_protocol_scope = app.asset_protocol_scope();
@@ -136,7 +139,7 @@ fn get_executable_dir() -> String {
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
struct SingleInstancePayload {
args: Vec<String>,
cwd: String,
}
@@ -144,6 +147,7 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![
@@ -179,7 +183,7 @@ pub fn run() {
if !files.is_empty() {
allow_file_in_scopes(app, files.clone());
}
app.emit("single-instance", Payload { args: argv, cwd })
app.emit("single-instance", SingleInstancePayload { args: argv, cwd })
.unwrap();
}));
@@ -223,6 +227,11 @@ pub fn run() {
allow_dir_in_scopes(app.handle(), &PathBuf::from(get_executable_dir()));
}
#[cfg(target_os = "android")]
register_select_directory_callback(app.handle(), move |app, path| {
allow_dir_in_scopes(app, path);
});
#[cfg(desktop)]
{
app.handle().plugin(tauri_plugin_cli::init())?;
@@ -264,34 +273,54 @@ pub fn run() {
eprintln!("Failed to initialize tauri_plugin_log: {e}");
};
// Check for e-ink device on Android before building the window
#[cfg(target_os = "android")]
let is_eink = android::is_eink_device();
#[cfg(not(target_os = "android"))]
let is_eink = false;
let eink_script = if is_eink {
"window.__READEST_IS_EINK = true;"
} else {
""
};
let init_script = format!(
r#"
{eink_script}
window.addEventListener('DOMContentLoaded', function() {{
document.documentElement.classList.add('edge-to-edge');
const isTauriLocal = window.location.protocol === 'tauri:' ||
window.location.protocol === 'about:' ||
window.location.hostname === 'tauri.localhost';
const needsSafeArea = !isTauriLocal;
if (needsSafeArea && !document.getElementById('safe-area-style')) {{
const style = document.createElement('style');
style.id = 'safe-area-style';
style.textContent = `
body {{
padding-top: env(safe-area-inset-top) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
padding-left: env(safe-area-inset-left) !important;
padding-right: env(safe-area-inset-right) !important;
}}
`;
document.head.appendChild(style);
}}
}});
"#,
eink_script = eink_script
);
let app_handle = app.handle().clone();
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.background_throttling(BackgroundThrottlingPolicy::Disabled)
.background_color(tauri::window::Color(50, 49, 48, 255))
.initialization_script(
r#"
window.addEventListener('DOMContentLoaded', function() {
document.documentElement.classList.add('edge-to-edge');
const isTauriLocal = window.location.protocol === 'tauri:' ||
window.location.protocol === 'about:' ||
window.location.hostname === 'tauri.localhost';
const needsSafeArea = !isTauriLocal;
if (needsSafeArea && !document.getElementById('safe-area-style')) {
const style = document.createElement('style');
style.id = 'safe-area-style';
style.textContent = `
body {
padding-top: env(safe-area-inset-top) !important;
padding-bottom: env(safe-area-inset-bottom) !important;
padding-left: env(safe-area-inset-left) !important;
padding-right: env(safe-area-inset-right) !important;
}
`;
document.head.appendChild(style);
}
});
"#,
)
.background_color(if is_eink {
tauri::window::Color(255, 255, 255, 255)
} else {
tauri::window::Color(50, 49, 48, 255)
})
.initialization_script(&init_script)
.on_navigation(move |url| {
if url.scheme() == "alipays" || url.scheme() == "alipay" {
let url_str = url.as_str().to_string();
+52 -2
View File
@@ -1,8 +1,17 @@
use crate::allow_file_in_scopes;
use std::path::PathBuf;
use tauri::menu::MenuEvent;
use tauri::menu::{SubmenuBuilder, HELP_SUBMENU_ID};
use tauri::menu::{MenuItemBuilder, SubmenuBuilder, HELP_SUBMENU_ID};
use tauri::AppHandle;
use tauri::Emitter;
use tauri_plugin_opener::OpenerExt;
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct OpenFilesPayload {
files: Vec<String>,
}
pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
let global_menu = app.menu().unwrap();
@@ -10,6 +19,23 @@ pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
global_menu.remove(&item)?;
}
let open_item = MenuItemBuilder::new("Open...")
.id("open_file")
.accelerator("Cmd+O")
.build(app)?;
if let Some(file_menu) = global_menu.items()?.iter().find(|item| {
if let Some(submenu) = item.as_submenu() {
submenu.text().ok().as_deref() == Some("File")
} else {
false
}
}) {
if let Some(file_submenu) = file_menu.as_submenu() {
file_submenu.insert(&open_item, 0)?;
}
}
global_menu.append(
&SubmenuBuilder::new(app, "Help")
.text("privacy_policy", "Privacy Policy")
@@ -28,7 +54,9 @@ pub fn setup_macos_menu(app: &AppHandle) -> tauri::Result<()> {
pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) {
let opener = app.opener();
if event.id() == "privacy_policy" {
if event.id() == "open_file" {
handle_open_file(app);
} else if event.id() == "privacy_policy" {
let _ = opener.open_url("https://readest.com/privacy-policy", None::<&str>);
} else if event.id() == "report_issue" {
let _ = opener.open_url("https://github.com/readest/readest/issues", None::<&str>);
@@ -36,3 +64,25 @@ pub fn handle_menu_event(app: &AppHandle, event: &MenuEvent) {
let _ = opener.open_url("https://readest.com/support", None::<&str>);
}
}
fn handle_open_file(app: &AppHandle) {
use tauri_plugin_dialog::DialogExt;
let app_handle = app.clone();
app.dialog()
.file()
.add_filter(
"Files",
&["epub", "pdf", "mobi", "azw", "azw3", "fb2", "cbz", "txt"],
)
.pick_file(move |file_path| {
if let Some(path) = file_path {
let payload = OpenFilesPayload {
files: vec![path.to_string()],
};
allow_file_in_scopes(&app_handle, vec![PathBuf::from(path.to_string())]);
let _ = app_handle.emit("open-files", payload);
}
});
}
@@ -108,6 +108,7 @@ pub async fn download_file(
headers: HashMap<String, String>,
body: Option<String>,
single_threaded: Option<bool>,
skip_ssl_verification: Option<bool>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
use futures::stream::{self, StreamExt};
@@ -116,7 +117,10 @@ pub async fn download_file(
const PART_SIZE: u64 = 1024 * 1024;
let client = reqwest::Client::new();
let client = reqwest::ClientBuilder::new()
.danger_accept_invalid_certs(skip_ssl_verification.unwrap_or(false))
.danger_accept_invalid_hostnames(skip_ssl_verification.unwrap_or(false))
.build()?;
let force_single = single_threaded.unwrap_or(false);
async fn single_threaded_download(
+5 -4
View File
@@ -16,9 +16,9 @@
"csp": {
"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://*",
"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",
"img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.deno.dev https://cdnjs.cloudflare.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.deno.dev https://cdnjs.cloudflare.com",
"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"
},
@@ -72,7 +72,8 @@
},
"iOS": {
"developmentTeam": "J5W48D69VR",
"minimumSystemVersion": "14.0"
"infoPlist": "./Info-ios.plist",
"minimumSystemVersion": "15.0"
},
"fileAssociations": [
{
@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { EnvProvider } from '@/context/EnvContext';
import ProofreadPopup from '@/app/reader/components/annotator/ProofreadPopup';
vi.mock('@/services/environment', async () => {
const actual = await vi.importActual('@/services/environment');
const mockAppService = {
init: vi.fn().mockResolvedValue(undefined),
// Add any other methods from AppService interface
};
return {
...actual,
default: {
getAppService: vi.fn().mockResolvedValue(mockAppService),
},
};
});
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe('ProofreadPopup Component', () => {
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
bookKey: 'test-book',
isVertical: false,
selectedText: 'test word',
selection: {
key: 'test-book',
text: 'test word',
cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)',
range: {
deleteContents: vi.fn(),
insertNode: vi.fn(),
startContainer: document.createTextNode('test word here'),
endContainer: document.createTextNode('test word here'),
startOffset: 5,
endOffset: 9,
} as unknown as Range,
index: 0,
},
position: { point: { x: 100, y: 100 } },
trianglePosition: { point: { x: 100, y: 100 } },
popupWidth: 440,
popupHeight: 200,
onConfirm: mockOnConfirm,
onDismiss: mockOnClose,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('Rendering', () => {
it('should render default replacement scope options', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Current selection')).toBeTruthy();
});
it('should render the replacement text input field', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
expect(input).toBeTruthy();
});
it('should render the case sensitive checkbox', () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Case sensitive:')).toBeTruthy();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
});
it('should render the Apply button', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText('Apply')).toBeTruthy();
});
it('should display selected text preview', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
expect(screen.getByText(/Selected text:/)).toBeTruthy();
expect(screen.getByText(/"test word"/)).toBeTruthy();
});
});
describe('Case Sensitive Checkbox', () => {
it('should be checked by default (case-sensitive)', () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('should toggle when clicked', async () => {
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
});
describe('Replacement Text Input', () => {
it('should update value when user types', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'new text' } });
expect(input.value).toBe('new text');
});
it('should trim whitespace from replacement text', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: ' trimmed ' } });
const confirmButton = screen.getByText('Apply');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith(
expect.objectContaining({
replacement: 'trimmed',
}),
);
});
});
describe('Scope Selection Handlers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createValidSelection = () => ({
...defaultProps,
selection: {
...defaultProps.selection,
text: 'word',
cfi: 'epubcfi(/6/4[chap01ref]!/4/2/1:0)',
range: {
deleteContents: vi.fn(),
insertNode: vi.fn(),
startContainer: document.createTextNode('test word here'),
endContainer: document.createTextNode('test word here'),
startOffset: 5,
endOffset: 9,
} as unknown as Range,
},
});
it('should call onConfirm with correct scope for "selection"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'selection' }));
});
});
it('should call onConfirm with correct scope for "book"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const scopeSelect = screen.getByRole('combobox');
fireEvent.change(scopeSelect, { target: { value: 'book' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' }));
});
});
it('should call onConfirm with correct scope for "library"', async () => {
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const scopeSelect = screen.getByRole('combobox');
fireEvent.change(scopeSelect, { target: { value: 'library' } });
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' }));
});
});
});
describe('Click Outside Behavior', () => {
it('should not call onClose when clicking inside the menu', () => {
renderWithProviders(<ProofreadPopup {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter text...');
fireEvent.mouseDown(input);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,559 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import {
ProofreadRulesManager,
setProofreadRulesVisibility,
} from '@/app/reader/components/ProofreadRules';
import BookMenu from '@/app/reader/components/sidebar/BookMenu';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ProofreadRule } from '@/types/book';
// ------------------------------
// NEXT.JS ROUTER MOCK
// ------------------------------
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
toString: () => '',
}),
}));
// ------------------------------
// TRANSLATION MOCK
// ------------------------------
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
vi.mock('@/services/translators/cache', () => ({
initCache: vi.fn(),
loadCacheFromDB: vi.fn(),
pruneCache: vi.fn(),
}));
// ------------------------------
// ENV PROVIDER WRAPPER
// ------------------------------
// mock environment module so EnvProvider uses fake values
vi.mock('@/services/environment', async (importOriginal) => {
const actual = await importOriginal();
return {
...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform)
default: {
...(typeof actual === 'object' &&
actual !== null &&
'default' in actual &&
typeof actual.default === 'object' &&
actual.default !== null
? actual.default
: {}), // keep all real default fields
API_BASE: 'http://localhost',
ENABLE_TRANSLATOR: false,
getAppService: vi.fn().mockResolvedValue(null),
},
};
});
import { EnvProvider } from '@/context/EnvContext';
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe('ProofreadRulesManager', () => {
beforeEach(() => {
// Reset stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: {
enabled: false,
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
useSidebarStore.setState({ sideBarBookKey: null });
(useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} });
});
afterEach(() => {
cleanup();
});
it('renders book and library (global) proofreading rules from stores', async () => {
// Arrange: populate stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [
{
id: 'g1',
scope: 'library',
pattern: 'foo',
replacement: 'bar',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
},
{
id: 'g2',
scope: 'library',
pattern: 'hello',
replacement: 'world',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
},
],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ProofreadRulesManager />);
// wait a tick so the component's effect attaches the event listener
await Promise.resolve();
// open via helper which dispatches the custom event
setProofreadRulesVisibility(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Library (global) rules
expect(screen.getByText('foo')).toBeTruthy();
expect(screen.getByText('bar')).toBeTruthy();
expect(screen.getByText('hello')).toBeTruthy();
expect(screen.getByText('world')).toBeTruthy();
});
it('renders selection rules separately from book/library rules', async () => {
// Arrange: populate stores with a selection rule persisted in book config
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
const selectionRule: ProofreadRule = {
id: 's1',
scope: 'selection',
pattern: 'only-once',
replacement: 'single-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
sectionHref: 'chapter1.html',
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-wide',
replacement: 'book-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Single Instance Rules section
expect(screen.getByText('Selected Text Rules')).toBeTruthy();
expect(screen.getByText('only-once')).toBeTruthy();
expect(screen.getByText('single-hit')).toBeTruthy();
// Book section should still show book-wide rule
expect(screen.getByText('book-wide')).toBeTruthy();
expect(screen.getByText('book-hit')).toBeTruthy();
});
it('displays correct scope labels for different rule types', async () => {
const selectionRule: ProofreadRule = {
id: 's1',
scope: 'selection',
pattern: 'select-text',
replacement: 'replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
sectionHref: 'chapter1.html',
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-pattern',
replacement: 'book-replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
const libraryRule: ProofreadRule = {
id: 'l1',
scope: 'library',
pattern: 'library-pattern',
replacement: 'library-replaced',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 3,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [libraryRule],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [selectionRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
const selectionRuleElement = screen.getByText('select-text').closest('li');
expect(within(selectionRuleElement!).getByText(/Selection/)).toBeTruthy();
const bookRuleElement = screen.getByText('book-pattern').closest('li');
expect(within(bookRuleElement!).getByText(/Book/)).toBeTruthy();
const libraryRuleElement = screen.getByText('library-pattern').closest('li');
expect(within(libraryRuleElement!).getByText(/Library/)).toBeTruthy();
});
it('shows case sensitivity status for each rule', async () => {
const caseSensitiveRule: ProofreadRule = {
id: 'cs1',
scope: 'book',
pattern: 'case-sensitive',
replacement: 'CS-REPLACED',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
const caseInsensitiveRule: ProofreadRule = {
id: 'ci1',
scope: 'book',
pattern: 'case-insensitive',
replacement: 'CI-REPLACED',
enabled: true,
isRegex: false,
caseSensitive: false,
order: 2,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [caseSensitiveRule, caseInsensitiveRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [caseSensitiveRule, caseInsensitiveRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
const csRuleElement = screen.getByText('case-sensitive').closest('li');
expect(within(csRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(csRuleElement!).getByText(/Yes/)).toBeTruthy();
const ciRuleElement = screen.getByText('case-insensitive').closest('li');
expect(within(ciRuleElement!).getByText(/Case sensitive:/)).toBeTruthy();
expect(within(ciRuleElement!).getByText(/No/)).toBeTruthy();
});
it('opens when BookMenu item is clicked (integration)', async () => {
// Arrange stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: { viewSettings: { proofreadRules: [] } },
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Render both menu and window
renderWithProviders(
<div>
<BookMenu />
<ProofreadRulesManager />
</div>,
);
// wait a tick so effects attach
await Promise.resolve();
// Click the menu item
const menuItem = screen.getByRole('menuitem', { name: 'Proofread' });
fireEvent.click(menuItem);
// The dialog should open
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Proofread Replacement Rules')).toBeTruthy();
});
it('shows empty state messages when no rules exist', async () => {
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { proofreadRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Check for empty state messages
expect(screen.getByText('No selected text replacement rules')).toBeTruthy();
expect(screen.getByText('No book-level replacement rules')).toBeTruthy();
});
it('merges book and library rules correctly in book section', async () => {
const libraryRule: ProofreadRule = {
id: 'l1',
scope: 'library',
pattern: 'library-wide',
replacement: 'LIBRARY',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
};
const bookRule: ProofreadRule = {
id: 'b1',
scope: 'book',
pattern: 'book-specific',
replacement: 'BOOK',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
};
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
proofreadRules: [libraryRule],
},
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
proofreadRules: [bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
proofreadRules: [bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
renderWithProviders(<ProofreadRulesManager />);
await Promise.resolve();
setProofreadRulesVisibility(true);
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Both library and book rules should appear in the Book Specific Rules section
expect(screen.getByText('library-wide')).toBeTruthy();
expect(screen.getByText('book-specific')).toBeTruthy();
// But they should both be under Book Specific Rules section
const bookSection = screen.getByText('Book Specific Rules').parentElement;
expect(within(bookSection!).getByText('library-wide')).toBeTruthy();
expect(within(bookSection!).getByText('book-specific')).toBeTruthy();
});
});
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', () => {
@@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest';
import { getWordCount } from '../../utils/word';
describe('Word Limit Feature', () => {
describe('getWordCount', () => {
it('should count single word correctly', () => {
expect(getWordCount('hello')).toBe(1);
});
it('should count multiple words correctly', () => {
expect(getWordCount('hello world')).toBe(2);
expect(getWordCount('the quick brown fox')).toBe(4);
});
it('should handle multiple spaces between words', () => {
expect(getWordCount('hello world')).toBe(2);
});
it('should handle leading and trailing spaces', () => {
expect(getWordCount(' hello world ')).toBe(2);
});
it('should handle newlines and tabs', () => {
expect(getWordCount('hello\nworld')).toBe(2);
expect(getWordCount('hello\tworld')).toBe(2);
expect(getWordCount('hello\n\t world')).toBe(2);
});
it('should return 0 for empty string', () => {
expect(getWordCount('')).toBe(0);
});
it('should return 0 for whitespace only', () => {
expect(getWordCount(' ')).toBe(0);
expect(getWordCount('\n\t ')).toBe(0);
});
it('should handle punctuation as part of words', () => {
expect(getWordCount("don't")).toBe(1);
expect(getWordCount('hello, world!')).toBe(2);
});
it('should count exactly 30 words', () => {
const thirtyWords = Array(30).fill('word').join(' ');
expect(getWordCount(thirtyWords)).toBe(30);
});
it('should count more than 30 words', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(getWordCount(thirtyOneWords)).toBe(31);
});
it('should handle unicode characters', () => {
expect(getWordCount('你好 世界')).toBe(2);
expect(getWordCount('café résumé')).toBe(2);
expect(getWordCount('🎉 hello 🎊 world')).toBe(4);
});
it('should handle numbers as words', () => {
expect(getWordCount('1 2 3 4 5')).toBe(5);
expect(getWordCount('chapter 1 section 2')).toBe(4);
});
});
describe('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', () => {
// Helper function to simulate matching logic
const matchText = (text: string, pattern: string, caseSensitive: boolean): boolean => {
if (caseSensitive) {
return text === pattern;
}
return text.toLowerCase() === pattern.toLowerCase();
};
describe('Case-Sensitive Mode', () => {
it('should match exact case only', () => {
expect(matchText('Where', 'Where', true)).toBe(true);
});
it('should not match different case', () => {
expect(matchText('where', 'Where', true)).toBe(false);
expect(matchText('WHERE', 'Where', true)).toBe(false);
expect(matchText('wHeRe', 'Where', true)).toBe(false);
});
it('should handle all uppercase pattern', () => {
expect(matchText('HELLO', 'HELLO', true)).toBe(true);
expect(matchText('hello', 'HELLO', true)).toBe(false);
});
it('should handle all lowercase pattern', () => {
expect(matchText('hello', 'hello', true)).toBe(true);
expect(matchText('Hello', 'hello', true)).toBe(false);
});
});
describe('Case-Insensitive Mode', () => {
it('should match same case', () => {
expect(matchText('where', 'where', false)).toBe(true);
});
it('should match different cases', () => {
expect(matchText('where', 'Where', false)).toBe(true);
expect(matchText('Where', 'where', false)).toBe(true);
expect(matchText('WHERE', 'where', false)).toBe(true);
expect(matchText('wHeRe', 'where', false)).toBe(true);
});
it('should match title case to lowercase', () => {
expect(matchText('The', 'the', false)).toBe(true);
});
it('should match with mixed input', () => {
expect(matchText('HeLLo', 'HELLO', false)).toBe(true);
expect(matchText('HeLLo', 'hello', false)).toBe(true);
});
});
describe('Real-world examples', () => {
it('should handle "the" in different cases', () => {
const pattern = 'the';
// Case-insensitive (default behavior)
expect(matchText('The', pattern, false)).toBe(true);
expect(matchText('the', pattern, false)).toBe(true);
expect(matchText('THE', pattern, false)).toBe(true);
// Case-sensitive
expect(matchText('The', pattern, true)).toBe(false);
expect(matchText('the', pattern, true)).toBe(true);
expect(matchText('THE', pattern, true)).toBe(false);
});
it('should handle proper nouns correctly when case-sensitive', () => {
const pattern = 'John';
// Case-sensitive - only exact match
expect(matchText('John', pattern, true)).toBe(true);
expect(matchText('john', pattern, true)).toBe(false);
expect(matchText('JOHN', pattern, true)).toBe(false);
});
});
});
@@ -1,3 +1,4 @@
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { NextRequest, NextResponse } from 'next/server';
async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
@@ -33,9 +34,9 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
console.log(`[OPDS Proxy] ${method}: ${url}`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const timeout = setTimeout(() => controller.abort(), 20000);
const headers: HeadersInit = {
'User-Agent': 'Readest/1.0 (OPDS Browser)',
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*',
};
@@ -54,7 +55,6 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
if (!response.ok) {
console.error(`[OPDS Proxy] HTTP ${response.status} for ${url}`);
if (method === 'HEAD') {
console.log(`[OPDS Proxy] Response headers:`, response.headers);
if (response.status === 401) {
return new NextResponse(null, {
status: 403,
@@ -83,7 +83,16 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
},
});
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return new NextResponse(data, {
status: response.status,
headers: {
...Object.fromEntries(response.headers.entries()),
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
const contentType = response.headers.get('Content-Type') || 'text/xml';
@@ -104,7 +113,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
});
}
if (stream === 'true' || (contentLength && parseInt(contentLength) > 1024 * 1024)) {
if (stream === 'true' && contentLength && parseInt(contentLength) > 1024 * 1024) {
console.log(`[OPDS Proxy] Streaming: ${url}`);
return new NextResponse(response.body, {
@@ -116,16 +125,19 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Expose-Headers': 'X-Content-Length',
},
});
} else {
const data = await response.text();
console.log(`[OPDS Proxy] Success: ${url} (${data.length} bytes)`);
const buf = await response.arrayBuffer();
const length = buf.byteLength;
console.log(`[OPDS Proxy] Success: ${url} (${length} bytes)`);
return new NextResponse(data, {
return new NextResponse(buf, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': length.toString(),
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { validateUserAndToken } from '@/utils/access';
const getLangFromVoice = (voiceId: string): string => {
const match = voiceId.match(/^([a-z]{2}-[A-Z]{2})/);
return match ? match[1]! : 'en-US';
};
const isValidVoice = (voiceId: string): boolean => {
return EdgeSpeechTTS.voices.some((v) => v.id === voiceId);
};
export async function POST(request: NextRequest) {
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
}
try {
const body = await request.json();
const { input: text, voice, speed = 1.0 } = body;
let { rate, lang } = body;
if (!text || typeof text !== 'string') {
return NextResponse.json(
{ error: { message: 'Missing or invalid "input" field', type: 'invalid_request_error' } },
{ status: 400 },
);
}
if (!voice || typeof voice !== 'string') {
return NextResponse.json(
{ error: { message: 'Missing or invalid "voice" field', type: 'invalid_request_error' } },
{ status: 400 },
);
}
if (!isValidVoice(voice)) {
return NextResponse.json(
{
error: {
message: `Invalid voice "${voice}". Use GET /api/tts/edge to list available voices.`,
type: 'invalid_request_error',
},
},
{ status: 400 },
);
}
lang = lang || getLangFromVoice(voice);
// Calculate rate (OpenAI speed ranges from 0.25 to 4.0, Edge TTS rate is 0.5 to 2.0)
const clampedSpeed = Math.max(0.25, Math.min(4.0, speed));
let mappedSpeed: number;
if (clampedSpeed <= 1.0) {
mappedSpeed = 0.5 + ((clampedSpeed - 0.25) / (1.0 - 0.25)) * (1.0 - 0.5);
} else {
mappedSpeed = 1.0 + ((clampedSpeed - 1.0) / (4.0 - 1.0)) * (2.0 - 1.0);
}
rate = rate || mappedSpeed;
const payload: EdgeTTSPayload = {
lang,
text,
voice,
rate,
pitch: 1.0,
};
const tts = new EdgeSpeechTTS();
const response = await tts.create(payload);
const arrayBuffer = await response.arrayBuffer();
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'audio/mpeg',
'Content-Length': arrayBuffer.byteLength.toString(),
},
});
} catch (error) {
console.error('Edge TTS API error:', error);
return NextResponse.json(
{
error: {
message: error instanceof Error ? error.message : 'Internal server error',
type: 'internal_error',
},
},
{ status: 500 },
);
}
}
export async function GET(request: NextRequest) {
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
}
try {
const query = request.nextUrl.searchParams;
const lang = query.get('lang') || '';
let voices = EdgeSpeechTTS.voices;
if (lang) {
voices = voices.filter((v) => v.lang.toLowerCase().includes(lang.toLowerCase()));
}
const formattedVoices = voices.map((voice) => ({
id: voice.id,
name: voice.name,
language: voice.lang,
}));
return NextResponse.json({
voices: formattedVoices,
});
} catch (error) {
console.error('Error listing voices:', error);
return NextResponse.json(
{
error: {
message: 'Failed to list voices',
type: 'internal_error',
},
},
{ status: 500 },
);
}
}
+4 -1
View File
@@ -41,7 +41,10 @@ export const viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<html
lang='en'
className={process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri' ? 'edge-to-edge' : ''}
>
<head>
<title>{title}</title>
<meta
@@ -26,7 +26,7 @@ interface BookItemProps {
bookSelected: boolean;
transferProgress: number | null;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
showBookDetailsModal: (book: Book) => void;
}
@@ -61,13 +61,19 @@ const BookItem: React.FC<BookItemProps> = ({
>
<div
className={clsx(
'relative flex aspect-[28/41] justify-center',
'bookitem-main relative flex aspect-[28/41] justify-center rounded',
coverFit === 'crop' && 'overflow-hidden shadow-md',
mode === 'grid' && 'items-end',
mode === 'list' && 'min-w-20 items-center',
)}
>
<BookCover mode={mode} book={book} coverFit={coverFit} showSpine={false} />
<BookCover
mode={mode}
book={book}
coverFit={coverFit}
showSpine={false}
imageClassName='rounded shadow-md'
/>
{bookSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
@@ -154,7 +160,7 @@ const BookItem: React.FC<BookItemProps> = ({
if (!book.uploadedAt) {
handleBookUpload(book);
} else if (!book.downloadedAt) {
handleBookDownload(book);
handleBookDownload(book, { queued: true });
}
}}
>
@@ -10,6 +10,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useAutoFocus } from '@/hooks/useAutoFocus';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTransferStore } from '@/store/transferStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
@@ -23,6 +24,7 @@ import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import SelectModeActions from './SelectModeActions';
import GroupingModal from './GroupingModal';
import TransferQueuePanel from './TransferQueuePanel';
interface BookshelfProps {
libraryBooks: Book[];
@@ -30,7 +32,10 @@ interface BookshelfProps {
isSelectAll: boolean;
isSelectNone: boolean;
handleImportBooks: () => void;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDownload: (
book: Book,
options?: { redownload?: boolean; queued?: boolean },
) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
@@ -82,6 +87,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const { getGroupName } = useLibraryStore();
const { isTransferQueueOpen } = useTransferStore();
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
const updateUrlParams = useCallback(
@@ -304,6 +311,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleGroupBooks={groupSelectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
@@ -329,7 +337,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<button
aria-label={_('Import Books')}
className={clsx(
'border-1 bg-base-100 hover:bg-base-300/50',
'bookitem-main bg-base-100 hover:bg-base-300/50',
'flex items-center justify-center',
'aspect-[28/41] w-full',
)}
@@ -375,9 +383,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
/>
</ModalPortal>
)}
{isTransferQueueOpen && (
<ModalPortal>
<TransferQueuePanel />
</ModalPortal>
)}
{showDeleteAlert && (
<div
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
className={clsx('delete-alert fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
style={{
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
}}
@@ -81,7 +81,11 @@ interface BookshelfItemProps {
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleBookDownload: (book: Book) => Promise<boolean>;
handleGroupBooks: () => void;
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;
@@ -97,6 +101,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
transferProgress,
setLoading,
toggleSelection,
handleGroupBooks,
handleBookUpload,
handleBookDownload,
handleSetSelectMode,
@@ -110,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
}, []);
@@ -129,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);
@@ -188,6 +191,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
toggleSelection(book.hash);
},
});
const groupBooksMenuItem = await MenuItem.new({
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
toggleSelection(book.hash);
}
handleGroupBooks();
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
@@ -204,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({
@@ -221,6 +234,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
const menu = await Menu.new();
menu.append(selectBookMenuItem);
menu.append(groupBooksMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
if (book.uploadedAt && !book.downloadedAt) {
@@ -242,6 +256,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
toggleSelection(group.id);
},
});
const groupBooksMenuItem = await MenuItem.new({
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
toggleSelection(group.id);
}
handleGroupBooks();
},
});
const deleteGroupMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
@@ -250,6 +274,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
const menu = await Menu.new();
menu.append(selectGroupMenuItem);
menu.append(groupBooksMenuItem);
menu.append(deleteGroupMenuItem);
menu.popup();
};
@@ -336,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>
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { MdCheck, MdChevronRight } from 'react-icons/md';
import { MdCheck, MdChevronRight, MdEdit } from 'react-icons/md';
import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi';
import { IoMdArrowBack } from 'react-icons/io';
@@ -31,14 +31,23 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setLibrary, addGroup, getGroups, getGroupsByParent, getParentPath, refreshGroups } =
useLibraryStore();
const {
setLibrary,
addGroup,
getGroups,
getGroupId,
getGroupsByParent,
getParentPath,
refreshGroups,
} = useLibraryStore();
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
const [showInput, setShowInput] = useState(false);
const [editGroupName, setEditGroupName] = useState('');
const [selectedGroup, setSelectedGroup] = useState<BookGroupType | null>(null);
const [newGroup, setNewGroup] = useState<BookGroupType | null>(null);
const [isRenaming, setIsRenaming] = useState(false);
const [originalGroupName, setOriginalGroupName] = useState<string | null>(null);
const divRef = useKeyDownActions({ onCancel, onConfirm });
const editorRef = useRef<HTMLInputElement>(null);
@@ -59,6 +68,11 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
.map((hash) => libraryBooks.find((book) => book.hash === hash)?.groupId)
.some((group) => group && group !== BOOK_UNGROUPED_NAME);
const canRenameGroup = selectedBooks.length === 1 && selectedBooks.every((id) => !isMd5(id));
const currentGroupForRename = canRenameGroup
? allGroups.find((group) => group.id === selectedBooks[0])
: null;
const generateNextUntitledGroupName = () => {
const baseName = _('Untitled Group');
const basePattern = parentGroupName
@@ -86,6 +100,18 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
const nextName = generateNextUntitledGroupName();
setEditGroupName(nextName);
setShowInput(true);
setIsRenaming(false);
setOriginalGroupName(null);
setSelectedGroup(null);
};
const handleRenameGroup = () => {
if (!currentGroupForRename) return;
setEditGroupName(currentGroupForRename.name);
setOriginalGroupName(currentGroupForRename.name);
setShowInput(true);
setIsRenaming(true);
};
const handleRemoveFromGroup = () => {
@@ -112,17 +138,44 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
const handleConfirmCreateGroup = () => {
let groupName = editGroupName.trim();
if (groupName) {
if (currentPath && !groupName.startsWith(currentPath + '/')) {
groupName = `${currentPath}/${groupName}`;
}
if (isRenaming && originalGroupName) {
// Renaming existing group
const oldGroupName = originalGroupName;
const newGroup = addGroup(groupName);
setNewGroup(newGroup);
setSelectedGroup(newGroup);
setShowInput(false);
const parentGroup = getParentPath(groupName);
if (parentGroup) {
setCurrentPath(parentGroup);
// Update the group name for all books in this group and nested groups
libraryBooks.forEach((book) => {
if (book.groupName === oldGroupName) {
book.groupName = groupName;
book.groupId = getGroupId(book.groupName);
book.updatedAt = Date.now();
} else if (book.groupName?.startsWith(oldGroupName + '/')) {
book.groupName = book.groupName.replace(oldGroupName, groupName);
book.groupId = getGroupId(book.groupName);
book.updatedAt = Date.now();
}
});
setLibrary([...libraryBooks]);
appService?.saveLibraryBooks(libraryBooks);
refreshGroups();
setShowInput(false);
setIsRenaming(false);
setOriginalGroupName(null);
} else {
// Creating new group
if (currentPath && !groupName.startsWith(currentPath + '/')) {
groupName = `${currentPath}/${groupName}`;
}
const newGroup = addGroup(groupName);
setNewGroup(newGroup);
setSelectedGroup(newGroup);
setShowInput(false);
const parentGroup = getParentPath(groupName);
if (parentGroup) {
setCurrentPath(parentGroup);
}
}
}
};
@@ -205,25 +258,32 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
{/* Action buttons */}
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
{isSelectedBooksHasGroup && (
<button
onClick={handleRemoveFromGroup}
className='flex items-center space-x-2 p-2 text-blue-500'
>
<HiOutlineFolderRemove size={iconSize} />
<span className='truncate'>{_('Remove From Group')}</span>
</button>
)}
<button
onClick={handleRemoveFromGroup}
className='flex items-center space-x-2 p-2 text-blue-500 disabled:text-gray-400'
disabled={!isSelectedBooksHasGroup}
>
<HiOutlineFolderRemove size={iconSize} />
<span className='truncate'>{_('Remove From Group')}</span>
</button>
<button
onClick={handleCreateGroup}
className='flex items-center space-x-2 p-2 text-blue-500'
className='flex items-center space-x-2 p-2 text-blue-500 disabled:text-gray-400'
>
<HiOutlineFolderAdd size={iconSize} />
<span className='truncate'>{_('Create New Group')}</span>
</button>
<button
onClick={handleRenameGroup}
className='flex items-center space-x-2 p-2 text-blue-500 disabled:text-gray-400'
disabled={!canRenameGroup}
>
<MdEdit size={iconSize} />
<span className='truncate'>{_('Rename Group')}</span>
</button>
</div>
{/* Create group input */}
{/* Create/Rename group input */}
{showInput && (
<div className='mt-4 space-y-2'>
<div className='flex items-center gap-2'>
@@ -234,7 +294,11 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
onChange={(e) => setEditGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmCreateGroup();
if (e.key === 'Escape') setShowInput(false);
if (e.key === 'Escape') {
setShowInput(false);
setIsRenaming(false);
setOriginalGroupName(null);
}
e.stopPropagation();
}}
className='input input-ghost w-full border-0 px-2 text-base !outline-none sm:text-sm'
@@ -1,5 +1,4 @@
import clsx from 'clsx';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { IoFileTray } from 'react-icons/io5';
import { MdRssFeed } from 'react-icons/md';
@@ -9,20 +8,26 @@ import Menu from '@/components/Menu';
interface ImportMenuProps {
setIsDropdownOpen?: (open: boolean) => void;
onImportBooks: () => void;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onOpenCatalogManager: () => void;
}
const ImportMenu: React.FC<ImportMenuProps> = ({
setIsDropdownOpen,
onImportBooks,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onOpenCatalogManager,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const handleImportBooks = () => {
onImportBooks();
const handleImportFromFiles = () => {
onImportBooksFromFiles();
setIsDropdownOpen?.(false);
};
const handleImportFromDirectory = () => {
onImportBooksFromDirectory?.();
setIsDropdownOpen?.(false);
};
@@ -33,17 +38,21 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
return (
<Menu
className={clsx(
'dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow',
appService?.isMobile ? 'no-triangle' : 'dropdown-center',
)}
className={clsx('dropdown-content bg-base-100 rounded-box z-[1] mt-3 p-2 shadow')}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
label={_('From Local File')}
Icon={<IoFileTray className='h-5 w-5' />}
onClick={handleImportBooks}
onClick={handleImportFromFiles}
/>
{onImportBooksFromDirectory && (
<MenuItem
label={_('From Directory')}
Icon={<IoFileTray className='h-5 w-5' />}
onClick={handleImportFromDirectory}
/>
)}
<MenuItem
label={_('Online Library')}
Icon={<MdRssFeed className='h-5 w-5' />}
@@ -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';
@@ -26,7 +25,8 @@ import ViewMenu from './ViewMenu';
interface LibraryHeaderProps {
isSelectMode: boolean;
isSelectAll: boolean;
onImportBooks: () => void;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onOpenCatalogManager: () => void;
onToggleSelectMode: () => void;
onSelectAll: () => void;
@@ -36,7 +36,8 @@ interface LibraryHeaderProps {
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
isSelectMode,
isSelectAll,
onImportBooks,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onOpenCatalogManager,
onToggleSelectMode,
onSelectAll,
@@ -46,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();
@@ -108,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
@@ -124,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',
@@ -152,14 +149,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<Dropdown
label={_('Import Books')}
className={clsx(
'exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center',
isMobile ? 'dropdown-end' : 'dropdown-center',
'exclude-title-bar-mousedown dropdown-bottom dropdown-center flex h-6 cursor-pointer justify-center',
)}
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center !bg-transparent'
buttonClassName='p-0 h-6 min-h-6 w-6 flex touch-target items-center justify-center !bg-transparent'
toggleButton={<PiPlus role='none' className='m-0.5 h-5 w-5' />}
>
<ImportMenu
onImportBooks={onImportBooks}
onImportBooksFromFiles={onImportBooksFromFiles}
onImportBooksFromDirectory={onImportBooksFromDirectory}
onOpenCatalogManager={onOpenCatalogManager}
/>
</Dropdown>
@@ -7,7 +7,6 @@ import {
RiLoader2Line,
} from 'react-icons/ri';
import { documentDir, join } from '@tauri-apps/api/path';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { relaunch } from '@tauri-apps/plugin-process';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
@@ -20,6 +19,7 @@ import { formatBytes } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { getExternalSDCardPath } from '@/utils/bridge';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { requestStoragePermission } from '@/utils/permission';
import Dialog from '@/components/Dialog';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/MenuItem';
@@ -42,10 +42,6 @@ interface MigrationProgress {
currentFile?: string;
}
interface Permissions {
manageStorage: PermissionState;
}
export const MigrateDataWindow = () => {
const _ = useTranslation();
const { appService, envConfig } = useEnv();
@@ -158,13 +154,7 @@ export const MigrateDataWindow = () => {
setErrorMessage('');
if (!dir.includes('Android/data')) {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
if (permission.manageStorage !== 'granted') return;
if (!(await requestStoragePermission())) return;
}
try {
@@ -1,5 +1,5 @@
import { clsx } from 'clsx';
import { CatalogManager } from '@/app/opds/CatelogManager';
import { CatalogManager } from '@/app/opds/components/CatelogManager';
import { useTranslation } from '@/hooks/useTranslation';
import Dialog from '@/components/Dialog';
@@ -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,11 +16,13 @@ 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';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
import { requestStoragePermission } from '@/utils/permission';
import { saveSysSettings } from '@/helpers/settings';
import { selectDirectory } from '@/utils/bridge';
import UserAvatar from '@/components/UserAvatar';
@@ -60,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);
@@ -175,13 +185,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
};
const handleSetSavedBookCoverForLockScreen = async () => {
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
if (permission.manageStorage !== 'granted') {
permission = await invoke<Permissions>(
'plugin:native-bridge|request_manage_storage_permission',
);
}
if (permission.manageStorage !== 'granted' && appService?.distChannel === 'readest') return;
if (!(await requestStoragePermission()) && appService?.distChannel === 'readest') return;
const newValue = settings.savedBookCoverForLockScreen ? '' : 'default';
if (newValue) {
@@ -228,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)}
@@ -260,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}
@@ -334,7 +355,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
noIcon={!appService?.isAndroidApp}
onClick={handleSetRootDir}
/>
{appService?.isAndroidApp && (
{appService?.isAndroidApp && appService?.distChannel !== 'playstore' && (
<MenuItem
label={_('Save Book Cover')}
tooltip={_('Auto-save last book cover')}
@@ -0,0 +1,354 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import {
MdClose,
MdRefresh,
MdPause,
MdPlayArrow,
MdCloudUpload,
MdCloudDownload,
MdCheckCircle,
MdError,
MdCancel,
MdDeleteSweep,
} from 'react-icons/md';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useLibraryStore } from '@/store/libraryStore';
import { TransferItem, TransferStatus, useTransferStore } from '@/store/transferStore';
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const formatSpeed = (bytesPerSec: number): string => {
return `${formatBytes(bytesPerSec)}/s`;
};
const formatDateTime = (timestamp: number): string => {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) {
return timeStr;
} else if (isYesterday) {
return `Yesterday ${timeStr}`;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + timeStr;
}
};
const StatusIcon: React.FC<{
status: TransferStatus;
type: 'upload' | 'download';
size: number;
}> = ({ status, type, size }) => {
switch (status) {
case 'completed':
return <MdCheckCircle className='text-success' size={size} />;
case 'failed':
return <MdError className='text-error' size={size} />;
case 'cancelled':
return <MdCancel className='text-warning' size={size} />;
case 'in_progress':
case 'pending':
default:
return type === 'upload' ? (
<MdCloudUpload className='text-primary' size={size} />
) : (
<MdCloudDownload className='text-primary' size={size} />
);
}
};
const TransferItemRow: React.FC<{
transfer: TransferItem;
onCancel: (id: string) => void;
onRetry: (id: string) => void;
iconSize: number;
}> = ({ transfer, onCancel, onRetry, iconSize }) => {
const _ = useTranslation();
return (
<div className='hover:bg-base-200 flex items-center gap-3 rounded-lg p-3'>
<StatusIcon status={transfer.status} type={transfer.type} size={iconSize} />
<div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{transfer.bookTitle}</div>
<div className='text-base-content/60 text-xs'>
{transfer.status === 'in_progress' && (
<>
{Math.round(transfer.progress)}% - {formatSpeed(transfer.transferSpeed)}
</>
)}
{transfer.status === 'pending' && transfer.error && (
<span className='text-warning'>{transfer.error}</span>
)}
{transfer.status === 'pending' && !transfer.error && _('Waiting...')}
{transfer.status === 'failed' && (
<span className='text-error'>{transfer.error || _('Failed')}</span>
)}
{transfer.status === 'completed' && _('Completed')}
{transfer.status === 'cancelled' && _('Cancelled')}
{' · '}
{formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
</div>
{transfer.status === 'in_progress' && (
<div className='bg-base-300 mt-1 h-1.5 w-full overflow-hidden rounded-full'>
<div
className='bg-primary h-full transition-all'
style={{ width: `${transfer.progress}%` }}
/>
</div>
)}
</div>
<div className='flex items-center gap-1'>
{(transfer.status === 'failed' || transfer.status === 'cancelled') && (
<button
onClick={() => onRetry(transfer.id)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={_('Retry')}
>
<MdRefresh size={iconSize} />
</button>
)}
{['pending', 'in_progress'].includes(transfer.status) && (
<button
onClick={() => onCancel(transfer.id)}
className='btn btn-ghost btn-sm btn-circle'
aria-label={_('Cancel')}
>
<MdClose size={iconSize} />
</button>
)}
</div>
</div>
);
};
type FilterType = 'all' | 'active' | 'completed' | 'failed';
const TransferQueuePanel: React.FC = () => {
const _ = useTranslation();
const iconSize = useResponsiveSize(18);
const setIsOpen = useTransferStore((state) => state.setIsTransferQueueOpen);
const getVisibleLibrary = useLibraryStore((state) => state.getVisibleLibrary);
const {
transfers,
stats,
isQueuePaused,
cancelTransfer,
retryTransfer,
retryAllFailed,
pauseQueue,
resumeQueue,
clearCompleted,
clearFailed,
queueUpload,
queueDownload,
} = useTransferQueue();
const [filter, setFilter] = useState<FilterType>('all');
const onClose = () => setIsOpen(false);
const divRef = useKeyDownActions({ onCancel: onClose, onConfirm: onClose });
const booksToUpload = getVisibleLibrary().filter(
(book) => book.downloadedAt && !book.uploadedAt,
);
const booksToDownload = getVisibleLibrary().filter(
(book) => book.uploadedAt && !book.downloadedAt,
);
const handleUploadAll = () => {
booksToUpload.forEach((book) => queueUpload(book));
};
const handleDownloadAll = () => {
booksToDownload.forEach((book) => queueDownload(book));
};
const filteredTransfers = transfers
.filter((t) => {
switch (filter) {
case 'active':
return ['pending', 'in_progress'].includes(t.status);
case 'completed':
return t.status === 'completed';
case 'failed':
return t.status === 'failed' || t.status === 'cancelled';
default:
return true;
}
})
.sort((a, b) => {
// Sort by status priority then by createdAt
const statusOrder: Record<TransferStatus, number> = {
in_progress: 0,
pending: 1,
failed: 2,
cancelled: 3,
completed: 4,
};
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff;
return b.createdAt - a.createdAt;
});
const filterLabels: Record<FilterType, string> = {
all: _('All'),
active: _('Active'),
completed: _('Completed'),
failed: _('Failed'),
};
return (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className='absolute inset-0 bg-black/50' onClick={onClose} />
<div
ref={divRef}
className={clsx(
'modal-box bg-base-100 relative flex max-h-[85%] min-h-[65%] w-[95%] flex-col rounded-2xl p-0 shadow-xl',
'min-w-64 max-w-lg overflow-hidden',
)}
>
{/* Header */}
<div className='border-base-300 flex items-center justify-between border-b p-4'>
<h2 className='text-lg font-semibold'>{_('Transfer Queue')}</h2>
<div className='flex items-center gap-2'>
{booksToUpload.length > 0 && (
<button
onClick={handleUploadAll}
className='btn btn-ghost btn-sm gap-1'
title={_('Upload All')}
aria-label={_('Upload All')}
>
<MdCloudUpload size={iconSize} />
<span className='text-xs'>{booksToUpload.length}</span>
</button>
)}
{booksToDownload.length > 0 && (
<button
onClick={handleDownloadAll}
className='btn btn-ghost btn-sm gap-1'
title={_('Download All')}
aria-label={_('Download All')}
>
<MdCloudDownload size={iconSize} />
<span className='text-xs'>{booksToDownload.length}</span>
</button>
)}
<button
onClick={isQueuePaused ? resumeQueue : pauseQueue}
className='btn btn-ghost btn-sm btn-circle'
title={isQueuePaused ? _('Resume Transfers') : _('Pause Transfers')}
aria-label={isQueuePaused ? _('Resume Transfers') : _('Pause Transfers')}
>
{isQueuePaused ? <MdPlayArrow size={iconSize} /> : <MdPause size={iconSize} />}
</button>
<button
onClick={onClose}
className='btn btn-ghost btn-sm btn-circle'
title={_('Close')}
aria-label={_('Close')}
>
<MdClose size={iconSize} />
</button>
</div>
</div>
{/* Stats bar */}
<div className='bg-base-200 flex items-center gap-4 px-4 py-2 text-sm'>
<span>
{_('Active')}: {stats.active}
</span>
<span>
{_('Pending')}: {stats.pending}
</span>
<span>
{_('Completed')}: {stats.completed}
</span>
<span>
{_('Failed')}: {stats.failed}
</span>
</div>
{/* Filter tabs */}
<div className='border-base-300 flex gap-2 border-b p-4'>
{(['all', 'active', 'completed', 'failed'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={clsx(
'rounded-lg px-3 py-1 text-sm transition-colors',
filter === f ? 'bg-primary text-primary-content' : 'bg-base-200 hover:bg-base-300',
)}
>
{filterLabels[f]}
</button>
))}
</div>
{/* Transfer list */}
<div className='flex-1 overflow-y-auto p-2'>
{filteredTransfers.length === 0 ? (
<div className='text-base-content/60 py-8 text-center'>{_('No transfers')}</div>
) : (
filteredTransfers.map((transfer) => (
<TransferItemRow
key={transfer.id}
transfer={transfer}
onCancel={cancelTransfer}
onRetry={retryTransfer}
iconSize={iconSize}
/>
))
)}
</div>
{/* Footer actions */}
<div className='border-base-300 flex items-center justify-between border-t p-4'>
<div className='flex items-center gap-2'>
{stats.failed > 0 && (
<button onClick={retryAllFailed} className='btn btn-ghost btn-sm gap-1'>
<MdRefresh size={iconSize - 2} />
{_('Retry All')}
</button>
)}
</div>
<div className='flex items-center gap-2'>
{stats.completed > 0 && (
<button onClick={clearCompleted} className='btn btn-ghost btn-sm gap-1'>
<MdDeleteSweep size={iconSize - 2} />
{_('Clear Completed')}
</button>
)}
{stats.failed > 0 && (
<button onClick={clearFailed} className='btn btn-ghost btn-sm gap-1'>
<MdDeleteSweep size={iconSize - 2} />
{_('Clear Failed')}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default TransferQueuePanel;
@@ -99,7 +99,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
return (
<Menu
className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
onCancel={() => setIsDropdownOpen?.(false)}
>
{viewOptions.map((option) => (
@@ -136,11 +136,11 @@ export const useBooksSync = () => {
await appService?.downloadBookCovers(batch);
await Promise.all(batch.map(processNewBook));
const progress = Math.min((i + batchSize) / newBooks.length, 1);
setLibrary([...updatedLibrary]);
setSyncProgress(progress);
setLibrary([...updatedLibrary]);
appService?.saveLibraryBooks(updatedLibrary);
}
setLibrary(updatedLibrary);
appService?.saveLibraryBooks(updatedLibrary);
} finally {
if (newBooks.length > 0) {
setIsSyncing(false);
+139 -71
View File
@@ -10,12 +10,13 @@ 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 { getFilename } from '@/utils/path';
import { transferManager } from '@/services/transferManager';
import { getDirPath, getFilename, joinPaths } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
@@ -34,10 +35,13 @@ import { useTheme } from '@/hooks/useTheme';
import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useBookDataStore } from '@/store/bookDataStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation } from '@/utils/bridge';
import { lockScreenOrientation, selectDirectory } from '@/utils/bridge';
import { requestStoragePermission } from '@/utils/permission';
import { SUPPORTED_BOOK_EXTS } from '@/services/constants';
import {
tauriHandleClose,
tauriHandleSetAlwaysOnTop,
@@ -52,6 +56,7 @@ import { UpdaterWindow } from '@/components/UpdaterWindow';
import { CatalogDialog } from './components/OPDSDialog';
import { MigrateDataWindow } from './components/MigrateDataWindow';
import { useDragDropImport } from './hooks/useDragDropImport';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { Toast } from '@/components/Toast';
import { getBreadcrumbs } from './utils/libraryUtils';
import Spinner from '@/components/Spinner';
@@ -87,9 +92,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const _ = useTranslation();
const { selectFiles } = useFileSelector(appService, _);
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
const { clearBookData } = useBookDataStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const [showCatalogManager, setShowCatalogManager] = useState(false);
const [showCatalogManager, setShowCatalogManager] = useState(
searchParams?.get('opds') === 'true',
);
const [loading, setLoading] = useState(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
const [isSelectMode, setIsSelectMode] = useState(false);
@@ -114,6 +122,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useUICSS();
useOpenWithBooks();
useTransferQueue(libraryLoaded);
const { pullLibrary, pushLibrary } = useBooksSync();
const { isDragging } = useDragDropImport();
@@ -141,7 +150,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setSettingsDialogOpen(true);
},
onOpenBooks: () => {
handleImportBooks();
handleImportBooksFromFiles();
},
});
@@ -227,6 +236,13 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (book) {
bookIds.push(book.hash);
}
if (user && book && !temp && !book.uploadedAt && settings.autoUpload) {
setTimeout(() => {
console.log('Queueing upload for book:', book.title);
transferManager.queueUpload(book);
// wait for the initialization of the transfer manager and opening of the book
}, 3000);
}
} catch (error) {
console.log('Failed to import book:', file, error);
}
@@ -266,6 +282,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return false;
};
const handleShowOPDSDialog = () => {
setShowCatalogManager(true);
};
const handleDismissOPDSDialog = () => {
setShowCatalogManager(false);
const params = new URLSearchParams(searchParams?.toString());
params.delete('opds');
navigateToLibrary(router, `${params.toString()}`);
};
useEffect(() => {
if (pendingNavigationBookIds) {
const bookIds = pendingNavigationBookIds;
@@ -319,7 +346,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const handleOpenWithBooks = async (appService: AppService, library: Book[]) => {
const openWithFiles = (await parseOpenWithFiles()) || [];
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
if (openWithFiles.length > 0) {
return await processOpenWithFiles(appService, openWithFiles, library);
@@ -364,6 +391,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setLoading(true);
const { library } = useLibraryStore.getState();
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
const successfulImports: string[] = [];
const errorMap: [string, string][] = [
['No chapters detected', _('No chapters detected')],
['Failed to parse EPUB', _('Failed to parse the EPUB file')],
@@ -378,14 +406,24 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
if (!file) return;
try {
const book = await appService?.importBook(file, library);
const { path, basePath } = selectedFile;
if (book && groupId) {
book.groupId = groupId;
book.groupName = getGroupName(groupId);
await updateBook(envConfig, book);
} else if (book && 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);
console.log('Queueing upload for book:', book.title);
transferManager.queueUpload(book);
}
if (book) {
successfulImports.push(book.title);
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
@@ -415,8 +453,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(filenames),
}) + (errorMessage ? `\n${errorMessage}` : ''),
timeout: 5000,
type: 'error',
});
} else if (successfulImports.length > 0) {
eventDispatcher.dispatch('toast', {
message: _('Successfully imported {{count}} book(s)', {
count: successfulImports.length,
}),
timeout: 2000,
type: 'success',
});
}
setLibrary([...library]);
@@ -434,83 +481,66 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}, 500);
const handleBookUpload = useCallback(
async (book: Book, syncBooks = true) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
await updateBook(envConfig, book);
if (syncBooks) pushLibrary();
async (book: Book, _syncBooks = true) => {
// Use transfer queue for uploads - priority 1 for manual uploads (higher priority)
const transferId = transferManager.queueUpload(book, 1);
if (transferId) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book uploaded: {{title}}', {
message: _('Upload queued: {{title}}', {
title: book.title,
}),
});
return true;
} catch (err) {
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return false;
}
}
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to upload book: {{title}}', {
title: book.title,
}),
});
return false;
}
return false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
[],
);
const handleBookDownload = useCallback(
async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
async (book: Book, downloadOptions: { redownload?: boolean; queued?: boolean } = {}) => {
const { redownload = false, queued = false } = downloadOptions;
if (redownload || !queued) {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
}
// Use transfer queue for normal downloads - priority 1 for manual downloads
const transferId = transferManager.queueDownload(book, 1);
if (transferId) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
message: _('Download queued: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
return false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
@@ -531,6 +561,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
try {
await appService?.deleteBook(book, deleteAction);
await updateBook(envConfig, book);
clearBookData(book.hash);
if (syncBooks) pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
@@ -579,9 +610,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
await updateBook(envConfig, book);
};
const handleImportBooks = async () => {
const handleImportBooksFromFiles = async () => {
setIsSelectMode(false);
console.log('Importing books...');
console.log('Importing books from files...');
selectFiles({ type: 'books', multiple: true }).then((result) => {
if (result.files.length === 0 || result.error) return;
const groupId = searchParams?.get('group') || '';
@@ -589,6 +620,40 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
};
const handleImportBooksFromDirectory = async () => {
if (!appService || !isTauriAppPlatform()) return;
setIsSelectMode(false);
console.log('Importing books from directory...');
let importDirectory: string | undefined = '';
if (appService.isAndroidApp) {
if (!(await requestStoragePermission())) return;
const response = await selectDirectory();
importDirectory = response.path;
} else {
const selectedDir = await appService.selectDirectory?.('read');
importDirectory = selectedDir;
}
if (!importDirectory) {
console.log('No directory selected');
return;
}
const files = await appService.readDirectory(importDirectory, 'None');
const supportedFiles = files.filter((file) => {
const ext = file.path.split('.').pop()?.toLowerCase() || '';
return SUPPORTED_BOOK_EXTS.includes(ext);
});
const toImportFiles = await Promise.all(
supportedFiles.map(async (file) => {
return {
path: await joinPaths(importDirectory, file.path),
basePath: importDirectory,
};
}),
);
importBooks(toImportFiles, undefined);
};
const handleSetSelectMode = (selectMode: boolean) => {
if (selectMode && appService?.hasHaptics) {
impactFeedback('medium');
@@ -653,8 +718,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
onImportBooks={handleImportBooks}
onOpenCatalogManager={() => setShowCatalogManager(true)}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
@@ -739,7 +807,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
handleImportBooks={handleImportBooks}
handleImportBooks={handleImportBooksFromFiles}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete('both')}
@@ -761,7 +829,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
'Welcome to your library. You can import your books here and read them anytime.',
)}
</p>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
<button className='btn btn-primary rounded-xl' onClick={handleImportBooksFromFiles}>
{_('Import Books')}
</button>
</div>
@@ -785,7 +853,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<UpdaterWindow />
<MigrateDataWindow />
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
{showCatalogManager && <CatalogDialog onClose={() => setShowCatalogManager(false)} />}
{showCatalogManager && <CatalogDialog onClose={handleDismissOPDSDialog} />}
<Toast />
</div>
);
@@ -1,92 +0,0 @@
'use client';
import clsx from 'clsx';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { GiBookshelf } from 'react-icons/gi';
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { navigateToLibrary } from '@/utils/nav';
interface NavigationProps {
currentURL: string;
startURL?: string;
onNavigate: (url: string) => void;
onBack?: () => void;
onForward?: () => void;
canGoBack: boolean;
canGoForward: boolean;
}
export function Navigation({
startURL,
onNavigate,
onBack,
onForward,
canGoBack,
canGoForward,
}: NavigationProps) {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const handleGoHome = useCallback(() => {
if (startURL) {
onNavigate(startURL);
}
}, [startURL, onNavigate]);
const handleGoLibrary = useCallback(() => {
navigateToLibrary(router, '', {}, true);
}, [router]);
return (
<header
className={clsx(
'navbar min-h-0 px-2',
'flex h-[48px] w-full items-center',
appService?.isMobile ? '' : 'border-base-300 bg-base-200 border-b',
)}
>
<div className={clsx('navbar-start gap-1', isTrafficLightVisible && '!pl-16')}>
{onBack && (
<button
className='btn btn-ghost btn-sm px-3 disabled:bg-transparent'
onClick={onBack}
disabled={!canGoBack}
title={_('Back')}
>
<IoChevronBack className='h-6 w-6' />
</button>
)}
{onForward && (
<button
className='btn btn-ghost btn-sm disabled:bg-transparent'
onClick={onForward}
disabled={!canGoForward}
title={_('Forward')}
>
<IoChevronForward className='h-6 w-6' />
</button>
)}
</div>
<div className='navbar-center'>
<h1 className='max-w-md truncate text-base font-semibold'>{_('OPDS Catalog')}</h1>
</div>
<div className='navbar-end gap-2'>
<button className='btn btn-ghost btn-sm' onClick={handleGoHome} title={_('Home')}>
<IoHome className='h-5 w-5' />
</button>
<button className='btn btn-ghost btn-sm' onClick={handleGoLibrary} title={_('Library')}>
<GiBookshelf className='h-5 w-5' />
</button>
</div>
</header>
);
}
@@ -10,7 +10,7 @@ import { isWebAppPlatform } from '@/services/environment';
import { saveSysSettings } from '@/helpers/settings';
import { OPDSCatalog } from '@/types/opds';
import { isLanAddress } from '@/utils/network';
import { validateOPDSURL } from './utils/opdsUtils';
import { validateOPDSURL } from '../utils/opdsUtils';
import ModalPortal from '@/components/ModalPortal';
const POPULAR_CATALOGS: OPDSCatalog[] = [
@@ -21,6 +21,13 @@ const POPULAR_CATALOGS: OPDSCatalog[] = [
description: "World's largest collection of free ebooks",
icon: '🏛️',
},
{
id: 'standardebooks',
name: 'Standard Ebooks',
url: 'https://standardebooks.org/feeds/opds',
description: 'Free and liberated ebooks, carefully produced for the true book lover',
icon: '📚',
},
{
id: 'manybooks',
name: 'ManyBooks',
@@ -28,6 +35,13 @@ const POPULAR_CATALOGS: OPDSCatalog[] = [
description: 'Over 50,000 free ebooks',
icon: '📖',
},
{
id: 'unglue.it',
name: 'Unglue.it',
url: 'https://unglue.it/api/opds/',
description: 'Free ebooks from authors who have "unglued" their books',
icon: '🔓',
},
];
async function validateOPDSCatalog(
@@ -65,6 +79,12 @@ export function CatalogManager() {
const handleAddCatalog = async () => {
if (!newCatalog.name || !newCatalog.url) return;
const urlLower = newCatalog.url.trim().toLowerCase();
if (!urlLower.startsWith('http://') && !urlLower.startsWith('https://')) {
setUrlError(_('URL must start with http:// or https://'));
return;
}
if (
process.env['NODE_ENV'] === 'production' &&
isWebAppPlatform() &&
@@ -119,8 +139,7 @@ export function CatalogManager() {
const handleOpenCatalog = (catalog: OPDSCatalog) => {
const params = new URLSearchParams({ url: catalog.url });
if (catalog.username) params.set('username', catalog.username);
if (catalog.password) params.set('password', catalog.password);
if (catalog.username) params.set('id', catalog.id);
router.push(`/opds?${params.toString()}`);
};
@@ -172,7 +191,7 @@ export function CatalogManager() {
<div className='flex items-center justify-between'>
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center justify-between'>
<h3 className='card-title truncate text-sm'>
<h3 className='card-title line-clamp-1 text-sm'>
{catalog.icon && <span className=''>{catalog.icon}</span>}
{catalog.name}
</h3>
@@ -189,7 +208,7 @@ export function CatalogManager() {
{catalog.description}
</p>
)}
<p className='text-base-content/50 truncate text-xs'>{catalog.url}</p>
<p className='text-base-content/50 line-clamp-1 text-xs'>{catalog.url}</p>
{catalog.username && (
<p className='text-base-content/50 mt-1 text-xs'>
{_('Username')}: {catalog.username}
@@ -217,7 +236,7 @@ export function CatalogManager() {
<section className='text-base'>
<h2 className='mb-4 font-semibold'>{_('Popular Catalogs')}</h2>
<div className='grid gap-4 sm:grid-cols-2'>
{POPULAR_CATALOGS.map((catalog) => {
{POPULAR_CATALOGS.filter((catalog) => !catalog.disabled).map((catalog) => {
const isAdded = catalogs.some((c) => c.url === catalog.url);
return (
<div
@@ -387,9 +406,6 @@ export function CatalogManager() {
</div>
</form>
</div>
<form method='dialog' className='modal-backdrop'>
<button onClick={() => setShowAddDialog(false)}>{_('Close')}</button>
</form>
</dialog>
</ModalPortal>
)}
@@ -1,12 +1,13 @@
'use client';
import { useMemo } from 'react';
import { useMemo, useCallback } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { OPDSFeed, OPDSLink } from '@/types/opds';
import { PublicationCard } from './PublicationCard';
import { NavigationCard } from './NavigationCard';
import { groupByArray } from './utils/opdsUtils';
import { groupByArray } from '../utils/opdsUtils';
interface FeedViewProps {
feed: OPDSFeed;
@@ -18,9 +19,9 @@ interface FeedViewProps {
isOPDSCatalog: (type?: string) => boolean;
}
const gridClassName = 'grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
const gridClassName = 'grid grid-cols-3 gap-4 px-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
const navigationClassName =
'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
'grid grid-cols-2 gap-4 px-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
export function FeedView({
feed,
@@ -51,21 +52,34 @@ export function FeedView({
onNavigate(url);
};
const itemContent = useCallback(
(index: number) => (
<PublicationCard
publication={feed.publications![index]!}
baseURL={baseURL}
onClick={() => onPublicationSelect(-1, index)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
),
[feed.publications, baseURL, onPublicationSelect, resolveURL, onGenerateCachedImageUrl],
);
return (
<div className='container mx-auto max-w-7xl px-4 py-6'>
<div className='flex h-full flex-col'>
{/* Header */}
<div className='opds-header mb-6'>
<div className='opds-header flex-shrink-0 px-4 py-6'>
{feed.metadata?.title && <h1 className='mb-2 text-xl font-bold'>{feed.metadata.title}</h1>}
{feed.metadata?.subtitle && (
<p className='text-base-content/70 text-sm'>{feed.metadata.subtitle}</p>
)}
</div>
<div className='flex gap-6'>
<div className='flex min-h-0 flex-1 gap-6'>
{/* Facets Sidebar */}
{hasFacets && (
<aside className='hidden w-64 flex-shrink-0 lg:block'>
<div className='sticky top-6'>
<aside className='hidden w-64 flex-shrink-0 overflow-y-auto lg:block'>
<div className='px-4'>
<div className='mb-4 flex items-center gap-2'>
<IoFilter className='h-5 w-5' />
<h2 className='text-lg font-semibold'>Filters</h2>
@@ -113,10 +127,10 @@ export function FeedView({
)}
{/* Main Content */}
<div className='min-w-0 flex-1'>
<div className='flex min-w-0 flex-1 flex-col'>
{/* Navigation Items */}
{feed.navigation && feed.navigation.length > 0 && (
<section className='opds-navigation mb-8'>
<section className='opds-navigation flex-shrink-0 pb-6'>
<div className={navigationClassName}>
{feed.navigation.map((item, index: number) => (
<NavigationCard
@@ -131,29 +145,23 @@ export function FeedView({
</section>
)}
{/* Publications */}
{/* Publications Grid - Takes remaining space */}
{feed.publications && feed.publications.length > 0 && (
<section className='opds-publications mb-8'>
<div className={gridClassName}>
{feed.publications.map((pub, index: number) => (
<PublicationCard
key={index}
publication={pub}
baseURL={baseURL}
onClick={() => onPublicationSelect(-1, index)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
))}
</div>
<section className='opds-publications min-h-0 flex-1'>
<VirtuosoGrid
style={{ height: '100%' }}
totalCount={feed.publications.length}
listClassName={gridClassName}
itemContent={itemContent}
/>
</section>
)}
{/* Groups */}
{feed.groups?.map((group, groupIndex: number) => (
<section key={groupIndex} className='mb-12'>
<section key={groupIndex} className='mb-12 flex-shrink-0'>
{group.metadata && (
<div className='mb-4 flex items-center justify-between'>
<div className='mb-4 flex items-center justify-between px-4'>
<h2 className='text-2xl font-bold'>{group.metadata.title}</h2>
{group.links && group.links.length > 0 && (
<button
@@ -203,7 +211,7 @@ export function FeedView({
{/* Pagination */}
{pagination.some((links) => links && links.length > 0) && (
<nav className='mt-8 flex justify-center gap-2'>
<nav className='flex flex-shrink-0 justify-center gap-2 py-4'>
<button
onClick={() => handlePaginationClick(pagination[0])}
disabled={!pagination[0]}
@@ -0,0 +1,165 @@
'use client';
import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { IoMdCloseCircle } from 'react-icons/io';
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useSettingsStore } from '@/store/settingsStore';
import { navigateToLibrary } from '@/utils/nav';
import { debounce } from '@/utils/debounce';
import WindowButtons from '@/components/WindowButtons';
interface NavigationProps {
searchTerm?: string;
onBack?: () => void;
onForward?: () => void;
onGoStart: () => void;
onSearch: (queryTerm: string) => void;
canGoBack: boolean;
canGoForward: boolean;
hasSearch: boolean;
}
export function Navigation({
searchTerm,
onBack,
onForward,
onGoStart,
onSearch,
canGoBack,
canGoForward,
hasSearch = false,
}: NavigationProps) {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const viewSettings = settings.globalViewSettings;
const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const { isTrafficLightVisible } = useTrafficLight();
useEffect(() => {
setSearchQuery(searchTerm || '');
}, [searchTerm]);
useEffect(() => {
if (hasSearch && inputRef.current) {
inputRef.current.focus();
}
}, [hasSearch]);
const handleGoLibrary = useCallback(() => {
navigateToLibrary(router, 'opds=true', {}, true);
}, [router]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdateQueryParam = useCallback(
debounce((value: string) => {
if (value) {
onSearch(value);
}
}, 1000),
[onSearch],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
debouncedUpdateQueryParam(newQuery);
};
return (
<header
className={clsx(
'navbar min-h-0 px-2',
'flex h-[48px] w-full items-center',
appService?.isMobile ? '' : 'bg-base-100',
)}
>
<div className={clsx('justify-start gap-1 sm:gap-3', isTrafficLightVisible && '!pl-16')}>
<div className='flex gap-1'>
{onBack && (
<button
className='btn btn-ghost btn-sm px-1 disabled:bg-transparent'
onClick={onBack}
disabled={!canGoBack}
title={_('Back')}
>
<IoChevronBack className='h-6 w-6' />
</button>
)}
{onForward && (
<button
className='btn btn-ghost btn-sm px-1 disabled:bg-transparent'
onClick={onForward}
disabled={!canGoForward}
title={_('Forward')}
>
<IoChevronForward className='h-6 w-6' />
</button>
)}
</div>
<button className='btn btn-ghost btn-sm px-1' onClick={onGoStart} title={_('Home')}>
<IoHome className='h-5 w-5' />
</button>
</div>
<div className='flex-grow px-3 sm:px-5'>
<div className='relative flex w-full items-center'>
<span className='text-base-content/50 absolute left-3'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
ref={inputRef}
value={searchQuery}
placeholder={_('Search in OPDS Catalog...')}
disabled={!hasSearch}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge h-9 w-full pl-10 pr-4 sm:h-7',
viewSettings?.isEink
? 'border-1 border-base-content focus:border-base-content'
: 'bg-base-300/45 border-none',
'font-sans text-sm font-light',
'placeholder:text-base-content/50 truncate',
'focus:outline-none focus:ring-0',
)}
/>
<div className='text-base-content/50 absolute right-2 flex items-center space-x-2 sm:space-x-4'>
{searchQuery && (
<button
type='button'
onClick={() => {
setSearchQuery('');
onGoStart();
}}
className='text-base-content/40 hover:text-base-content/60 pe-1'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
</button>
)}
</div>
</div>
</div>
<div className='justify-end gap-2 px-1'>
<WindowButtons
className='window-buttons flex h-full items-center'
onClose={() => {
handleGoLibrary();
}}
/>
</div>
</header>
);
}
@@ -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' />
@@ -1,10 +1,10 @@
'use client';
import { useMemo } from 'react';
import { groupByArray } from './utils/opdsUtils';
import { useTranslation } from '@/hooks/useTranslation';
import { CachedImage } from '@/components/CachedImage';
import { OPDSPublication, REL } from '@/types/opds';
import { useTranslation } from '@/hooks/useTranslation';
import { groupByArray } from '../utils/opdsUtils';
interface PublicationCardProps {
publication: OPDSPublication;
@@ -5,13 +5,14 @@ import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { IoPricetag } from 'react-icons/io5';
import { Book } from '@/types/book';
import { groupByArray } from './utils/opdsUtils';
import { OPDSLink, OPDSPublication, REL, SYMBOL } from '@/types/opds';
import { useTranslation } from '@/hooks/useTranslation';
import { getFileExtFromMimeType } from '@/libs/document';
import { formatDate, formatLanguage } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { navigateToReader } from '@/utils/nav';
import { CachedImage } from '@/components/CachedImage';
import { OPDSLink, OPDSPublication, REL, SYMBOL } from '@/types/opds';
import { groupByArray } from '../utils/opdsUtils';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/MenuItem';
@@ -37,7 +38,6 @@ export function PublicationView({
const _ = useTranslation();
const router = useRouter();
const [downloading, setDownloading] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const [downloadedBook, setDownloadedBook] = useState<Book | null>(null);
const [progress, setProgress] = useState<number | null>(null);
@@ -78,7 +78,6 @@ export function PublicationView({
}
setDownloading(true);
setDownloaded(false);
setProgress(null);
try {
@@ -149,13 +148,13 @@ export function PublicationView({
<div className='flex flex-wrap gap-2'>
{acquisitionLinks.map(({ rel, links }) => (
<div key={rel} className='flex gap-1'>
{links.length === 1 ? (
{links.length === 1 || downloadedBook ? (
<button
onClick={() => handleActionButton(links[0]!.href, links[0]!.type)}
disabled={downloading}
className={clsx(
'btn btn-primary min-w-20 rounded-3xl',
downloaded && 'btn-success',
downloadedBook && 'btn-success',
)}
>
{downloadedBook ? _('Open & Read') : getAcquisitionLabel(rel)}
@@ -164,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' : ''}`,
downloaded && 'btn-success',
)}
>
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
</div>
<div>{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}</div>
}
>
<div
@@ -190,7 +183,11 @@ export function PublicationView({
key={idx}
noIcon
transient
label={link.title || link.type || ''}
label={
link.title ||
getFileExtFromMimeType(link.type || '').toUpperCase() ||
idx.toString()
}
onClick={() => handleActionButton(link.href, link.type)}
/>
))}
@@ -2,20 +2,28 @@
import { useState, FormEvent } from 'react';
import { IoSearch } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { OPDSSearch } from '@/types/opds';
interface SearchViewProps {
search: OPDSSearch;
baseURL: string;
onNavigate: (url: string) => void;
onNavigate: (url: string, isSearch?: boolean) => void;
resolveURL: (url: string, base: string) => string;
}
export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchViewProps) {
const _ = useTranslation();
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
search.params?.forEach((param) => {
initial[param.name] = param.value || '';
if (param.name === 'count') {
initial[param.name] = '20';
} else if (param.name === 'startPage') {
initial[param.name] = '1';
} else {
initial[param.name] = param.value || '';
}
});
return initial;
});
@@ -38,7 +46,7 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
const searchURL = search.search(map);
const resolvedURL = resolveURL(searchURL, baseURL);
onNavigate(resolvedURL);
onNavigate(resolvedURL, true);
};
const handleInputChange = (name: string, value: string) => {
@@ -47,21 +55,23 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
const getParamLabel = (name: string): string => {
const labels: Record<string, string> = {
searchTerms: 'Search',
query: 'Query',
title: 'Title',
author: 'Author',
publisher: 'Publisher',
language: 'Language',
subject: 'Subject',
searchTerms: _('Title, Author, Tag, etc...'),
query: _('Query'),
title: _('Title'),
author: _('Author'),
publisher: _('Publisher'),
language: _('Language'),
subject: _('Subject'),
count: _('Count'),
startPage: _('Start Page'),
};
return labels[name] || name;
};
return (
<div className='container mx-auto max-w-2xl px-4 py-12'>
<div className='container mx-auto max-w-md px-4 py-12'>
<div className='mb-8 text-center'>
<h1 className='mb-2 text-3xl font-bold'>{search.metadata?.title || 'Search'}</h1>
<h1 className='mb-2 text-xl font-bold'>{search.metadata?.title || _('Search')}</h1>
{search.metadata?.description && (
<p className='text-base-content/70'>{search.metadata.description}</p>
)}
@@ -81,7 +91,7 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
value={formData[param.name] || ''}
onChange={(e) => handleInputChange(param.name, e.target.value)}
required={param.required}
placeholder={`Enter ${getParamLabel(param.name).toLowerCase()}`}
placeholder={`${_('Enter {{terms}}', { terms: getParamLabel(param.name).toLowerCase() })}`}
className='input input-bordered w-full'
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={
@@ -96,7 +106,7 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
<div className='pt-4'>
<button type='submit' className='btn btn-primary w-full'>
<IoSearch className='h-5 w-5' />
Search
{_('Search')}
</button>
</div>
</form>
+187 -43
View File
@@ -1,30 +1,40 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import clsx from 'clsx';
import { md5 } from 'js-md5';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { isOPDSCatalog, getPublication, getFeed, getOpenSearch } from 'foliate-js/opds.js';
import { md5 } from 'js-md5';
import { openUrl } from '@tauri-apps/plugin-opener';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { isWebAppPlatform } from '@/services/environment';
import { FeedView } from './FeedView';
import { PublicationView } from './PublicationView';
import { SearchView } from './SearchView';
import { Navigation } from './Navigation';
import { getBaseFilename } from '@/utils/path';
import { downloadFile } from '@/libs/storage';
import { Toast } from '@/components/Toast';
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 { MIME, parseMediaType, resolveURL } from './utils/opdsUtils';
import {
getFileExtFromPath,
isSearchLink,
MIME,
parseMediaType,
resolveURL,
} from './utils/opdsUtils';
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
import clsx from 'clsx';
import { useThemeStore } from '@/store/themeStore';
import { useTheme } from '@/hooks/useTheme';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { FeedView } from './components/FeedView';
import { PublicationView } from './components/PublicationView';
import { SearchView } from './components/SearchView';
import { Navigation } from './components/Navigation';
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
@@ -48,8 +58,10 @@ 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();
const [viewMode, setViewMode] = useState<ViewMode>('loading');
const [state, setState] = useState<OPDSState>({
baseURL: '',
@@ -65,16 +77,19 @@ export default function BrowserPage() {
const [historyIndex, setHistoryIndex] = useState(-1);
const searchParams = useSearchParams();
const usernameRef = useRef(searchParams?.get('username'));
const passwordRef = useRef(searchParams?.get('password'));
const catalogUrl = searchParams?.get('url') || '';
const catalogId = searchParams?.get('id') || '';
const usernameRef = useRef<string | null | undefined>(undefined);
const passwordRef = useRef<string | null | undefined>(undefined);
const startURLRef = useRef<string | null | undefined>(undefined);
const loadingOPDSRef = useRef(false);
const startURLRef = useRef<string | undefined>(undefined);
const historyIndexRef = useRef(-1);
const isNavigatingHistoryRef = useRef(false);
const searchTermRef = useRef('');
useTheme({ systemUIVisible: false });
useTransferQueue(libraryLoaded);
// Keep refs in sync with state
useEffect(() => {
startURLRef.current = state.startURL;
}, [state.startURL]);
@@ -102,8 +117,44 @@ export default function BrowserPage() {
[],
);
const quickSearch = useCallback((search: OPDSSearch, baseURL: string, searchTerms: string) => {
if (searchTerms) {
const formData: Record<string, string> = {};
search.params?.forEach((param) => {
if (param.name === 'count') {
formData[param.name] = '20';
} else if (param.name === 'startPage') {
formData[param.name] = '1';
} else if (param.name === 'searchTerms') {
formData[param.name] = searchTerms;
} else {
formData[param.name] = param.value || '';
}
});
const map = new Map<string | null, Map<string | null, string>>();
for (const param of search.params || []) {
const value = formData[param.name] || '';
const ns = param.ns ?? null;
if (map.has(ns)) {
map.get(ns)!.set(param.name, value);
} else {
map.set(ns, new Map([[param.name, value]]));
}
}
const searchURL = search.search(map);
const resolvedURL = resolveURL(searchURL, baseURL);
handleNavigate(resolvedURL, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadOPDS = useCallback(
async (url: string, skipHistory = false) => {
async (url: string, options: { skipHistory?: boolean; isSearch?: boolean } = {}) => {
const { skipHistory = false, isSearch = false } = options;
if (loadingOPDSRef.current) return;
loadingOPDSRef.current = true;
@@ -117,15 +168,30 @@ export default function BrowserPage() {
const res = await fetchWithAuth(url, username, password, useProxy);
if (!res.ok) {
eventDispatcher.dispatch('toast', {
message: `Failed to load OPDS feed: ${res.status} ${res.statusText}`,
timeout: 5000,
type: 'error',
});
setTimeout(() => {
router.back();
}, 5000);
throw new Error(`Failed to load OPDS feed: ${res.status} ${res.statusText}`);
if (isSearch && res.status === 404) {
const warnMessage = _('No search results found');
eventDispatcher.dispatch('toast', {
message: warnMessage,
timeout: 2000,
type: 'warning',
});
setViewMode('search');
return;
} else {
const errorMessage = _('Failed to load OPDS feed: {{status}} {{statusText}}', {
status: res.status,
statusText: res.statusText,
});
eventDispatcher.dispatch('toast', {
message: errorMessage,
timeout: 5000,
type: 'error',
});
setTimeout(() => {
router.back();
}, 5000);
throw new Error(errorMessage);
}
}
const currentStartURL = startURLRef.current || url;
@@ -176,9 +242,12 @@ export default function BrowserPage() {
startURL: currentStartURL || responseURL,
};
setState(newState);
setViewMode('search');
setSelectedPublication(null);
if (searchTermRef.current) {
quickSearch(search, responseURL, searchTermRef.current);
} else {
setViewMode('search');
setSelectedPublication(null);
}
if (!skipHistory) {
addToHistory(url, newState, 'search', null);
}
@@ -231,14 +300,14 @@ export default function BrowserPage() {
loadingOPDSRef.current = false;
}
},
[router, addToHistory],
[_, router, quickSearch, addToHistory],
);
useEffect(() => {
const url = searchParams?.get('url');
const username = searchParams?.get('username');
const password = searchParams?.get('password');
const url = catalogUrl;
if (url && !isNavigatingHistoryRef.current) {
const catalog = settings.opdsCatalogs?.find((cat) => cat.id === catalogId);
const { username, password } = catalog || {};
if (username || password) {
usernameRef.current = username;
passwordRef.current = password;
@@ -246,25 +315,87 @@ export default function BrowserPage() {
usernameRef.current = null;
passwordRef.current = null;
}
loadOPDS(url);
if (libraryLoaded) {
loadOPDS(url);
}
} else if (isNavigatingHistoryRef.current) {
isNavigatingHistoryRef.current = false;
} else {
setViewMode('error');
setError(new Error('No OPDS URL provided'));
}
}, [searchParams, loadOPDS]);
}, [catalogUrl, catalogId, settings, libraryLoaded, loadOPDS]);
const handleNavigate = useCallback(
(url: string) => {
(url: string, isSearch = false) => {
const newURL = new URL(window.location.href);
newURL.searchParams.set('url', url);
window.history.pushState({}, '', newURL.toString());
loadOPDS(url);
loadOPDS(url, { isSearch });
},
[loadOPDS],
);
const hasSearch = useMemo(() => {
return !!state.feed?.links?.find(isSearchLink);
}, [state.feed]);
const handleGoStart = useCallback(() => {
if (startURLRef.current) {
handleNavigate(startURLRef.current);
}
searchTermRef.current = '';
}, [startURLRef, handleNavigate]);
const handleSearch = useCallback(
(queryTerm: string) => {
if (!state.feed) return;
searchTermRef.current = queryTerm;
const searchLink = state.feed.links?.find(isSearchLink);
if (searchLink && searchLink.href) {
const searchURL = resolveURL(searchLink.href, state.baseURL);
if (searchLink.type === MIME.OPENSEARCH) {
handleNavigate(searchURL, true);
} else if (searchLink.type === MIME.ATOM) {
const search: OPDSSearch = {
metadata: {
title: _('Search'),
description: state.feed.metadata?.title
? _('Search in {{title}}', { title: state.feed.metadata.title })
: undefined,
},
params: [
{
name: 'searchTerms',
required: true,
},
],
search: (map: Map<string | null, Map<string | null, string>>) => {
const defaultParams = map.get(null);
const searchTerms = defaultParams?.get('searchTerms') || '';
const decodedURL = decodeURIComponent(searchURL);
return decodedURL.replace('{searchTerms}', encodeURIComponent(searchTerms));
},
};
const newState: OPDSState = {
feed: state.feed,
search,
baseURL: state.baseURL,
currentURL: state.currentURL,
startURL: state.startURL,
};
setState(newState);
setSelectedPublication(null);
setViewMode('search');
addToHistory(state.currentURL, newState, 'search', null);
}
}
},
[_, state, handleNavigate, addToHistory],
);
const handleDownload = useCallback(
async (
href: string,
@@ -283,8 +414,9 @@ export default function BrowserPage() {
}
return;
} else {
const ext = parsed?.mediaType ? getFileExtFromMimeType(parsed.mediaType) : '';
const basename = getBaseFilename(url);
const pathname = decodeURIComponent(new URL(url).pathname);
const ext = getFileExtFromMimeType(parsed?.mediaType) || getFileExtFromPath(pathname);
const basename = pathname.replaceAll('/', '_');
const filename = ext ? `${basename}.${ext}` : basename;
const dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
if (dstFilePath) {
@@ -292,7 +424,10 @@ export default function BrowserPage() {
const password = passwordRef.current || '';
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {};
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) {
@@ -308,10 +443,16 @@ export default function BrowserPage() {
url: downloadUrl,
headers,
singleThreaded: true,
skipSslVerification: true,
onProgress,
});
const { library, setLibrary } = useLibraryStore.getState();
const book = await appService.importBook(dstFilePath, library);
if (user && book && !book.uploadedAt && settings.autoUpload) {
setTimeout(() => {
transferManager.queueUpload(book);
}, 3000);
}
setLibrary(library);
appService.saveLibraryBooks(library);
return book;
@@ -323,7 +464,8 @@ export default function BrowserPage() {
}
return;
},
[state.baseURL, appService, libraryLoaded],
// eslint-disable-next-line react-hooks/exhaustive-deps
[user, state.baseURL, appService, libraryLoaded],
);
const handleGenerateCachedImageUrl = useCallback(
@@ -357,6 +499,7 @@ export default function BrowserPage() {
cfp: '',
url: downloadUrl,
singleThreaded: true,
skipSslVerification: true,
headers,
});
return await appService.getImageURL(cachedPath);
@@ -446,13 +589,14 @@ export default function BrowserPage() {
}}
>
<Navigation
currentURL={state.currentURL}
startURL={state.startURL}
onNavigate={handleNavigate}
searchTerm={searchTermRef.current}
onBack={handleBack}
onForward={handleForward}
onGoStart={handleGoStart}
onSearch={handleSearch}
canGoBack={canGoBack}
canGoForward={canGoForward}
hasSearch={hasSearch}
/>
</div>
<main className='flex-1 overflow-auto'>

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