Compare commits

...

65 Commits

Author SHA1 Message Date
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
169 changed files with 10707 additions and 1954 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. | 🔄 |
+86 -32
View File
@@ -64,39 +64,93 @@ const nextConfig = {
},
};
const withPWA = withPWAInit({
dest: 'public',
disable: isDev || appPlatform !== 'web',
cacheStartUrl: false,
dynamicStartUrl: false,
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
swcMinify: true,
fallbacks: {
document: '/offline',
},
workboxOptions: {
disableDevLogs: true,
manifestTransforms: [
(manifestEntries) => {
const manifest = manifestEntries.filter((entry) => {
const url = entry.url;
return (
!url.includes('dynamic-css-manifest.json') &&
!url.includes('middleware-manifest.json') &&
!url.includes('react-loadable-manifest.json') &&
!url.includes('build-manifest.json') &&
!url.includes('_buildManifest.js') &&
!url.includes('_ssgManifest.js') &&
!url.includes('_headers')
);
});
return { manifest };
const pwaDisabled = isDev || appPlatform !== 'web';
const withPWA = pwaDisabled
? (config) => config
: withPWAInit({
dest: 'public',
cacheStartUrl: false,
dynamicStartUrl: false,
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
swcMinify: true,
fallbacks: {
document: '/offline',
},
],
},
});
workboxOptions: {
disableDevLogs: true,
runtimeCaching: [
{
urlPattern: ({ url, request }) => {
const clientRoutes = ['/library', '/reader'];
const isClientRoute = clientRoutes.some((route) => url.pathname.startsWith(route));
return isClientRoute && request.mode === 'navigate';
},
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'pages-cache',
expiration: {
maxAgeSeconds: 365 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
const basePath = url.pathname.split('/')[1];
const cacheKey = `${url.origin}/${basePath}`;
return cacheKey;
},
},
],
},
},
{
urlPattern: ({ url }) => {
if (url.pathname.startsWith('/api/')) {
return false;
}
return /^https?.*/.test(url.href);
},
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'offlineCache',
expiration: {
maxEntries: 512,
maxAgeSeconds: 365 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
manifestTransforms: [
(manifestEntries) => {
const manifest = manifestEntries.filter((entry) => {
const url = entry.url;
return (
!url.includes('dynamic-css-manifest.json') &&
!url.includes('middleware-manifest.json') &&
!url.includes('react-loadable-manifest.json') &&
!url.includes('build-manifest.json') &&
!url.includes('_buildManifest.js') &&
!url.includes('_ssgManifest.js') &&
!url.includes('_headers')
);
});
return { manifest };
},
],
},
});
const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
+14 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.94",
"version": "0.9.96",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -37,7 +37,7 @@
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0",
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0 --port 3001",
"deploy": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare deploy",
"upload": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
@@ -61,7 +61,7 @@
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.76.1",
"@tauri-apps/api": "2.9.0",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-cli": "^2.4.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
@@ -74,6 +74,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "~2.3.3",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-websocket": "~2.4.1",
"@zip.js/zip.js": "^2.7.53",
"abortcontroller-polyfill": "^1.7.8",
"app-store-server-api": "^0.17.1",
@@ -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,10 @@
"postcss-cli": "^11.0.0",
"postcss-nested": "^7.0.2",
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"tailwindcss": "^3.4.18",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"vitest": "^4.0.15",
"wrangler": "^4.50.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,71 @@
"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.": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة."
}
@@ -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,51 @@
"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 বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।"
}
@@ -695,9 +695,7 @@
"Validating...": "བདེན་སྦྱོར་བཞིན་...",
"View All": "ཡོངས་ལྟ་བ་",
"Forward": "མདུན་དུ་",
"OPDS Catalog": "OPDS དཀར་ཆག",
"Home": "གཙོ་ངོས་",
"Library": "དེབ་མཛོད་",
"{{count}} items_other": "{{count}} རྣམ་གྲངས་",
"Download completed": "ཕབ་ལེན་རྫོགས་སོང་།",
"Download failed": "ཕབ་ལེན་ཕམ་པ་",
@@ -716,5 +714,46 @@
"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 རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།"
}
@@ -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,51 @@
"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."
}
@@ -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,51 @@
"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."
}
@@ -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,56 @@
"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."
}
@@ -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,51 @@
"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 وارد شوید."
}
@@ -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,56 @@
"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."
}
@@ -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,51 @@
"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 सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।"
}
@@ -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,46 @@
"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."
}
@@ -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,56 @@
"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."
}
@@ -695,9 +695,7 @@
"Validating...": "検証中...",
"View All": "すべて表示",
"Forward": "進む",
"OPDS Catalog": "OPDSカタログ",
"Home": "ホーム",
"Library": "ライブラリ",
"{{count}} items_other": "{{count}} 件",
"Download completed": "ダウンロード完了",
"Download failed": "ダウンロード失敗",
@@ -716,5 +714,46 @@
"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機能を使用するにはログインしてください。"
}
@@ -695,9 +695,7 @@
"Validating...": "검증 중...",
"View All": "모두 보기",
"Forward": "다음",
"OPDS Catalog": "OPDS 카탈로그",
"Home": "홈",
"Library": "라이브러리",
"{{count}} items_other": "{{count}}개 항목",
"Download completed": "다운로드 완료",
"Download failed": "다운로드 실패",
@@ -716,5 +714,46 @@
"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 기능을 사용하려면 로그인하세요."
}
@@ -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,46 @@
"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."
}
@@ -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,51 @@
"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."
}
@@ -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,61 @@
"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."
}
@@ -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,56 @@
"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."
}
@@ -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,61 @@
"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."
}
@@ -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,51 @@
"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 විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න."
}
@@ -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,51 @@
"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."
}
@@ -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,51 @@
"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 அம்சங்களை பயன்படுத்த உள்நுழையவும்."
}
@@ -695,9 +695,7 @@
"Validating...": "กำลังตรวจสอบ...",
"View All": "ดูทั้งหมด",
"Forward": "ไปข้างหน้า",
"OPDS Catalog": "แคตตาล็อก OPDS",
"Home": "หน้าแรก",
"Library": "ห้องสมุด",
"{{count}} items_other": "{{count}} รายการ",
"Download completed": "ดาวน์โหลดเสร็จสิ้น",
"Download failed": "ดาวน์โหลดล้มเหลว",
@@ -716,5 +714,46 @@
"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 ขั้นสูง"
}
@@ -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,51 @@
"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."
}
@@ -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,61 @@
"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."
}
@@ -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,46 @@
"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."
}
@@ -695,9 +695,7 @@
"Validating...": "验证中...",
"View All": "查看全部",
"Forward": "前进",
"OPDS Catalog": "OPDS 目录",
"Home": "首页",
"Library": "图书馆",
"{{count}} items_other": "{{count}} 项",
"Download completed": "下载完成",
"Download failed": "下载失败",
@@ -716,5 +714,46 @@
"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 功能"
}
@@ -695,9 +695,7 @@
"Validating...": "驗證中...",
"View All": "檢視全部",
"Forward": "前往",
"OPDS Catalog": "OPDS 目錄",
"Home": "首頁",
"Library": "圖書館",
"{{count}} items_other": "{{count}} 項",
"Download completed": "下載完成",
"Download failed": "下載失敗",
@@ -716,5 +714,46 @@
"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 功能"
}
+38
View File
@@ -1,5 +1,43 @@
{
"releases": {
"0.9.96": {
"date": "2025-12-19",
"notes": [
"TTS: Resolved an issue where Edge TTS was blocked in certain regions",
"OPDS: Improved compatibility with older versions of Calibre Web",
"OPDS: Added an instant search bar for faster browsing in OPDS catalogs",
"OPDS: Added a curated book catalog from Standard Ebooks",
"Bookshelf: Added a “Group Books” action to the context menu",
"Comics: Fixed layout issues to improve comic book reading",
"Layout: Footnote popups now adapt correctly to different screen sizes",
"Layout: Fixed an issue where the maximum inline width was not applied to EPUBs",
"Sync: Improved file downloading by correctly handling special characters in filenames",
"UI: Added an option to temporarily dismiss the reading progress bar",
"Settings: Screen brightness adjustments now apply only to the reader view",
"iOS: You can now open files directly in Readest from the Files app",
"macOS: Added a global menu option to open files with Readest"
]
},
"0.9.95": {
"date": "2025-12-08",
"notes": [
"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,23 @@ package com.bilingify.readest
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import android.view.KeyEvent
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 +47,32 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
interceptBackKeyEnabled = enabled
}
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 +116,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 +127,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 +169,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)
}
}
}
@@ -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()
+13 -7
View File
@@ -13,18 +13,19 @@ 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 +50,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 +136,7 @@ fn get_executable_dir() -> String {
#[derive(Clone, serde::Serialize)]
#[allow(dead_code)]
struct Payload {
struct SingleInstancePayload {
args: Vec<String>,
cwd: String,
}
@@ -144,6 +144,7 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_oauth::init())
.invoke_handler(tauri::generate_handler![
@@ -179,7 +180,7 @@ pub fn run() {
if !files.is_empty() {
allow_file_in_scopes(app, files.clone());
}
app.emit("single-instance", Payload { args: argv, cwd })
app.emit("single-instance", SingleInstancePayload { args: argv, cwd })
.unwrap();
}));
@@ -223,6 +224,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())?;
+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);
}
});
}
+2 -1
View File
@@ -16,7 +16,7 @@
"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://*",
"img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
@@ -72,6 +72,7 @@
},
"iOS": {
"developmentTeam": "J5W48D69VR",
"infoPlist": "./Info-ios.plist",
"minimumSystemVersion": "14.0"
},
"fileAssociations": [
@@ -0,0 +1,390 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import ReplacementOptions from '@/app/reader/components/annotator/ReplacementOptions';
describe('ReplacementOptions Component', () => {
// IMPORTANT: ReplacementOptions should ONLY be rendered for EPUB books.
// for non-EPUB formats (PDF, TXT, etc), the button is disabled
// and ReplacementOptions is never rendered/shown to the user.
//
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
isVertical: false,
style: { left: '100px', top: '100px' },
selectedText: 'test word',
onConfirm: mockOnConfirm,
onClose: mockOnClose,
};
// Note: ReplacementOptions component should only be rendered for EPUB books.
// All tests here implicitly test EPUB book scenarios.
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('Rendering', () => {
it('should render all three replacement scope options', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Fix this once')).toBeTruthy();
expect(screen.getByText('Fix in this book')).toBeTruthy();
expect(screen.getByText('Fix in library')).toBeTruthy();
});
it('should render the replacement text input field', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
expect(input).toBeTruthy();
});
it('should render the Case Sensitive checkbox', () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Case Sensitive')).toBeTruthy();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
});
it('should render the Cancel button', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Cancel')).toBeTruthy();
});
it('should display selected text preview', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText(/Selected:/)).toBeTruthy();
expect(screen.getByText(/"test word"/)).toBeTruthy();
});
it('should truncate long selected text in preview', () => {
const longText = 'a'.repeat(100);
render(<ReplacementOptions {...defaultProps} selectedText={longText} />);
// Should show truncated version with ellipsis
const preview = screen.getByText(/Selected:/);
expect(preview.parentElement?.textContent).toContain('...');
});
});
describe('Case Sensitive Checkbox', () => {
it('should be checked by default (case-sensitive)', () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('should toggle when clicked', async () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
it('should pass case sensitivity value to onConfirm when checked', async () => {
render(<ReplacementOptions {...defaultProps} />);
// Enter replacement text
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
// Checkbox is checked by default (case sensitive = true)
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Should show confirmation dialog
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('Yes')).toBeTruthy(); // Case sensitive: Yes
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith({
replacementText: 'replacement',
caseSensitive: true,
scope: 'once',
});
});
it('should pass case sensitivity value to onConfirm when unchecked', async () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
// Enter replacement text
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
// Uncheck the checkbox (default is true, so we click to toggle to false)
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith({
replacementText: 'replacement',
caseSensitive: false,
scope: 'once',
});
});
});
describe('Replacement Text Input', () => {
it('should update value when user types', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'new text' } });
expect(input.value).toBe('new text');
});
it('should disable scope buttons when input is empty', () => {
render(<ReplacementOptions {...defaultProps} />);
const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement;
const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement;
const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement;
expect(fixOnceButton.disabled).toBe(true);
expect(fixInBookButton.disabled).toBe(true);
expect(fixInLibraryButton.disabled).toBe(true);
});
it('should enable scope buttons when input has text', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement;
const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement;
const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement;
expect(fixOnceButton.disabled).toBe(false);
expect(fixInBookButton.disabled).toBe(false);
expect(fixInLibraryButton.disabled).toBe(false);
});
it('should trim whitespace from replacement text', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: ' trimmed ' } });
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith(
expect.objectContaining({
replacementText: 'trimmed',
}),
);
});
});
describe('Scope Button Click Handlers', () => {
it('should show confirmation dialog when "Fix this once" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix this once');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('this instance')).toBeTruthy();
});
it('should show confirmation dialog when "Fix in this book" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix in this book');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('all instances in this book')).toBeTruthy();
});
it('should show confirmation dialog when "Fix in library" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix in library');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('all instances in your library')).toBeTruthy();
});
it('should call onConfirm with correct scope for "once"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'once' }));
});
it('should call onConfirm with correct scope for "book"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix in this book'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' }));
});
it('should call onConfirm with correct scope for "library"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix in library'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' }));
});
});
describe('Confirmation Dialog', () => {
it('should display original text in confirmation', () => {
render(<ReplacementOptions {...defaultProps} selectedText='original' />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('"original"')).toBeTruthy();
});
it('should display replacement text in confirmation', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'new text' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('"new text"')).toBeTruthy();
});
it('should go back to main view when Back is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
fireEvent.click(screen.getByText('Back'));
// Should be back to main view
expect(screen.queryByText('Confirm Replacement')).toBeNull();
expect(screen.getByText('Fix this once')).toBeTruthy();
});
it('should not call onConfirm when Back is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
fireEvent.click(screen.getByText('Back'));
expect(mockOnConfirm).not.toHaveBeenCalled();
});
});
describe('Cancel Button', () => {
it('should call onClose when Cancel is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('Click Outside Behavior', () => {
it('should call onClose when clicking outside the menu', () => {
render(
<div>
<div data-testid='outside'>Outside element</div>
<ReplacementOptions {...defaultProps} />
</div>,
);
const outsideElement = screen.getByTestId('outside');
fireEvent.mouseDown(outsideElement);
expect(mockOnClose).toHaveBeenCalled();
});
it('should not call onClose when clicking inside the menu', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.mouseDown(input);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import {
ReplacementRulesWindow,
setReplacementRulesWindowVisible,
} from '@/app/reader/components/ReplacementRulesWindow';
import BookMenu from '@/app/reader/components/sidebar/BookMenu';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ReplacementRule } from '@/types/book';
// ------------------------------
// NEXT.JS ROUTER MOCK
// ------------------------------
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
toString: () => '',
}),
}));
// ------------------------------
// TRANSLATION MOCK
// ------------------------------
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
vi.mock('@/services/translators/cache', () => ({
initCache: vi.fn(),
loadCacheFromDB: vi.fn(),
pruneCache: vi.fn(),
}));
// ------------------------------
// ENV PROVIDER WRAPPER
// ------------------------------
// mock environment module so EnvProvider uses fake values
vi.mock('@/services/environment', async (importOriginal) => {
const actual = await importOriginal();
return {
...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform)
default: {
...(typeof actual === 'object' &&
actual !== null &&
'default' in actual &&
typeof actual.default === 'object' &&
actual.default !== null
? actual.default
: {}), // keep all real default fields
API_BASE: 'http://localhost',
ENABLE_TRANSLATOR: false,
getAppService: vi.fn().mockResolvedValue(null),
},
};
});
import { EnvProvider } from '@/context/EnvContext';
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe.skip('ReplacementRulesWindow', () => {
beforeEach(() => {
// Reset stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: {
enabled: false,
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
useSidebarStore.setState({ sideBarBookKey: null });
(useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} });
});
afterEach(() => {
cleanup();
});
it('renders book and global replacement rules from stores', async () => {
// Arrange: populate stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
replacementRules: [
{
id: 'g1',
pattern: 'foo',
replacement: 'bar',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
},
{
id: 'b1',
pattern: 'hello',
replacement: 'world',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
},
],
kosync: { enabled: false },
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
replacementRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ReplacementRulesWindow />);
// wait a tick so the component's effect attaches the event listener
await Promise.resolve();
// open via helper which dispatches the custom event
setReplacementRulesWindowVisible(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Global rules
expect(screen.getByText('foo')).toBeTruthy();
expect(screen.getByText('bar')).toBeTruthy();
expect(screen.getByText('hello')).toBeTruthy();
expect(screen.getByText('world')).toBeTruthy();
});
it('renders single-instance rules separately from book/global rules', async () => {
// Arrange: populate stores with a single rule persisted in book config
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: { enabled: false },
},
});
const singleRule: ReplacementRule = {
id: 's1',
pattern: 'only-once',
replacement: 'single-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
singleInstance: true,
};
const bookRule: ReplacementRule = {
id: 'b1',
pattern: 'book-wide',
replacement: 'book-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
replacementRules: [singleRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
replacementRules: [singleRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ReplacementRulesWindow />);
await Promise.resolve();
setReplacementRulesWindowVisible(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Single-instance section
expect(screen.getByText('Single Instance Rules')).toBeTruthy();
expect(screen.getByText('only-once')).toBeTruthy();
expect(screen.getByText('single-hit')).toBeTruthy();
// Book section should still show book-wide rule
expect(screen.getByText('book-wide')).toBeTruthy();
expect(screen.getByText('book-hit')).toBeTruthy();
});
it('opens when BookMenu item is clicked (integration)', async () => {
// Arrange stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: { viewSettings: { replacementRules: [] } },
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Render both menu and window
renderWithProviders(
<div>
<BookMenu />
<ReplacementRulesWindow />
</div>,
);
// wait a tick so effects attach
await Promise.resolve();
// Click the menu item
const menuItem = screen.getByRole('menuitem', { name: 'Replacement Rules' });
fireEvent.click(menuItem);
// The dialog should open
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Replacement Rules')).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest';
import { getWordCount, isWordLimitExceeded } from '../../utils/wordLimit';
describe('Word Limit Feature', () => {
describe('getWordCount', () => {
it('should count single word correctly', () => {
expect(getWordCount('hello')).toBe(1);
});
it('should count multiple words correctly', () => {
expect(getWordCount('hello world')).toBe(2);
expect(getWordCount('the quick brown fox')).toBe(4);
});
it('should handle multiple spaces between words', () => {
expect(getWordCount('hello world')).toBe(2);
});
it('should handle leading and trailing spaces', () => {
expect(getWordCount(' hello world ')).toBe(2);
});
it('should handle newlines and tabs', () => {
expect(getWordCount('hello\nworld')).toBe(2);
expect(getWordCount('hello\tworld')).toBe(2);
expect(getWordCount('hello\n\t world')).toBe(2);
});
it('should return 0 for empty string', () => {
expect(getWordCount('')).toBe(0);
});
it('should return 0 for whitespace only', () => {
expect(getWordCount(' ')).toBe(0);
expect(getWordCount('\n\t ')).toBe(0);
});
it('should handle punctuation as part of words', () => {
expect(getWordCount("don't")).toBe(1);
expect(getWordCount('hello, world!')).toBe(2);
});
it('should count exactly 30 words', () => {
const thirtyWords = Array(30).fill('word').join(' ');
expect(getWordCount(thirtyWords)).toBe(30);
});
it('should count more than 30 words', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(getWordCount(thirtyOneWords)).toBe(31);
});
});
describe('isWordLimitExceeded', () => {
it('should return false for text under 30 words', () => {
expect(isWordLimitExceeded('hello world')).toBe(false);
expect(isWordLimitExceeded('a')).toBe(false);
});
it('should return false for exactly 30 words', () => {
const thirtyWords = Array(30).fill('word').join(' ');
expect(isWordLimitExceeded(thirtyWords)).toBe(false);
});
it('should return true for 31 words', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(isWordLimitExceeded(thirtyOneWords)).toBe(true);
});
it('should return true for many words', () => {
const manyWords = Array(100).fill('word').join(' ');
expect(isWordLimitExceeded(manyWords)).toBe(true);
});
it('should return false for empty string', () => {
expect(isWordLimitExceeded('')).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle very long words', () => {
const longWord = 'a'.repeat(1000);
expect(getWordCount(longWord)).toBe(1);
expect(isWordLimitExceeded(longWord)).toBe(false);
});
it('should handle mixed content with newlines', () => {
const text = `Line one with words.
Line two with more words.
Line three.`;
// "Line one with words." = 4, "Line two with more words." = 5, "Line three." = 2 = 11 total
expect(getWordCount(text)).toBe(11);
expect(isWordLimitExceeded(text)).toBe(false);
});
it('should handle unicode characters', () => {
expect(getWordCount('你好 世界')).toBe(2);
expect(getWordCount('café résumé')).toBe(2);
expect(getWordCount('🎉 hello 🎊 world')).toBe(4);
});
it('should handle numbers as words', () => {
expect(getWordCount('1 2 3 4 5')).toBe(5);
expect(getWordCount('chapter 1 section 2')).toBe(4);
});
});
});
describe('Case Sensitivity Matching', () => {
// Helper function to simulate matching logic
const matchText = (text: string, pattern: string, caseSensitive: boolean): boolean => {
if (caseSensitive) {
return text === pattern;
}
return text.toLowerCase() === pattern.toLowerCase();
};
describe('Case-Sensitive Mode', () => {
it('should match exact case only', () => {
expect(matchText('Where', 'Where', true)).toBe(true);
});
it('should not match different case', () => {
expect(matchText('where', 'Where', true)).toBe(false);
expect(matchText('WHERE', 'Where', true)).toBe(false);
expect(matchText('wHeRe', 'Where', true)).toBe(false);
});
it('should handle all uppercase pattern', () => {
expect(matchText('HELLO', 'HELLO', true)).toBe(true);
expect(matchText('hello', 'HELLO', true)).toBe(false);
});
it('should handle all lowercase pattern', () => {
expect(matchText('hello', 'hello', true)).toBe(true);
expect(matchText('Hello', 'hello', true)).toBe(false);
});
});
describe('Case-Insensitive Mode', () => {
it('should match same case', () => {
expect(matchText('where', 'where', false)).toBe(true);
});
it('should match different cases', () => {
expect(matchText('where', 'Where', false)).toBe(true);
expect(matchText('Where', 'where', false)).toBe(true);
expect(matchText('WHERE', 'where', false)).toBe(true);
expect(matchText('wHeRe', 'where', false)).toBe(true);
});
it('should match title case to lowercase', () => {
expect(matchText('The', 'the', false)).toBe(true);
});
it('should match with mixed input', () => {
expect(matchText('HeLLo', 'HELLO', false)).toBe(true);
expect(matchText('HeLLo', 'hello', false)).toBe(true);
});
});
describe('Real-world examples', () => {
it('should handle "the" in different cases', () => {
const pattern = 'the';
// Case-insensitive (default behavior)
expect(matchText('The', pattern, false)).toBe(true);
expect(matchText('the', pattern, false)).toBe(true);
expect(matchText('THE', pattern, false)).toBe(true);
// Case-sensitive
expect(matchText('The', pattern, true)).toBe(false);
expect(matchText('the', pattern, true)).toBe(true);
expect(matchText('THE', pattern, true)).toBe(false);
});
it('should handle proper nouns correctly when case-sensitive', () => {
const pattern = 'John';
// Case-sensitive - only exact match
expect(matchText('John', pattern, true)).toBe(true);
expect(matchText('john', pattern, true)).toBe(false);
expect(matchText('JOHN', pattern, true)).toBe(false);
});
});
});
@@ -1,3 +1,4 @@
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { NextRequest, NextResponse } from 'next/server';
async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
@@ -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';
@@ -116,6 +125,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Expose-Headers': 'X-Content-Length',
},
});
} else {
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { validateUserAndToken } from '@/utils/access';
const getLangFromVoice = (voiceId: string): string => {
const match = voiceId.match(/^([a-z]{2}-[A-Z]{2})/);
return match ? match[1]! : 'en-US';
};
const isValidVoice = (voiceId: string): boolean => {
return EdgeSpeechTTS.voices.some((v) => v.id === voiceId);
};
export async function POST(request: NextRequest) {
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
}
try {
const body = await request.json();
const { input: text, voice, speed = 1.0 } = body;
let { rate, lang } = body;
if (!text || typeof text !== 'string') {
return NextResponse.json(
{ error: { message: 'Missing or invalid "input" field', type: 'invalid_request_error' } },
{ status: 400 },
);
}
if (!voice || typeof voice !== 'string') {
return NextResponse.json(
{ error: { message: 'Missing or invalid "voice" field', type: 'invalid_request_error' } },
{ status: 400 },
);
}
if (!isValidVoice(voice)) {
return NextResponse.json(
{
error: {
message: `Invalid voice "${voice}". Use GET /api/tts/edge to list available voices.`,
type: 'invalid_request_error',
},
},
{ status: 400 },
);
}
lang = lang || getLangFromVoice(voice);
// Calculate rate (OpenAI speed ranges from 0.25 to 4.0, Edge TTS rate is 0.5 to 2.0)
const clampedSpeed = Math.max(0.25, Math.min(4.0, speed));
let mappedSpeed: number;
if (clampedSpeed <= 1.0) {
mappedSpeed = 0.5 + ((clampedSpeed - 0.25) / (1.0 - 0.25)) * (1.0 - 0.5);
} else {
mappedSpeed = 1.0 + ((clampedSpeed - 1.0) / (4.0 - 1.0)) * (2.0 - 1.0);
}
rate = rate || mappedSpeed;
const payload: EdgeTTSPayload = {
lang,
text,
voice,
rate,
pitch: 1.0,
};
const tts = new EdgeSpeechTTS();
const response = await tts.create(payload);
const arrayBuffer = await response.arrayBuffer();
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'audio/mpeg',
'Content-Length': arrayBuffer.byteLength.toString(),
},
});
} catch (error) {
console.error('Edge TTS API error:', error);
return NextResponse.json(
{
error: {
message: error instanceof Error ? error.message : 'Internal server error',
type: 'internal_error',
},
},
{ status: 500 },
);
}
}
export async function GET(request: NextRequest) {
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
}
try {
const query = request.nextUrl.searchParams;
const lang = query.get('lang') || '';
let voices = EdgeSpeechTTS.voices;
if (lang) {
voices = voices.filter((v) => v.lang.toLowerCase().includes(lang.toLowerCase()));
}
const formattedVoices = voices.map((voice) => ({
id: voice.id,
name: voice.name,
language: voice.lang,
}));
return NextResponse.json({
voices: formattedVoices,
});
} catch (error) {
console.error('Error listing voices:', error);
return NextResponse.json(
{
error: {
message: 'Failed to list voices',
type: 'internal_error',
},
},
{ status: 500 },
);
}
}
@@ -304,6 +304,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleGroupBooks={groupSelectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
@@ -81,6 +81,7 @@ interface BookshelfItemProps {
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleGroupBooks: () => void;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
@@ -97,6 +98,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
transferProgress,
setLoading,
toggleSelection,
handleGroupBooks,
handleBookUpload,
handleBookDownload,
handleSetSelectMode,
@@ -188,6 +190,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
toggleSelection(book.hash);
},
});
const groupBooksMenuItem = await MenuItem.new({
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
toggleSelection(book.hash);
}
handleGroupBooks();
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
@@ -221,6 +233,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
const menu = await Menu.new();
menu.append(selectBookMenuItem);
menu.append(groupBooksMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
if (book.uploadedAt && !book.downloadedAt) {
@@ -242,6 +255,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
toggleSelection(group.id);
},
});
const groupBooksMenuItem = await MenuItem.new({
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
toggleSelection(group.id);
}
handleGroupBooks();
},
});
const deleteGroupMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
@@ -250,6 +273,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
const menu = await Menu.new();
menu.append(selectGroupMenuItem);
menu.append(groupBooksMenuItem);
menu.append(deleteGroupMenuItem);
menu.popup();
};
@@ -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,17 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
const nextName = generateNextUntitledGroupName();
setEditGroupName(nextName);
setShowInput(true);
setIsRenaming(false);
setOriginalGroupName(null);
};
const handleRenameGroup = () => {
if (!currentGroupForRename) return;
setEditGroupName(currentGroupForRename.name);
setOriginalGroupName(currentGroupForRename.name);
setShowInput(true);
setIsRenaming(true);
};
const handleRemoveFromGroup = () => {
@@ -112,17 +137,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 +257,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 +293,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' />}
@@ -26,7 +26,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 +37,8 @@ interface LibraryHeaderProps {
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
isSelectMode,
isSelectAll,
onImportBooks,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onOpenCatalogManager,
onToggleSelectMode,
onSelectAll,
@@ -152,14 +154,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';
@@ -20,6 +20,7 @@ import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/
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';
@@ -175,13 +176,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) {
@@ -334,7 +329,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')}
+87 -12
View File
@@ -15,7 +15,7 @@ import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/
import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { getFilename } from '@/utils/path';
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 +34,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,
@@ -87,9 +90,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);
@@ -141,7 +147,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setSettingsDialogOpen(true);
},
onOpenBooks: () => {
handleImportBooks();
handleImportBooksFromFiles();
},
});
@@ -266,6 +272,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return false;
};
const handleShowOPDSDialog = () => {
setShowCatalogManager(true);
};
const handleDismissOPDSDialog = () => {
setShowCatalogManager(false);
const params = new URLSearchParams(searchParams?.toString());
params.delete('opds');
navigateToLibrary(router, `${params.toString()}`);
};
useEffect(() => {
if (pendingNavigationBookIds) {
const bookIds = pendingNavigationBookIds;
@@ -319,7 +336,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const handleOpenWithBooks = async (appService: AppService, library: Book[]) => {
const openWithFiles = (await parseOpenWithFiles()) || [];
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
if (openWithFiles.length > 0) {
return await processOpenWithFiles(appService, openWithFiles, library);
@@ -364,6 +381,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,15 +396,25 @@ 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);
}
if (book) {
successfulImports.push(book.title);
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getFilename(filename);
@@ -415,8 +443,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]);
@@ -531,6 +568,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 +617,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 +627,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 +725,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 +814,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 +836,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 +860,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
@@ -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>
);
}
@@ -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)}
@@ -172,7 +171,7 @@ export function PublicationView({
tabIndex={0}
className={clsx(
`btn btn-primary min-w-20 rounded-3xl ${downloading ? 'btn-disabled' : ''}`,
downloaded && 'btn-success',
downloadedBook && 'btn-success',
)}
>
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
@@ -190,7 +189,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>
+164 -41
View File
@@ -1,30 +1,31 @@
'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 { 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 { 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 { 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';
@@ -50,6 +51,7 @@ export default function BrowserPage() {
const { appService } = useEnv();
const { libraryLoaded } = useLibrary();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const { settings } = useSettingsStore();
const [viewMode, setViewMode] = useState<ViewMode>('loading');
const [state, setState] = useState<OPDSState>({
baseURL: '',
@@ -65,16 +67,18 @@ 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 });
// Keep refs in sync with state
useEffect(() => {
startURLRef.current = state.startURL;
}, [state.startURL]);
@@ -102,8 +106,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 +157,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 +231,12 @@ export default function BrowserPage() {
startURL: currentStartURL || responseURL,
};
setState(newState);
setViewMode('search');
setSelectedPublication(null);
if (searchTermRef.current) {
quickSearch(search, responseURL, searchTermRef.current);
} else {
setViewMode('search');
setSelectedPublication(null);
}
if (!skipHistory) {
addToHistory(url, newState, 'search', null);
}
@@ -231,14 +289,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 +304,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,
@@ -284,7 +404,7 @@ export default function BrowserPage() {
return;
} else {
const ext = parsed?.mediaType ? getFileExtFromMimeType(parsed.mediaType) : '';
const basename = getBaseFilename(url);
const basename = new URL(url).pathname.replaceAll('/', '_');
const filename = ext ? `${basename}.${ext}` : basename;
const dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
if (dstFilePath) {
@@ -292,7 +412,9 @@ 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,
};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
if (authHeader) {
@@ -446,13 +568,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'>
+30 -2
View File
@@ -1,7 +1,15 @@
import { md5 } from 'js-md5';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import {
getAPIBaseUrl,
getNodeAPIBaseUrl,
isTauriAppPlatform,
isWebAppPlatform,
} from '@/services/environment';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
const OPDS_PROXY_URL = `${getAPIBaseUrl()}/opds/proxy`;
const NODE_OPDS_PROXY_URL = `${getNodeAPIBaseUrl()}/opds/proxy`;
/**
* Extract username and password from URL credentials
*/
@@ -33,6 +41,19 @@ export const needsProxy = (url: string): boolean => {
return isWebAppPlatform() && url.startsWith('http');
};
const PROXY_OVERRIDES: Record<string, string> = {
standardebooks: NODE_OPDS_PROXY_URL,
};
const getProxyBaseUrl = (url: string): string => {
for (const [domain, proxyUrl] of Object.entries(PROXY_OVERRIDES)) {
if (url.includes(domain)) {
return proxyUrl;
}
}
return OPDS_PROXY_URL;
};
/**
* Generate proxied URL for OPDS requests
*/
@@ -45,7 +66,8 @@ export const getProxiedURL = (url: string, auth: string = '', stream = false): s
if (auth) {
params.append('auth', auth);
}
const proxyUrl = `/api/opds/proxy?${params.toString()}`;
const baseUrl = getProxyBaseUrl(url);
const proxyUrl = `${baseUrl}?${params.toString()}`;
return proxyUrl;
}
return url;
@@ -184,6 +206,7 @@ export const probeAuth = async (
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml',
};
@@ -210,6 +233,10 @@ export const probeAuth = async (
} else if (wwwAuthenticate.toLowerCase().startsWith('basic')) {
return createBasicAuth(finalUsername, finalPassword);
}
} else {
// Fallback to Basic auth if no WWW-Authenticate header
// some older Calibre-Web versions behave this way, see issue #2656
return createBasicAuth(finalUsername, finalPassword);
}
}
@@ -237,6 +264,7 @@ export const fetchWithAuth = async (
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml',
...(options.headers as Record<string, string>),
};
@@ -1,4 +1,5 @@
import { isOPDSCatalog } from 'foliate-js/opds.js';
import { OPDSLink } from '@/types/opds';
import { fetchWithAuth } from './opdsReq';
export const groupByArray = <T, K>(arr: T[] | undefined, f: (el: T) => K | K[]): Map<K, T[]> => {
@@ -66,6 +67,11 @@ export const parseMediaType = (str?: string) => {
};
};
export const isSearchLink = (link: OPDSLink): boolean => {
const rels = Array.isArray(link.rel) ? link.rel : [link.rel || ''];
return rels.includes('search') && (link.type === MIME.OPENSEARCH || link.type === MIME.ATOM);
};
export const resolveURL = (url: string, relativeTo: string): string => {
if (!url) return '';
if (relativeTo.includes('/api/opds/proxy?url=')) {
@@ -131,7 +131,7 @@ const FoliateViewer: React.FC<{
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(width, height, data);
return transformStylesheet(data, width, height, viewSettings.vertical);
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
const ctx: TransformContext = {
bookKey,
@@ -149,7 +149,9 @@ const FoliateViewer: React.FC<{
'language',
'sanitizer',
'simplecc',
'replacement',
],
sectionHref: detail.name, // Pass section href for single-instance replacements
};
return Promise.resolve(transformContent(ctx));
}
@@ -48,6 +48,14 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
return Math.min(size, maxSize - popupPadding - 12);
};
const clipPopupWith = (size: number) => {
return Math.min(size, window.innerWidth - popupPadding - 12);
};
const clipPopupHeight = (size: number) => {
return Math.min(size, window.innerHeight - popupPadding - 12);
};
useEffect(() => {
const handleBeforeRender = (e: Event) => {
const detail = (e as CustomEvent).detail;
@@ -99,9 +107,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const { renderer } = view as FoliateView;
const viewSettings = getViewSettings(bookKey)!;
if (viewSettings.vertical) {
setResponsiveWidth(getResponsivePopupSize(renderer.viewSize, true));
setResponsiveWidth(clipPopupWith(getResponsivePopupSize(renderer.viewSize, true)));
} else {
setResponsiveHeight(getResponsivePopupSize(renderer.viewSize, false));
setResponsiveHeight(clipPopupHeight(getResponsivePopupSize(renderer.viewSize, false)));
}
setShowPopup(true);
});
@@ -124,11 +132,11 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
useEffect(() => {
if (viewSettings.vertical) {
setResponsiveWidth(popupHeight);
setResponsiveHeight(Math.max(popupWidth, window.innerHeight / 4));
setResponsiveWidth(clipPopupWith(popupHeight));
setResponsiveHeight(clipPopupHeight(Math.max(popupWidth, window.innerHeight / 4)));
} else {
setResponsiveWidth(Math.max(popupWidth, window.innerWidth / 4));
setResponsiveHeight(popupHeight);
setResponsiveWidth(clipPopupWith(Math.max(popupWidth, window.innerWidth / 4)));
setResponsiveHeight(clipPopupHeight(popupHeight));
}
}, [viewSettings]);
@@ -250,7 +258,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
onDismiss={handleDismissPopup}
>
<div
className=''
className='footnote-content'
ref={footnoteRef}
style={{
width: `${responsiveWidth}px`,
@@ -90,11 +90,11 @@ const HintInfo: React.FC<SectionInfoProps> = ({
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
width: showDoubleBorder ? '30px' : `${contentInsets.right}px`,
}
: {
top: `${topInset}px`,
insetInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
insetInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right / 2}px)`,
}
}
>
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Insets } from '@/types/misc';
import { PageInfo, TimeInfo } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
@@ -7,6 +7,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { formatNumber, formatProgress } from '@/utils/progress';
import { saveViewSettings } from '@/helpers/settings';
interface PageInfoProps {
bookKey: string;
@@ -28,7 +29,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { envConfig, appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
@@ -70,15 +71,59 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
})
: '';
const [progressInfoMode, setProgressInfoMode] = useState(viewSettings.progressInfoMode);
const cycleProgressInfoModes = () => {
const hasRemainingInfo = viewSettings.showRemainingTime || viewSettings.showRemainingPages;
const hasProgressInfo = viewSettings.showProgressInfo;
const modeSequence: (typeof progressInfoMode)[] = ['all', 'remaining', 'progress', 'none'];
const currentIndex = modeSequence.indexOf(progressInfoMode);
for (let i = 1; i <= modeSequence.length; i++) {
const nextIndex = (currentIndex + i) % modeSequence.length;
const nextMode = modeSequence[nextIndex]!;
const currentRenders = {
remaining:
progressInfoMode === 'all' || progressInfoMode === 'remaining' ? hasRemainingInfo : false,
progress:
progressInfoMode === 'all' || progressInfoMode === 'progress' ? hasProgressInfo : false,
};
const nextRenders = {
remaining: nextMode === 'all' || nextMode === 'remaining' ? hasRemainingInfo : false,
progress: nextMode === 'all' || nextMode === 'progress' ? hasProgressInfo : false,
};
const isDifferent =
currentRenders.remaining !== nextRenders.remaining ||
currentRenders.progress !== nextRenders.progress;
if (isDifferent) {
setProgressInfoMode(nextMode);
return;
}
}
const nextIndex = (currentIndex + 1) % modeSequence.length;
setProgressInfoMode(modeSequence[nextIndex]!);
};
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'progressInfoMode', progressInfoMode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progressInfoMode]);
return (
<div
role='presentation'
className={clsx(
'progressinfo absolute flex items-center justify-between font-sans',
'pointer-events-none bottom-0',
'pointer-events-auto bottom-0',
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
onClick={() => cycleProgressInfoModes()}
aria-label={[
progress
? _('On {{current}} of {{total}} page', {
@@ -98,12 +143,12 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
left: showDoubleBorder
? `calc(${contentInsets.left}px)`
: `calc(${Math.max(0, contentInsets.left - 32)}px)`,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
width: showDoubleBorder ? '32px' : `${contentInsets.left}px`,
height: `calc(100% - ${((contentInsets.top + contentInsets.bottom) / 2) * 3}px)`,
}
: {
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left / 2}px)`,
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right / 2}px)`,
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
}
}
@@ -115,15 +160,24 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
isVertical ? 'h-full' : 'h-[52px] w-full',
)}
>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && (
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
{progressInfo}
</span>
{(progressInfoMode === 'all' || progressInfoMode === 'remaining') && (
<>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
</>
)}
{(progressInfoMode === 'all' || progressInfoMode === 'progress') && (
<>
{viewSettings.showProgressInfo && (
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
{progressInfo}
</span>
)}
</>
)}
</div>
</div>
@@ -23,6 +23,7 @@ import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { KOSyncSettingsWindow } from './KOSyncSettings';
import { ReplacementRulesWindow } from './ReplacementRulesWindow';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
import { initDayjs } from '@/utils/time';
@@ -33,8 +34,8 @@ Z-Index Layering Guide:
---------------------------------
99 Window Border (Linux only)
Ensures the border stays on top of all UI elements.
50 Loading Progress / Toast Notifications / Dialogs
Includes Settings, About, Updater, and KOSync dialogs.
50 Loading Progress / Toast Notifications / Dialogs / Popups
Includes Settings, About, Updater, KOSync dialogs and Annotation popups.
45 Sidebar / Notebook (Unpinned)
Floats above the content but below global dialogs.
40 TTS Bar
@@ -52,9 +53,10 @@ Z-Index Layering Guide:
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const router = useRouter();
const { appService } = useEnv();
const { hoveredBookKey, getView } = useReaderStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { hoveredBookKey, getView } = useReaderStore();
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore();
@@ -74,6 +76,27 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
initDayjs(getLocale());
}, []);
useEffect(() => {
const brightness = settings.screenBrightness;
const autoBrightness = settings.autoScreenBrightness;
if (appService?.hasScreenBrightness && !autoBrightness && brightness >= 0) {
setScreenBrightness(brightness / 100);
}
let previousBrightness = -1;
if (appService?.isIOSApp) {
getScreenBrightness().then((b) => {
previousBrightness = b;
});
}
return () => {
if (appService?.hasScreenBrightness && !autoBrightness) {
setScreenBrightness(previousBrightness);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
const handleKeyDown = (event: CustomEvent) => {
const view = getView(sideBarBookKey!);
if (event.detail.keyName === 'Back') {
@@ -116,7 +139,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useEffect(() => {
if (!appService?.isMobileApp) return;
const systemUIVisible = !!hoveredBookKey || settings.alwaysShowStatusBar;
const visible = systemUIVisible && !systemUIAlwaysHidden;
const visible = !!(systemUIVisible && !systemUIAlwaysHidden);
setSystemUIVisibility({ visible, darkMode: isDarkMode });
if (visible) {
showSystemUI();
@@ -138,6 +161,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<AboutWindow />
<UpdaterWindow />
<KOSyncSettingsWindow />
<ReplacementRulesWindow />
<Toast />
</Suspense>
</div>
@@ -52,7 +52,8 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (isInitiating.current) return;
isInitiating.current = true;
const bookIds = ids || searchParams?.get('ids') || '';
const pathname = window.location.pathname;
const bookIds = ids || searchParams?.get('ids') || pathname.split('/reader/')[1] || '';
const initialIds = bookIds.split(BOOK_IDS_SEPARATOR).filter(Boolean);
const initialBookKeys = initialIds.map((id) => `${id}-${uniqueId()}`);
setBookKeys(initialBookKeys);
@@ -177,7 +178,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
dismissBook(bookKey);
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
const openWithFiles = (await parseOpenWithFiles()) || [];
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
if (appService?.hasWindow) {
if (openWithFiles.length > 0) {
tauriHandleOnCloseWindow(handleCloseBooks);
@@ -0,0 +1,440 @@
import React, { useEffect, useState } from 'react';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ReplacementRule } from '@/types/book';
import environmentConfig from '@/services/environment';
import { updateReplacementRule, removeReplacementRule } from '@/services/transformers/replacement';
import { eventDispatcher } from '@/utils/event';
import { RiEditLine, RiDeleteBin7Line } from 'react-icons/ri';
export const setReplacementRulesWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('replacement_rules_window');
if (dialog) {
const event = new CustomEvent('setReplacementRulesVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const ReplacementRulesWindow: React.FC = () => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { getConfig } = useBookDataStore();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(!!event.detail?.visible);
};
const el = document.getElementById('replacement_rules_window');
el?.addEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener);
};
}, []);
const viewSettings = sideBarBookKey ? getViewSettings(sideBarBookKey) : null;
const inMemoryRules = viewSettings?.replacementRules || [];
const persistedConfig = sideBarBookKey ? getConfig(sideBarBookKey) : null;
const persistedBookRules = persistedConfig?.viewSettings?.replacementRules || [];
// Prefer persisted rules; fall back to in-memory so we show unsaved edits in tests/dev
const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules;
const singleRules = bookRuleSource.filter((r: ReplacementRule) => !!r.singleInstance);
const bookScopedRules = bookRuleSource.filter((r: ReplacementRule) => !r.singleInstance);
// Book rules = book-scoped rules + global rules (merged for display)
// Merge logic:
// 1. Include all book-scoped rules (including disabled overrides of global rules)
// 2. Include global rules that aren't overridden at book level
// 3. Filter out orphaned overrides (disabled global rules that no longer exist globally)
const globalRules = settings?.globalViewSettings?.replacementRules || [];
// Create a map of global rule IDs to identify overridden rules
const globalRuleIds = new Set(globalRules.map((gr: ReplacementRule) => gr.id));
// Filter out book rules that are disabled overrides of non-existent global rules
const validBookRules = bookScopedRules.filter((br: ReplacementRule) => {
// If it's enabled, it's a real book rule
if (br.enabled !== false) return true;
// If it's disabled and the global rule still exists, keep it (it's an override)
// If the global rule doesn't exist, filter it out (orphaned override)
return globalRuleIds.has(br.id);
});
const mergedRules = validBookRules.concat(
globalRules.filter(
(gr: ReplacementRule) => !validBookRules.find((br: ReplacementRule) => br.id === gr.id),
),
);
// Create a map to track the scope of each rule for editing/deleting
const getRuleScope = (rule: ReplacementRule): 'single' | 'book' | 'global' => {
if (rule.singleInstance) return 'single';
// If the rule is in validBookRules and originates from global, it's an override
return globalRuleIds.has(rule.id) ? 'global' : 'book';
};
const bookRules = mergedRules;
const [editing, setEditing] = useState<{
id: string | null;
scope: 'single' | 'book' | 'global' | null;
pattern: string;
replacement: string;
enabled: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
// Track when a delete/edit operation is in progress to prevent rapid successive operations
const [isReloading, setIsReloading] = useState(false);
const startEdit = (r: ReplacementRule, scope: 'single' | 'book' | 'global') => {
setEditing({
id: r.id,
scope,
pattern: r.pattern,
replacement: r.replacement,
enabled: !!r.enabled,
});
};
const cancelEdit = () =>
setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
const saveEdit = async () => {
if (!editing.id || !editing.scope) return;
// Prevent rapid successive operations
if (isReloading) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Please wait for the current operation to complete.'),
timeout: 3000,
});
return;
}
setIsReloading(true);
try {
const bookKey = sideBarBookKey || '';
if (editing.scope === 'global') {
await updateReplacementRule(
environmentConfig,
bookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'global',
);
} else if (editing.scope === 'book' && sideBarBookKey) {
await updateReplacementRule(
environmentConfig,
sideBarBookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'book',
);
} else if (editing.scope === 'single' && sideBarBookKey) {
await updateReplacementRule(
environmentConfig,
sideBarBookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'single',
);
}
cancelEdit();
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Replacement rule updated. Reloading book to apply changes...'),
timeout: 3000,
});
if (sideBarBookKey) {
const { clearViewState, initViewState } = useReaderStore.getState();
const id = sideBarBookKey.split('-')[0]!;
// Hard reload: clear and reinit viewer to load from original source
clearViewState(sideBarBookKey);
await initViewState(environmentConfig, id, sideBarBookKey, true, true);
}
} catch (err) {
console.error('Failed to save replacement rule', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to update replacement rule'),
timeout: 3000,
});
} finally {
setIsReloading(false);
}
};
const deleteRule = async (ruleId: string, scope: 'single' | 'book' | 'global') => {
console.log('Deleting rule', ruleId, 'scope', scope);
// Prevent rapid successive deletions
if (isReloading) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Please wait for the book to finish reloading.'),
timeout: 3000,
});
return;
}
setIsReloading(true);
try {
const bookKey = sideBarBookKey || '';
if (scope === 'global') {
// delete global rule for all books
await removeReplacementRule(environmentConfig, '', ruleId, 'global');
} else {
await removeReplacementRule(environmentConfig, bookKey, ruleId, scope);
}
const successMessage =
scope === 'global'
? _(
'Global replacement rule deleted for all books in the library. Reloading book to apply changes...',
)
: _('Replacement rule deleted. Reloading book to apply changes...');
eventDispatcher.dispatch('toast', {
type: 'success',
message: successMessage,
timeout: 3000,
});
if (sideBarBookKey) {
const { clearViewState, initViewState } = useReaderStore.getState();
const id = sideBarBookKey.split('-')[0]!;
// Hard reload: clear and reinit viewer to load from original source
clearViewState(sideBarBookKey);
await initViewState(environmentConfig, id, sideBarBookKey, true, true);
}
} catch (err) {
console.error('Failed to delete replacement rule', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to delete replacement rule'),
timeout: 3000,
});
} finally {
setIsReloading(false);
}
};
return (
<Dialog
id='replacement_rules_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Replacement Rules')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
<div>
<h3 className='text-sm font-semibold'>{_('Single Instance Rules')}</h3>
{singleRules.length === 0 ? (
<p className='text-base-content/70 mt-2 text-sm'>
{_('No single replacement rules')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{singleRules.map((r) => (
<li key={r.id} className='rounded border p-2'>
{editing.id === r.id && editing.scope === 'single' ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Selected phrase:')}
</label>
<input
className='input input-sm flex-1 text-sm opacity-60'
value={editing.pattern}
disabled
/>
</div>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Replace with:')}
</label>
<input
className='input input-sm flex-1'
value={editing.replacement}
onChange={(e) =>
setEditing({ ...editing, replacement: e.target.value })
}
/>
</div>
<div className='flex gap-2'>
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
{_('Save')}
</button>
<button className='btn btn-sm' onClick={cancelEdit}>
{_('Cancel')}
</button>
</div>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
<div className='text-base-content/70 mt-1 break-all text-sm'>
<span className='text-base-content/80 mr-2 text-xs font-medium'>
{_('Replace with:')}
</span>
{r.replacement}
</div>
<div className='text-base-content/60 mt-1 text-xs'>
{_('Scope:')}&nbsp;<span className='font-medium'>Single Instance</span>
&nbsp;|&nbsp;{_('Case sensitive:')}&nbsp;
<span className='font-medium'>
{r.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</div>
</div>
<div className='flex items-center gap-2'>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => startEdit(r, 'single')}
aria-label={_('Edit')}
>
<RiEditLine />
</button>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => deleteRule(r.id, 'single')}
aria-label={_('Delete')}
>
<RiDeleteBin7Line />
</button>
</div>
</div>
)}
</li>
))}
</ul>
)}
<h3 className='mt-4 text-sm font-semibold'>{_('Book Specific Rules')}</h3>
{bookRules.length === 0 ? (
<p className='text-base-content/70 mt-2 text-sm'>
{_('No book-level replacement rules')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{bookRules.map((r) => {
const ruleScope = getRuleScope(r);
const isEditing = editing.id === r.id && editing.scope === ruleScope;
return (
<li key={r.id} className='rounded border p-2'>
{isEditing ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Selected phrase:')}
</label>
<input
className='input input-sm flex-1 text-sm opacity-60'
value={editing.pattern}
disabled
/>
</div>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Replace with:')}
</label>
<input
className='input input-sm flex-1'
value={editing.replacement}
onChange={(e) =>
setEditing({ ...editing, replacement: e.target.value })
}
/>
</div>
<div className='flex gap-2'>
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
{_('Save')}
</button>
<button className='btn btn-sm' onClick={cancelEdit}>
{_('Cancel')}
</button>
</div>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
<div className='text-base-content/70 mt-1 break-all text-sm'>
<span className='text-base-content/80 mr-2 text-xs font-medium'>
{_('Replace with:')}
</span>
{r.replacement}
</div>
<div className='text-base-content/60 mt-1 text-xs'>
{_('Scope:')}&nbsp;
<span className='font-medium'>
{getRuleScope(r) === 'book' ? _('Book') : _('Global')}
</span>
&nbsp;|&nbsp;{_('Case sensitive:')}&nbsp;
<span className='font-medium'>
{r.caseSensitive !== false ? _('Yes') : _('No')}
</span>
</div>
</div>
<div className='flex items-center gap-2'>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => startEdit(r, getRuleScope(r))}
aria-label={_('Edit')}
>
<RiEditLine />
</button>
<button
className='btn btn-ghost btn-xs p-1'
onClick={() => deleteRule(r.id, ruleScope)}
aria-label={_('Delete')}
>
<RiDeleteBin7Line />
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</Dialog>
);
};
export default ReplacementRulesWindow;
@@ -65,12 +65,12 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
width: showDoubleBorder ? '32px' : `${contentInsets.right}px`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
}
: {
top: `${topInset}px`,
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
paddingInline: `calc(${horizontalGap / 2}% + ${contentInsets.left / 2}px)`,
width: '100%',
}
}
@@ -15,6 +15,7 @@ interface AnnotationPopupProps {
Icon: React.ElementType;
onClick: () => void;
disabled?: boolean;
visible?: boolean;
}>;
position: Position;
trianglePosition: Position;
@@ -51,6 +52,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
<Popup
width={isVertical ? popupHeight : popupWidth}
height={isVertical ? popupWidth : popupHeight}
minHeight={isVertical ? popupWidth : popupHeight}
position={position}
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 text-white'
@@ -59,23 +61,24 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
>
<div
className={clsx(
'selection-buttons flex items-center justify-between p-2',
isVertical ? 'flex-col' : 'flex-row',
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
)}
style={{
height: isVertical ? popupWidth : popupHeight,
}}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{buttons.map((button, index) => (
<PopupButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
))}
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<PopupButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
);
})}
</div>
</Popup>
{highlightOptionsVisible && (
@@ -8,6 +8,7 @@ import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import { MdBuildCircle } from 'react-icons/md';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
@@ -29,11 +30,15 @@ import { findTocItemBS } from '@/utils/toc';
import { throttle } from '@/utils/throttle';
import { runSimpleCC } from '@/utils/simplecc';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import { addReplacementRule } from '@/services/transformers/replacement';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import TranslatorPopup from './TranslatorPopup';
import useShortcuts from '@/hooks/useShortcuts';
import ReplacementOptions from './ReplacementOptions';
import { isWordLimitExceeded } from '@/utils/wordLimit';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -59,6 +64,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
const [showWikipediaPopup, setShowWikipediaPopup] = useState(false);
const [showDeepLPopup, setShowDeepLPopup] = useState(false);
const [showReplacementOptions, setShowReplacementOptions] = useState(false);
const [trianglePosition, setTrianglePosition] = useState<Position>();
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
@@ -150,6 +156,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
setShowReplacementOptions(false);
}, 500),
[],
);
@@ -163,10 +170,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleScroll,
handleTouchStart,
handleTouchEnd,
handlePointerdown,
handlePointerup,
handleSelectionchange,
handleShowPopup,
handleUpToPopup,
handleContextmenu,
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
const onLoad = (event: Event) => {
@@ -189,6 +198,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerdown', handlePointerdown);
detail.doc?.addEventListener('pointerup', (ev: PointerEvent) =>
handlePointerup(doc, index, ev),
);
@@ -222,13 +232,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
// Disable the default context menu on mobile devices (selection handles suffice)
if (appService?.isMobile) {
detail.doc?.addEventListener('contextmenu', (event: Event) => {
event.preventDefault();
event.stopPropagation();
return false;
});
}
detail.doc?.addEventListener('contextmenu', handleContextmenu);
};
const onDrawAnnotation = (event: Event) => {
@@ -385,8 +389,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowWikipediaPopup(false);
};
const handleCopy = () => {
const handleCopy = (copyToNotebook = true) => {
if (!selection || !selection.text) return;
setTimeout(() => {
// Delay to ensure it won't be overridden by system clipboard actions
navigator.clipboard?.writeText(selection.text);
}, 100);
handleDismissPopupAndSelection();
if (!copyToNotebook) return;
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Copied to notebook'),
@@ -395,7 +407,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
});
const { booknotes: annotations = [] } = config;
if (selection) navigator.clipboard?.writeText(selection.text);
const cfi = view?.getCFI(selection.index, selection.range);
if (!cfi) return;
const annotation: BookNote = {
@@ -421,7 +432,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
handleDismissPopupAndSelection();
if (!appService?.isMobile) {
setNotebookVisible(true);
}
@@ -519,6 +529,288 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
// Import type for ReplacementConfig
type ReplacementConfig = {
replacementText: string;
caseSensitive: boolean;
scope: 'once' | 'book' | 'library';
};
// Helper to check if selected text is a whole word (has word boundaries on both sides)
// Updated to be more lenient: allows phrases and lines, only prevents partial word matches
const isWholeWord = (range: Range, selectedText: string): boolean => {
try {
if (!selectedText || selectedText.trim().length === 0) return false;
// Verify the selection contains word characters
const hasWordCharInSelection = /[a-zA-Z0-9_]/.test(selectedText);
if (!hasWordCharInSelection) {
return false;
}
// If the selection contains spaces, punctuation, or multiple words, it's a phrase
// Phrases (including lines with quotes) are always allowed for single-instance replacements
const hasSpaces = /\s/.test(selectedText);
const hasPunctuation = /[^\w\s]/.test(selectedText);
const isPhrase = hasSpaces || hasPunctuation;
// Also allow selections that start or end with punctuation (e.g., "'tis", "off;", "look,")
// These are valid selections where the user intentionally includes punctuation
const startsWithPunctuation = /^[^\w\s]/.test(selectedText);
const endsWithPunctuation = /[^\w\s]$/.test(selectedText);
const hasBoundaryPunctuation = startsWithPunctuation || endsWithPunctuation;
if (isPhrase || hasBoundaryPunctuation) {
// For phrases or selections with boundary punctuation, we allow them
// The only thing we want to prevent is selecting "and" inside "England"
return true;
}
// For single words, check boundaries to prevent partial word matches
// Get characters immediately before and after the selection
let charBefore = '';
let charAfter = '';
try {
// Get character before
const startNode = range.startContainer;
if (startNode.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
const textNode = startNode as Text;
charBefore = textNode.textContent?.charAt(range.startOffset - 1) || '';
} else if (startNode.nodeType === Node.TEXT_NODE && range.startOffset === 0) {
// Check previous sibling text node
let prevSibling = startNode.previousSibling;
while (prevSibling && prevSibling.nodeType !== Node.TEXT_NODE) {
prevSibling = prevSibling.previousSibling;
}
if (prevSibling && prevSibling.nodeType === Node.TEXT_NODE) {
const prevText = (prevSibling as Text).textContent || '';
charBefore = prevText.charAt(prevText.length - 1);
}
}
// Get character after
const endNode = range.endContainer;
if (endNode.nodeType === Node.TEXT_NODE) {
const textNode = endNode as Text;
const textContent = textNode.textContent || '';
if (range.endOffset < textContent.length) {
charAfter = textContent.charAt(range.endOffset);
} else {
// Check next sibling text node
let nextSibling = textNode.nextSibling;
while (nextSibling && nextSibling.nodeType !== Node.TEXT_NODE) {
nextSibling = nextSibling.nextSibling;
}
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) {
const nextText = (nextSibling as Text).textContent || '';
charAfter = nextText.charAt(0);
}
}
}
} catch (e) {
// If we can't determine boundaries for a single word, be lenient
// This handles edge cases with complex HTML
console.warn('[isWholeWord] Error checking boundaries:', e);
return true; // Allow if we can't verify (better to allow than reject valid selections)
}
// Word characters are: letters, digits, and underscore [a-zA-Z0-9_]
const isWordChar = (char: string) => /[a-zA-Z0-9_]/.test(char);
// Check boundaries for single words
// Empty means we're at start/end of text (valid boundary)
const hasBoundaryBefore = !charBefore || !isWordChar(charBefore);
const hasBoundaryAfter = !charAfter || !isWordChar(charAfter);
const isValid = hasBoundaryBefore && hasBoundaryAfter;
if (!isValid) {
console.log('[isWholeWord] Not a whole word:', {
selectedText,
charBefore: charBefore || '(start)',
charAfter: charAfter || '(end)',
hasBoundaryBefore,
hasBoundaryAfter,
});
}
return isValid;
} catch (e) {
console.warn('Failed to check whole word:', e);
// On error, be lenient - allow selections with word characters
// This prevents false rejections for complex selections (quotes, multi-node, etc.)
return /[a-zA-Z0-9_]/.test(selectedText);
}
};
// Helper to count which occurrence of a pattern was selected (using whole-word matching)
const getOccurrenceIndex = (range: Range, pattern: string): number => {
try {
const doc = range.startContainer.ownerDocument;
if (!doc || !doc.body) return 0;
// Create a range from start of body to start of selection
const beforeRange = doc.createRange();
beforeRange.setStart(doc.body, 0);
beforeRange.setEnd(range.startContainer, range.startOffset);
// Get text before selection and count occurrences using whole-word matching
const textBefore = beforeRange.toString();
// Escape pattern and add word boundaries for whole-word matching
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wholeWordPattern = `\\b${escapedPattern}\\b`;
const regex = new RegExp(wholeWordPattern, 'g');
const matches = textBefore.match(regex);
return matches ? matches.length : 0;
} catch (e) {
console.warn('Failed to get occurrence index:', e);
return 0;
}
};
const handleReplacementConfirm = async (config: ReplacementConfig) => {
if (!selection || !selection.text) return;
const { replacementText, caseSensitive, scope } = config;
console.log('Replacement confirmed:', {
originalText: selection.text,
replacementText,
caseSensitive,
scope,
});
try {
if (scope === 'once') {
// For single-instance: direct DOM modification + persistent rule
const range = selection.range;
if (range) {
// Validate that the selection is a whole word
// Single-instance replacements only work on whole words to prevent
// replacing substrings inside larger words (e.g., "and" in "England")
const isValidWholeWord = isWholeWord(range, selection.text);
if (!isValidWholeWord) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`,
timeout: 5000,
});
return;
}
// Get which occurrence this is BEFORE modifying the DOM
// Use whole-word matching to count occurrences correctly
const occurrenceIndex = getOccurrenceIndex(range, selection.text);
const sectionHref = progress?.sectionHref;
// Directly modify DOM for immediate effect
// Note: createTextNode automatically escapes HTML entities, so angle brackets will be preserved
range.deleteContents();
const textNode = document.createTextNode(replacementText);
range.insertNode(textNode);
// Create rule with occurrence tracking for persistence
await addReplacementRule(
envConfig,
bookKey,
{
pattern: selection.text,
replacement: replacementText,
isRegex: false,
enabled: true,
caseSensitive,
singleInstance: true,
sectionHref,
occurrenceIndex,
},
'single',
);
eventDispatcher.dispatch('toast', {
type: 'success',
message: 'Replacement applied! Will persist on refresh.',
timeout: 3000,
});
setShowReplacementOptions(false);
handleDismissPopupAndSelection();
}
} else {
// For book-wide and global: use the transformer approach
const backendScope = scope === 'book' ? 'book' : 'global';
const range = selection.range;
const isValidWholeWord = range ? isWholeWord(range, selection.text) : false;
if (!isValidWholeWord) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`,
timeout: 5000,
});
return;
}
await addReplacementRule(
envConfig,
bookKey,
{
pattern: selection.text,
replacement: replacementText,
isRegex: false,
enabled: true,
caseSensitive,
singleInstance: false,
wholeWord: true,
},
backendScope as 'book' | 'global',
);
const scopeLabels = {
book: 'this book',
library: 'your library',
};
eventDispatcher.dispatch('toast', {
type: 'success',
message: `Replacement applied to ${scopeLabels[scope]}! Reloading...`,
timeout: 3000,
});
setShowReplacementOptions(false);
handleDismissPopupAndSelection();
// Reload the book view to apply the replacement
const { recreateViewer } = useReaderStore.getState();
await recreateViewer(envConfig, bookKey);
}
} catch (error) {
console.error('Failed to apply replacement:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: 'Failed to apply replacement. Please try again.',
timeout: 3000,
});
}
};
const handleShowReplacementOptions = () => {
if (!selection || !selection.text) {
return;
}
if (isWordLimitExceeded(selection.text)) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: 'Word limit exceeded. Please select 30 words or fewer.',
timeout: 3000,
});
return;
}
setShowReplacementOptions(!showReplacementOptions);
};
// Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden
useShortcuts(
{
@@ -535,7 +827,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleSearch();
},
onCopySelection: () => {
handleCopy();
handleCopy(false);
},
onTranslateSelection: () => {
handleTranslation();
@@ -668,6 +960,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onClick: handleSpeakText,
disabled: bookData.book?.format === 'PDF',
},
{
tooltipText: 'Text Replacement',
Icon: MdBuildCircle,
onClick: handleShowReplacementOptions,
disabled: bookData.book?.format !== 'EPUB',
visible: false,
},
];
return (
@@ -720,6 +1019,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showReplacementOptions && trianglePosition && annotPopupPosition && (
<ReplacementOptions
isVertical={viewSettings.vertical}
style={{
height: 'auto',
left: `${annotPopupPosition.point.x}px`,
top: `${
annotPopupPosition.point.y +
(annotPopupHeight + 16) * (trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}}
selectedText={selection?.text || ''}
onConfirm={handleReplacementConfirm}
onClose={() => setShowReplacementOptions(false)}
/>
)}
</div>
);
};
@@ -0,0 +1,284 @@
'use client';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
export interface ReplacementConfig {
replacementText: string;
caseSensitive: boolean;
scope: 'once' | 'book' | 'library';
}
interface ReplacementOptionsProps {
isVertical: boolean;
style: React.CSSProperties;
selectedText: string;
onConfirm: (config: ReplacementConfig) => void;
onClose: () => void;
}
const ReplacementOptions: React.FC<ReplacementOptionsProps> = ({
style,
isVertical,
selectedText,
onConfirm,
onClose,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [replacementText, setReplacementText] = useState('');
const [caseSensitive, setCaseSensitive] = useState(true);
const [selectedScope, setSelectedScope] = useState<'once' | 'book' | 'library' | null>(null);
const [showConfirmation, setShowConfirmation] = useState(false);
const [adjustedStyle, setAdjustedStyle] = useState<React.CSSProperties | null>(null);
const [isPositioned, setIsPositioned] = useState(false);
const hasAdjusted = useRef(false);
// Adjust position to stay within viewport - only once on initial render
useEffect(() => {
// Only adjust once to prevent jumping when other UI elements appear
if (menuRef.current && !hasAdjusted.current) {
// Use requestAnimationFrame to ensure the element is rendered before measuring
requestAnimationFrame(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const padding = 10;
const newStyle = { ...style };
// Check if popup extends beyond bottom of viewport
if (rect.bottom > viewportHeight - padding) {
const currentTop = parseFloat(String(style.top)) || 0;
// Move popup above the selection instead
newStyle.top = `${Math.max(padding, currentTop - rect.height - 40)}px`;
}
// Check if popup extends beyond right of viewport
if (rect.right > viewportWidth - padding) {
newStyle.left = `${Math.max(padding, viewportWidth - rect.width - padding)}px`;
}
// Check if popup extends beyond left of viewport
if (rect.left < padding) {
newStyle.left = `${padding}px`;
}
setAdjustedStyle(newStyle);
hasAdjusted.current = true;
setIsPositioned(true);
}
});
}
}, [style]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Focus input on mount
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleScopeClick = (scope: 'once' | 'book' | 'library') => {
if (!replacementText.trim()) {
// Show error if no replacement text
return;
}
setSelectedScope(scope);
setShowConfirmation(true);
};
const handleConfirm = () => {
if (selectedScope && replacementText.trim()) {
onConfirm({
replacementText: replacementText.trim(),
caseSensitive,
scope: selectedScope,
});
}
};
const handleCancelConfirmation = () => {
setShowConfirmation(false);
setSelectedScope(null);
};
const handleCancel = () => {
onClose();
};
const getScopeLabel = (scope: 'once' | 'book' | 'library' | null) => {
switch (scope) {
case 'once':
return 'this instance';
case 'book':
return 'all instances in this book';
case 'library':
return 'all instances in your library';
default:
return '';
}
};
// Secondary confirmation dialog
if (showConfirmation) {
return (
<div
ref={menuRef}
className={clsx(
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
)}
style={{
...(adjustedStyle || style),
minWidth: '320px',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
<div className='text-sm text-white'>
<p className='mb-2 font-semibold'>Confirm Replacement</p>
<p className='mb-1 text-gray-300'>
Replace: <span className='text-yellow-300'>&quot;{selectedText}&quot;</span>
</p>
<p className='mb-1 text-gray-300'>
With: <span className='text-green-300'>&quot;{replacementText}&quot;</span>
</p>
<p className='mb-1 text-gray-300'>
Scope: <span className='text-blue-300'>{getScopeLabel(selectedScope)}</span>
</p>
<p className='text-gray-300'>
Case sensitive: <span className='text-purple-300'>{caseSensitive ? 'Yes' : 'No'}</span>
</p>
</div>
<div className='mt-2 flex gap-2'>
<button
onClick={handleCancelConfirmation}
className='flex-1 rounded-md bg-gray-600 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-500'
>
Back
</button>
<button
onClick={handleConfirm}
className='flex-1 rounded-md bg-green-600 px-3 py-2 text-sm text-white transition-colors hover:bg-green-500'
>
Confirm
</button>
</div>
</div>
);
}
return (
<div
ref={menuRef}
className={clsx(
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
isVertical ? 'flex-col' : 'flex-col',
)}
style={{
...(adjustedStyle || style),
minWidth: '280px',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
{/* Selected text preview */}
<div className='text-xs text-gray-400'>
<span>Selected: </span>
<span className='break-words text-yellow-300'>
&quot;{selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText}
&quot;
</span>
</div>
{/* Replacement text input */}
<div className='flex flex-col gap-1'>
<label htmlFor='replacement-input' className='text-xs text-gray-400'>
Replace with:
</label>
<input
ref={inputRef}
id='replacement-input'
type='text'
value={replacementText}
onChange={(e) => setReplacementText(e.target.value)}
placeholder='Enter replacement text...'
className='w-full rounded-md bg-gray-600 px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
</div>
{/* Case sensitivity checkbox */}
<label className='flex cursor-pointer items-center gap-2'>
<input
type='checkbox'
checked={caseSensitive}
onChange={(e) => setCaseSensitive(e.target.checked)}
className='h-4 w-4 rounded border-gray-500 bg-gray-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-gray-700'
/>
<span className='text-sm text-white'>Case Sensitive</span>
</label>
{/* Scope buttons */}
<div className='mt-1 flex flex-col gap-1'>
<button
onClick={() => handleScopeClick('once')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix this once
</button>
<button
onClick={() => handleScopeClick('book')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix in this book
</button>
<button
onClick={() => handleScopeClick('library')}
disabled={!replacementText.trim()}
className={clsx(
'whitespace-nowrap rounded-md px-3 py-2 text-left text-sm text-white transition-colors',
replacementText.trim() ? 'hover:bg-base-content/10' : 'cursor-not-allowed opacity-50',
)}
>
Fix in library
</button>
</div>
{/* Cancel button */}
<button
onClick={handleCancel}
className='mt-2 rounded-md bg-gray-600 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-500'
>
Cancel
</button>
</div>
);
};
export default ReplacementOptions;
@@ -202,23 +202,20 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
)}
</div>
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between bg-gray-600 px-4'>
{provider && !loading && (
<div className='line-clamp-1 text-xs opacity-60'>
{error
? ''
: _('Translated by {{provider}}.', {
provider: providers.find((p) => p.name === provider)?.label,
})}
</div>
)}
<div className='ml-auto'>
<Select
className='bg-gray-600 text-white/75'
value={provider}
onChange={handleProviderChange}
options={providers.map(({ name: value, label }) => ({ value, label }))}
/>
<div className='line-clamp-1 text-xs opacity-60'>
{provider &&
!loading &&
!error &&
_('Translated by {{provider}}.', {
provider: providers.find((p) => p.name === provider)?.label,
})}
</div>
<Select
className='bg-gray-600 text-white/75'
value={provider}
onChange={handleProviderChange}
options={providers.map(({ name: value, label }) => ({ value, label }))}
/>
</div>
</Popup>
</div>

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