Compare commits

..

39 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
129 changed files with 6322 additions and 905 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') }}
+6 -4
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
+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
+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',
+10 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.95",
"version": "0.9.96",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -37,7 +37,7 @@
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0",
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0 --port 3001",
"deploy": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare deploy",
"upload": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
@@ -61,7 +61,7 @@
"@supabase/auth-ui-react": "^0.4.7",
"@supabase/auth-ui-shared": "^0.1.8",
"@supabase/supabase-js": "^2.76.1",
"@tauri-apps/api": "2.9.0",
"@tauri-apps/api": "2.9.1",
"@tauri-apps/plugin-cli": "^2.4.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
@@ -74,6 +74,7 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "~2.3.3",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-websocket": "~2.4.1",
"@zip.js/zip.js": "^2.7.53",
"abortcontroller-polyfill": "^1.7.8",
"app-store-server-api": "^0.17.1",
@@ -92,10 +93,11 @@
"i18next-http-backend": "^3.0.1",
"iso-639-2": "^3.0.2",
"iso-639-3": "^3.0.1",
"isomorphic-ws": "^5.0.0",
"js-md5": "^0.8.3",
"jwt-decode": "^4.0.0",
"marked": "^15.0.12",
"next": "16.0.7",
"next": "16.0.10",
"overlayscrollbars": "^2.11.4",
"overlayscrollbars-react": "^0.5.6",
"posthog-js": "^1.246.0",
@@ -105,19 +107,21 @@
"react-i18next": "^15.2.0",
"react-icons": "^5.4.0",
"react-responsive": "^10.0.0",
"react-virtuoso": "^4.17.0",
"react-window": "^1.8.11",
"semver": "^7.7.1",
"stripe": "^18.2.1",
"styled-jsx": "^5.1.7",
"tinycolor2": "^1.6.0",
"uuid": "^11.1.0",
"ws": "^8.18.3",
"zod": "^4.0.8",
"zustand": "5.0.6"
},
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.9.4",
"@tauri-apps/cli": "2.9.6",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/cors": "^2.8.17",
@@ -130,6 +134,7 @@
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@vitejs/plugin-react": "^5.1.1",
@@ -715,9 +715,7 @@
"Validating...": "جارٍ التحقق...",
"View All": "عرض الكل",
"Forward": "إلى الأمام",
"OPDS Catalog": "كتالوج OPDS",
"Home": "الصفحة الرئيسية",
"Library": "المكتبة",
"{{count}} items_zero": "{{count}} عناصر",
"{{count}} items_one": "{{count}} عنصر",
"{{count}} items_two": "{{count}} عنصران",
@@ -803,5 +801,9 @@
"Successfully imported {{count}} book(s)_two": "تم استيراد كتابين",
"Successfully imported {{count}} book(s)_few": "تم استيراد {{count}} كتب",
"Successfully imported {{count}} book(s)_many": "تم استيراد {{count}} كتابًا",
"Successfully imported {{count}} book(s)_other": "تم استيراد {{count}} كتاب"
"Successfully imported {{count}} book(s)_other": "تم استيراد {{count}} كتاب",
"Count": "العدد",
"Start Page": "الصفحة الأولى",
"Search in OPDS Catalog...": "البحث في كتالوج OPDS...",
"Please log in to use advanced TTS features.": "يرجى تسجيل الدخول لاستخدام ميزات تحويل النص إلى كلام المتقدمة."
}
@@ -699,9 +699,7 @@
"Validating...": "যাচাই করা হচ্ছে...",
"View All": "সব দেখুন",
"Forward": "ফরোয়ার্ড",
"OPDS Catalog": "OPDS ক্যাটালগ",
"Home": "হোম",
"Library": "লাইব্রেরি",
"{{count}} items_one": "{{count}} আইটেম",
"{{count}} items_other": "{{count}} আইটেম",
"Download completed": "ডাউনলোড সম্পন্ন হয়েছে",
@@ -763,5 +761,9 @@
"Rename Group": "গ্রুপের নাম পরিবর্তন করুন",
"From Directory": "ডিরেক্টরি থেকে",
"Successfully imported {{count}} book(s)_one": "সফলভাবে ১টি বই আমদানি করা হয়েছে",
"Successfully imported {{count}} book(s)_other": "সফলভাবে {{count}}টি বই আমদানি করা হয়েছে"
"Successfully imported {{count}} book(s)_other": "সফলভাবে {{count}}টি বই আমদানি করা হয়েছে",
"Count": "গণনা",
"Start Page": "শুরু পৃষ্ঠা",
"Search in OPDS Catalog...": "OPDS ক্যাটালগে অনুসন্ধান করুন...",
"Please log in to use advanced TTS features.": "উন্নত TTS বৈশিষ্ট্যগুলি ব্যবহার করতে লগইন করুন।"
}
@@ -695,9 +695,7 @@
"Validating...": "བདེན་སྦྱོར་བཞིན་...",
"View All": "ཡོངས་ལྟ་བ་",
"Forward": "མདུན་དུ་",
"OPDS Catalog": "OPDS དཀར་ཆག",
"Home": "གཙོ་ངོས་",
"Library": "དེབ་མཛོད་",
"{{count}} items_other": "{{count}} རྣམ་གྲངས་",
"Download completed": "ཕབ་ལེན་རྫོགས་སོང་།",
"Download failed": "ཕབ་ལེན་ཕམ་པ་",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "སྤྲིན་གནས་སྣོད་གསོག་ལུས་སྐོར།",
"Rename Group": "ཚོགས་མིང་བསྒྱུར་བ།",
"From Directory": "སྐོར་འདེམས་པ་ནས།",
"Successfully imported {{count}} book(s)_other": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་ {{count}} དེབ་འདེམས་སོང་"
"Successfully imported {{count}} book(s)_other": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་ {{count}} དེབ་འདེམས་སོང་",
"Count": "ཨང་",
"Start Page": "ཤོག་ངོས་དང་པོ",
"Search in OPDS Catalog...": "OPDS དཀར་ཆག་ནང་འཚོལ།...",
"Please log in to use advanced TTS features.": "དབང་བསྐྱོད་ཀྱི TTS རྣམ་པ་ཚུགས་སྤྱོད་བྱས་མ་ཐུབ།"
}
@@ -699,9 +699,7 @@
"Validating...": "Wird überprüft...",
"View All": "Alle anzeigen",
"Forward": "Weiter",
"OPDS Catalog": "OPDS-Katalog",
"Home": "Start",
"Library": "Bibliothek",
"{{count}} items_one": "{{count}} Element",
"{{count}} items_other": "{{count}} Elemente",
"Download completed": "Download abgeschlossen",
@@ -763,5 +761,9 @@
"Rename Group": "Gruppe umbenennen",
"From Directory": "Aus Verzeichnis",
"Successfully imported {{count}} book(s)_one": "Erfolgreich 1 Buch importiert",
"Successfully imported {{count}} book(s)_other": "Erfolgreich {{count}} Bücher importiert"
"Successfully imported {{count}} book(s)_other": "Erfolgreich {{count}} Bücher importiert",
"Count": "Anzahl",
"Start Page": "Startseite",
"Search in OPDS Catalog...": "Im OPDS-Katalog suchen...",
"Please log in to use advanced TTS features.": "Bitte melde dich an, um erweiterte TTS-Funktionen zu nutzen."
}
@@ -699,9 +699,7 @@
"Validating...": "Γίνεται έλεγχος...",
"View All": "Προβολή όλων",
"Forward": "Μπροστά",
"OPDS Catalog": "Κατάλογος OPDS",
"Home": "Αρχική",
"Library": "Βιβλιοθήκη",
"{{count}} items_one": "{{count}} στοιχείο",
"{{count}} items_other": "{{count}} στοιχεία",
"Download completed": "Η λήψη ολοκληρώθηκε",
@@ -763,5 +761,9 @@
"Rename Group": "Μετονομασία ομάδας",
"From Directory": "Από κατάλογο",
"Successfully imported {{count}} book(s)_one": "Επιτυχής εισαγωγή 1 βιβλίου",
"Successfully imported {{count}} book(s)_other": "Επιτυχής εισαγωγή {{count}} βιβλίων"
"Successfully imported {{count}} book(s)_other": "Επιτυχής εισαγωγή {{count}} βιβλίων",
"Count": "Πλήθος",
"Start Page": "Αρχική Σελίδα",
"Search in OPDS Catalog...": "Αναζήτηση στον κατάλογο OPDS...",
"Please log in to use advanced TTS features.": "Παρακαλώ συνδεθείτε για να χρησιμοποιήσετε προηγμένες λειτουργίες TTS."
}
@@ -703,9 +703,7 @@
"Validating...": "Validando...",
"View All": "Ver todo",
"Forward": "Adelante",
"OPDS Catalog": "Catálogo OPDS",
"Home": "Inicio",
"Library": "Biblioteca",
"{{count}} items_one": "{{count}} elemento",
"{{count}} items_many": "{{count}} elementos",
"{{count}} items_other": "{{count}} elementos",
@@ -773,5 +771,9 @@
"From Directory": "Desde el directorio",
"Successfully imported {{count}} book(s)_one": "Se importó correctamente 1 libro",
"Successfully imported {{count}} book(s)_many": "Se importaron correctamente {{count}} libros",
"Successfully imported {{count}} book(s)_other": "Se importaron correctamente {{count}} libros"
"Successfully imported {{count}} book(s)_other": "Se importaron correctamente {{count}} libros",
"Count": "Cuenta",
"Start Page": "Página de inicio",
"Search in OPDS Catalog...": "Buscar en el catálogo OPDS...",
"Please log in to use advanced TTS features.": "Por favor, inicie sesión para usar funciones avanzadas de TTS."
}
@@ -699,9 +699,7 @@
"Validating...": "در حال بررسی...",
"View All": "مشاهده همه",
"Forward": "بعدی",
"OPDS Catalog": "فهرست OPDS",
"Home": "خانه",
"Library": "کتابخانه",
"{{count}} items_one": "{{count}} مورد",
"{{count}} items_other": "{{count}} مورد",
"Download completed": "دانلود کامل شد",
@@ -763,5 +761,9 @@
"Rename Group": "تغییر نام گروه",
"From Directory": "از مسیر",
"Successfully imported {{count}} book(s)_one": "با موفقیت 1 کتاب وارد شد",
"Successfully imported {{count}} book(s)_other": "با موفقیت {{count}} کتاب وارد شد"
"Successfully imported {{count}} book(s)_other": "با موفقیت {{count}} کتاب وارد شد",
"Count": "تعداد",
"Start Page": "صفحه شروع",
"Search in OPDS Catalog...": "جستجو در فهرست OPDS...",
"Please log in to use advanced TTS features.": "لطفاً برای استفاده از ویژگی‌های پیشرفته TTS وارد شوید."
}
@@ -703,9 +703,7 @@
"Validating...": "Validation...",
"View All": "Tout afficher",
"Forward": "Suivant",
"OPDS Catalog": "Catalogue OPDS",
"Home": "Accueil",
"Library": "Bibliothèque",
"{{count}} items_one": "{{count}} élément",
"{{count}} items_many": "{{count}} éléments",
"{{count}} items_other": "{{count}} éléments",
@@ -773,5 +771,9 @@
"From Directory": "Depuis le répertoire",
"Successfully imported {{count}} book(s)_one": "Importation réussie de 1 livre",
"Successfully imported {{count}} book(s)_many": "Importation réussie de {{count}} livres",
"Successfully imported {{count}} book(s)_other": "Importation réussie de {{count}} livres"
"Successfully imported {{count}} book(s)_other": "Importation réussie de {{count}} livres",
"Count": "Nombre",
"Start Page": "Page de départ",
"Search in OPDS Catalog...": "Rechercher dans le catalogue OPDS...",
"Please log in to use advanced TTS features.": "Veuillez vous connecter pour utiliser les fonctionnalités avancées de TTS."
}
@@ -699,9 +699,7 @@
"Validating...": "मान्य किया जा रहा है...",
"View All": "सभी देखें",
"Forward": "आगे",
"OPDS Catalog": "OPDS कैटलॉग",
"Home": "होम",
"Library": "लाइब्रेरी",
"{{count}} items_one": "{{count}} आइटम",
"{{count}} items_other": "{{count}} आइटम",
"Download completed": "डाउनलोड पूरा हुआ",
@@ -763,5 +761,9 @@
"Rename Group": "समूह का नाम बदलें",
"From Directory": "निर्देशिका से",
"Successfully imported {{count}} book(s)_one": "सफलतापूर्वक 1 पुस्तक आयात की गई",
"Successfully imported {{count}} book(s)_other": "सफलतापूर्वक {{count}} पुस्तकों का आयात किया गया"
"Successfully imported {{count}} book(s)_other": "सफलतापूर्वक {{count}} पुस्तकों का आयात किया गया",
"Count": "गणना",
"Start Page": "प्रारंभ पृष्ठ",
"Search in OPDS Catalog...": "OPDS कैटलॉग में खोजें...",
"Please log in to use advanced TTS features.": "उन्नत TTS सुविधाओं का उपयोग करने के लिए कृपया लॉग इन करें।"
}
@@ -695,9 +695,7 @@
"Validating...": "Memvalidasi...",
"View All": "Lihat Semua",
"Forward": "Maju",
"OPDS Catalog": "Katalog OPDS",
"Home": "Beranda",
"Library": "Perpustakaan",
"{{count}} items_other": "{{count}} item",
"Download completed": "Unduhan selesai",
"Download failed": "Unduhan gagal",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "Penggunaan Penyimpanan Cloud",
"Rename Group": "Ganti Nama Grup",
"From Directory": "Dari Direktori",
"Successfully imported {{count}} book(s)_other": "Berhasil mengimpor {{count}} buku"
"Successfully imported {{count}} book(s)_other": "Berhasil mengimpor {{count}} buku",
"Count": "Jumlah",
"Start Page": "Halaman Awal",
"Search in OPDS Catalog...": "Cari di Katalog OPDS...",
"Please log in to use advanced TTS features.": "Silakan masuk untuk menggunakan fitur TTS lanjutan."
}
@@ -703,9 +703,7 @@
"Validating...": "Convalida in corso...",
"View All": "Vedi tutto",
"Forward": "Avanti",
"OPDS Catalog": "Catalogo OPDS",
"Home": "Home",
"Library": "Libreria",
"{{count}} items_one": "{{count}} elemento",
"{{count}} items_many": "{{count}} elementi",
"{{count}} items_other": "{{count}} elementi",
@@ -773,5 +771,9 @@
"From Directory": "Da directory",
"Successfully imported {{count}} book(s)_one": "Importato con successo 1 libro",
"Successfully imported {{count}} book(s)_many": "Importati con successo {{count}} libri",
"Successfully imported {{count}} book(s)_other": "Importati con successo {{count}} libri"
"Successfully imported {{count}} book(s)_other": "Importati con successo {{count}} libri",
"Count": "Conteggio",
"Start Page": "Pagina iniziale",
"Search in OPDS Catalog...": "Cerca nel catalogo OPDS...",
"Please log in to use advanced TTS features.": "Effettua il login per utilizzare le funzionalità TTS avanzate."
}
@@ -695,9 +695,7 @@
"Validating...": "検証中...",
"View All": "すべて表示",
"Forward": "進む",
"OPDS Catalog": "OPDSカタログ",
"Home": "ホーム",
"Library": "ライブラリ",
"{{count}} items_other": "{{count}} 件",
"Download completed": "ダウンロード完了",
"Download failed": "ダウンロード失敗",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "クラウドストレージ使用量",
"Rename Group": "グループ名を変更",
"From Directory": "ディレクトリから",
"Successfully imported {{count}} book(s)_other": "成功裏に{{count}}冊の本をインポートしました"
"Successfully imported {{count}} book(s)_other": "成功裏に{{count}}冊の本をインポートしました",
"Count": "件数",
"Start Page": "開始ページ",
"Search in OPDS Catalog...": "OPDSカタログ内を検索...",
"Please log in to use advanced TTS features.": "高度なTTS機能を使用するにはログインしてください。"
}
@@ -695,9 +695,7 @@
"Validating...": "검증 중...",
"View All": "모두 보기",
"Forward": "다음",
"OPDS Catalog": "OPDS 카탈로그",
"Home": "홈",
"Library": "라이브러리",
"{{count}} items_other": "{{count}}개 항목",
"Download completed": "다운로드 완료",
"Download failed": "다운로드 실패",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "클라우드 저장소 사용량",
"Rename Group": "그룹 이름 바꾸기",
"From Directory": "디렉토리에서",
"Successfully imported {{count}} book(s)_other": "성공적으로 {{count}} 권의 책이 가져와졌습니다"
"Successfully imported {{count}} book(s)_other": "성공적으로 {{count}} 권의 책이 가져와졌습니다",
"Count": "개수",
"Start Page": "시작 페이지",
"Search in OPDS Catalog...": "OPDS 카탈로그에서 검색...",
"Please log in to use advanced TTS features.": "고급 TTS 기능을 사용하려면 로그인하세요."
}
@@ -695,9 +695,7 @@
"Validating...": "Mengesahkan...",
"View All": "Lihat Semua",
"Forward": "Maju",
"OPDS Catalog": "Katalog OPDS",
"Home": "Laman Utama",
"Library": "Perpustakaan",
"{{count}} items_other": "{{count}} item",
"Download completed": "Muat turun selesai",
"Download failed": "Muat turun gagal",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "Penggunaan Storan Awan",
"Rename Group": "Namakan Semula Kumpulan",
"From Directory": "Dari Direktori",
"Successfully imported {{count}} book(s)_other": "Berjaya mengimport {{count}} buku"
"Successfully imported {{count}} book(s)_other": "Berjaya mengimport {{count}} buku",
"Count": "Jumlah",
"Start Page": "Halaman Awal",
"Search in OPDS Catalog...": "Cari dalam Katalog OPDS...",
"Please log in to use advanced TTS features.": "Sila log masuk untuk menggunakan ciri TTS lanjutan."
}
@@ -699,9 +699,7 @@
"Validating...": "Valideren...",
"View All": "Alles bekijken",
"Forward": "Verder",
"OPDS Catalog": "OPDS-catalogus",
"Home": "Startpagina",
"Library": "Bibliotheek",
"{{count}} items_one": "{{count}} item",
"{{count}} items_other": "{{count}} items",
"Download completed": "Download voltooid",
@@ -763,5 +761,9 @@
"Rename Group": "Groep hernoemen",
"From Directory": "Vanuit map",
"Successfully imported {{count}} book(s)_one": "Succesvol 1 boek geïmporteerd",
"Successfully imported {{count}} book(s)_other": "Succesvol {{count}} boeken geïmporteerd"
"Successfully imported {{count}} book(s)_other": "Succesvol {{count}} boeken geïmporteerd",
"Count": "Aantal",
"Start Page": "Startpagina",
"Search in OPDS Catalog...": "Zoeken in OPDS-catalogus...",
"Please log in to use advanced TTS features.": "Log in om geavanceerde TTS-functies te gebruiken."
}
@@ -707,9 +707,7 @@
"Validating...": "Weryfikacja...",
"View All": "Pokaż wszystkie",
"Forward": "Dalej",
"OPDS Catalog": "Katalog OPDS",
"Home": "Strona główna",
"Library": "Biblioteka",
"{{count}} items_one": "{{count}} element",
"{{count}} items_few": "{{count}} elementy",
"{{count}} items_many": "{{count}} elementów",
@@ -783,5 +781,9 @@
"Successfully imported {{count}} book(s)_one": "Pomyślnie zaimportowano 1 książkę",
"Successfully imported {{count}} book(s)_few": "Pomyślnie zaimportowano {{count}} książki",
"Successfully imported {{count}} book(s)_many": "Pomyślnie zaimportowano {{count}} książek",
"Successfully imported {{count}} book(s)_other": "Pomyślnie zaimportowano {{count}} książek"
"Successfully imported {{count}} book(s)_other": "Pomyślnie zaimportowano {{count}} książek",
"Count": "Liczba",
"Start Page": "Strona startowa",
"Search in OPDS Catalog...": "Szukaj w katalogu OPDS...",
"Please log in to use advanced TTS features.": "Zaloguj się, aby korzystać z zaawansowanych funkcji TTS."
}
@@ -703,9 +703,7 @@
"Validating...": "Validando...",
"View All": "Ver todos",
"Forward": "Avançar",
"OPDS Catalog": "Catálogo OPDS",
"Home": "Início",
"Library": "Biblioteca",
"{{count}} items_one": "{{count}} item",
"{{count}} items_many": "{{count}} itens",
"{{count}} items_other": "{{count}} item",
@@ -773,5 +771,9 @@
"From Directory": "Do Diretório",
"Successfully imported {{count}} book(s)_one": "Importado com sucesso 1 livro",
"Successfully imported {{count}} book(s)_many": "Importados com sucesso {{count}} livros",
"Successfully imported {{count}} book(s)_other": "Importados com sucesso {{count}} livros"
"Successfully imported {{count}} book(s)_other": "Importados com sucesso {{count}} livros",
"Count": "Contagem",
"Start Page": "Página Inicial",
"Search in OPDS Catalog...": "Pesquisar no Catálogo OPDS...",
"Please log in to use advanced TTS features.": "Por favor, faça login para usar recursos avançados de TTS."
}
@@ -707,9 +707,7 @@
"Validating...": "Проверка...",
"View All": "Посмотреть все",
"Forward": "Вперёд",
"OPDS Catalog": "Каталог OPDS",
"Home": "Главная",
"Library": "Библиотека",
"{{count}} items_one": "{{count}} элемент",
"{{count}} items_few": "{{count}} элемента",
"{{count}} items_many": "{{count}} элементов",
@@ -783,5 +781,9 @@
"Successfully imported {{count}} book(s)_one": "Успешно импортирован 1 книга",
"Successfully imported {{count}} book(s)_few": "Успешно импортировано {{count}} книги",
"Successfully imported {{count}} book(s)_many": "Успешно импортировано {{count}} книг",
"Successfully imported {{count}} book(s)_other": "Успешно импортировано {{count}} книг"
"Successfully imported {{count}} book(s)_other": "Успешно импортировано {{count}} книг",
"Count": "Количество",
"Start Page": "Начальная страница",
"Search in OPDS Catalog...": "Поиск в каталоге OPDS...",
"Please log in to use advanced TTS features.": "Пожалуйста, войдите в систему, чтобы использовать расширенные функции TTS."
}
@@ -699,9 +699,7 @@
"Validating...": "තහවුරු කරමින්...",
"View All": "සියල්ල බලන්න",
"Forward": "ඉදිරියට",
"OPDS Catalog": "OPDS දත්තසමුදා",
"Home": "මුල් පිටුව",
"Library": "පුස්තකාලය",
"{{count}} items_one": "{{count}} අයිතමය",
"{{count}} items_other": "{{count}} අයිතම",
"Download completed": "බාගත කිරීම සම්පූර්ණයි",
@@ -763,5 +761,9 @@
"Rename Group": "කණ්ඩායම නැවත නම් කරන්න",
"From Directory": "ෆෝල්ඩරයෙන්",
"Successfully imported {{count}} book(s)_one": "සාර්ථකව ආයාත කළ 1 පොත",
"Successfully imported {{count}} book(s)_other": "සාර්ථකව ආයාත කළ {{count}} පොත්"
"Successfully imported {{count}} book(s)_other": "සාර්ථකව ආයාත කළ {{count}} පොත්",
"Count": "ගණන",
"Start Page": "ආරම්භක පිටුව",
"Search in OPDS Catalog...": "OPDS දත්තසමුදා තුළ සෙවීම...",
"Please log in to use advanced TTS features.": "උසස් TTS විශේෂාංග භාවිතා කිරීමට කරුණාකර පිවිසෙන්න."
}
@@ -699,9 +699,7 @@
"Validating...": "Verifierar...",
"View All": "Visa alla",
"Forward": "Framåt",
"OPDS Catalog": "OPDS-katalog",
"Home": "Start",
"Library": "Bibliotek",
"{{count}} items_one": "{{count}} objekt",
"{{count}} items_other": "{{count}} objekt",
"Download completed": "Nedladdning klar",
@@ -763,5 +761,9 @@
"Rename Group": "Byt namn på grupp",
"From Directory": "Från katalog",
"Successfully imported {{count}} book(s)_one": "Importerat 1 bok",
"Successfully imported {{count}} book(s)_other": "Importerat {{count}} böcker"
"Successfully imported {{count}} book(s)_other": "Importerat {{count}} böcker",
"Count": "Antal",
"Start Page": "Start sida",
"Search in OPDS Catalog...": "Sök i OPDS-katalog...",
"Please log in to use advanced TTS features.": "Logga in för att använda avancerade TTS-funktioner."
}
@@ -699,9 +699,7 @@
"Validating...": "சரிபார்க்கப்படுகிறது...",
"View All": "அனைத்தையும் பார்க்கவும்",
"Forward": "முன்னேற்று",
"OPDS Catalog": "OPDS பட்டியல்",
"Home": "முகப்பு",
"Library": "நூலகம்",
"{{count}} items_one": "{{count}} பொருள்",
"{{count}} items_other": "{{count}} பொருட்கள்",
"Download completed": "பதிவிறக்கம் முடிந்தது",
@@ -763,5 +761,9 @@
"Rename Group": "குழுவை மறுபெயரிடவும்",
"From Directory": "கோப்புறையிலிருந்து",
"Successfully imported {{count}} book(s)_one": "வெற்றிகரமாக 1 புத்தகம் இறக்குமதி செய்யப்பட்டது",
"Successfully imported {{count}} book(s)_other": "வெற்றிகரமாக {{count}} புத்தகங்கள் இறக்குமதி செய்யப்பட்டது"
"Successfully imported {{count}} book(s)_other": "வெற்றிகரமாக {{count}} புத்தகங்கள் இறக்குமதி செய்யப்பட்டது",
"Count": "எண்ணிக்கை",
"Start Page": "தொடக்கப் பக்கம்",
"Search in OPDS Catalog...": "OPDS பட்டியலில் தேடவும்...",
"Please log in to use advanced TTS features.": "மேம்பட்ட TTS அம்சங்களை பயன்படுத்த உள்நுழையவும்."
}
@@ -695,9 +695,7 @@
"Validating...": "กำลังตรวจสอบ...",
"View All": "ดูทั้งหมด",
"Forward": "ไปข้างหน้า",
"OPDS Catalog": "แคตตาล็อก OPDS",
"Home": "หน้าแรก",
"Library": "ห้องสมุด",
"{{count}} items_other": "{{count}} รายการ",
"Download completed": "ดาวน์โหลดเสร็จสิ้น",
"Download failed": "ดาวน์โหลดล้มเหลว",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "การใช้งานพื้นที่เก็บข้อมูลคลาวด์",
"Rename Group": "เปลี่ยนชื่อกลุ่ม",
"From Directory": "จากไดเรกทอรี",
"Successfully imported {{count}} book(s)_other": "นำเข้า {{count}} หนังสือเรียบร้อยแล้ว"
"Successfully imported {{count}} book(s)_other": "นำเข้า {{count}} หนังสือเรียบร้อยแล้ว",
"Count": "นับ",
"Start Page": "หน้าเริ่มต้น",
"Search in OPDS Catalog...": "ค้นหาในแคตตาล็อก OPDS...",
"Please log in to use advanced TTS features.": "กรุณาเข้าสู่ระบบเพื่อใช้ฟีเจอร์ TTS ขั้นสูง"
}
@@ -699,9 +699,7 @@
"Validating...": "Doğrulanıyor...",
"View All": "Tümünü Görüntüle",
"Forward": "İleri",
"OPDS Catalog": "OPDS Kataloğu",
"Home": "Ana Sayfa",
"Library": "Kütüphane",
"{{count}} items_one": "{{count}} öğe",
"{{count}} items_other": "{{count}} öğe",
"Download completed": "İndirme tamamlandı",
@@ -763,5 +761,9 @@
"Rename Group": "Grubu Yeniden Adlandır",
"From Directory": "Dizinden",
"Successfully imported {{count}} book(s)_one": "Başarıyla 1 kitap içe aktarıldı",
"Successfully imported {{count}} book(s)_other": "Başarıyla {{count}} kitap içe aktarıldı"
"Successfully imported {{count}} book(s)_other": "Başarıyla {{count}} kitap içe aktarıldı",
"Count": "Sayım",
"Start Page": "Başlangıç Sayfası",
"Search in OPDS Catalog...": "OPDS Kataloğunda ara...",
"Please log in to use advanced TTS features.": "Gelişmiş TTS özelliklerini kullanmak için lütfen giriş yapın."
}
@@ -707,9 +707,7 @@
"Validating...": "Перевірка...",
"View All": "Переглянути все",
"Forward": "Вперед",
"OPDS Catalog": "Каталог OPDS",
"Home": "Головна",
"Library": "Бібліотека",
"{{count}} items_one": "{{count}} елемент",
"{{count}} items_few": "{{count}} елементи",
"{{count}} items_many": "{{count}} елементів",
@@ -783,5 +781,9 @@
"Successfully imported {{count}} book(s)_one": "Успішно імпортовано 1 книгу",
"Successfully imported {{count}} book(s)_few": "Успішно імпортовано {{count}} книги",
"Successfully imported {{count}} book(s)_many": "Успішно імпортовано {{count}} книг",
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книг"
"Successfully imported {{count}} book(s)_other": "Успішно імпортовано {{count}} книг",
"Count": "Кількість",
"Start Page": "Початкова сторінка",
"Search in OPDS Catalog...": "Пошук у каталозі OPDS...",
"Please log in to use advanced TTS features.": "Будь ласка, увійдіть, щоб використовувати розширені функції TTS."
}
@@ -695,9 +695,7 @@
"Validating...": "Đang xác thực...",
"View All": "Xem tất cả",
"Forward": "Tiếp",
"OPDS Catalog": "Danh mục OPDS",
"Home": "Trang chủ",
"Library": "Thư viện",
"{{count}} items_other": "{{count}} mục",
"Download completed": "Tải xuống hoàn tất",
"Download failed": "Tải xuống thất bại",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "Sử dụng lưu trữ đám mây",
"Rename Group": "Đổi tên nhóm",
"From Directory": "Từ thư mục",
"Successfully imported {{count}} book(s)_other": "Đã nhập thành công {{count}} sách"
"Successfully imported {{count}} book(s)_other": "Đã nhập thành công {{count}} sách",
"Count": "Số lượng",
"Start Page": "Trang bắt đầu",
"Search in OPDS Catalog...": "Tìm kiếm trong danh mục OPDS...",
"Please log in to use advanced TTS features.": "Vui lòng đăng nhập để sử dụng các tính năng TTS nâng cao."
}
@@ -695,9 +695,7 @@
"Validating...": "验证中...",
"View All": "查看全部",
"Forward": "前进",
"OPDS Catalog": "OPDS 目录",
"Home": "首页",
"Library": "图书馆",
"{{count}} items_other": "{{count}} 项",
"Download completed": "下载完成",
"Download failed": "下载失败",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "云存储使用情况",
"Rename Group": "重命名分组",
"From Directory": "从文件夹导入",
"Successfully imported {{count}} book(s)_other": "成功导入 {{count}} 本书"
"Successfully imported {{count}} book(s)_other": "成功导入 {{count}} 本书",
"Count": "数量",
"Start Page": "起始页",
"Search in OPDS Catalog...": "在 OPDS 目录中搜索...",
"Please log in to use advanced TTS features.": "请登录以使用高级 TTS 功能"
}
@@ -695,9 +695,7 @@
"Validating...": "驗證中...",
"View All": "檢視全部",
"Forward": "前往",
"OPDS Catalog": "OPDS 目錄",
"Home": "首頁",
"Library": "圖書館",
"{{count}} items_other": "{{count}} 項",
"Download completed": "下載完成",
"Download failed": "下載失敗",
@@ -753,5 +751,9 @@
"Cloud Storage Usage": "雲端儲存使用情況",
"Rename Group": "重新命名",
"From Directory": "從目錄導入",
"Successfully imported {{count}} book(s)_other": "成功導入 {{count}} 本書"
"Successfully imported {{count}} book(s)_other": "成功導入 {{count}} 本書",
"Count": "數量",
"Start Page": "起始頁",
"Search in OPDS Catalog...": "在 OPDS 目錄中搜尋...",
"Please log in to use advanced TTS features.": "請登入以使用進階 TTS 功能"
}
+18
View File
@@ -1,5 +1,23 @@
{
"releases": {
"0.9.96": {
"date": "2025-12-19",
"notes": [
"TTS: Resolved an issue where Edge TTS was blocked in certain regions",
"OPDS: Improved compatibility with older versions of Calibre Web",
"OPDS: Added an instant search bar for faster browsing in OPDS catalogs",
"OPDS: Added a curated book catalog from Standard Ebooks",
"Bookshelf: Added a “Group Books” action to the context menu",
"Comics: Fixed layout issues to improve comic book reading",
"Layout: Footnote popups now adapt correctly to different screen sizes",
"Layout: Fixed an issue where the maximum inline width was not applied to EPUBs",
"Sync: Improved file downloading by correctly handling special characters in filenames",
"UI: Added an option to temporarily dismiss the reading progress bar",
"Settings: Screen brightness adjustments now apply only to the reader view",
"iOS: You can now open files directly in Readest from the Files app",
"macOS: Added a global menu option to open files with Readest"
]
},
"0.9.95": {
"date": "2025-12-08",
"notes": [
+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>
@@ -116,6 +116,7 @@
}
]
},
"websocket:default",
"dialog:default",
"os:default",
"core:window:default",
@@ -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 ->
@@ -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",
@@ -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"]
@@ -14,6 +14,7 @@ Default permissions for the plugin
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
- `allow-lock-screen-orientation`
- `allow-iap-is-available`
- `allow-iap-initialize`
- `allow-iap-fetch-products`
- `allow-iap-purchase-product`
@@ -409,6 +410,32 @@ Denies the iap_initialize command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-iap-is-available`
</td>
<td>
Enables the iap_is_available command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-is-available`
</td>
<td>
Denies the iap_is_available command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-purchase-product`
</td>
@@ -11,6 +11,7 @@ permissions = [
"allow-get-sys-fonts-list",
"allow-intercept-keys",
"allow-lock-screen-orientation",
"allow-iap-is-available",
"allow-iap-initialize",
"allow-iap-fetch-products",
"allow-iap-purchase-product",
@@ -462,6 +462,18 @@
"const": "deny-iap-initialize",
"markdownDescription": "Denies the iap_initialize command without any pre-configured scope."
},
{
"description": "Enables the iap_is_available command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-is-available",
"markdownDescription": "Enables the iap_is_available command without any pre-configured scope."
},
{
"description": "Denies the iap_is_available command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-is-available",
"markdownDescription": "Denies the iap_is_available command without any pre-configured scope."
},
{
"description": "Enables the iap_purchase_product command without any pre-configured scope.",
"type": "string",
@@ -655,10 +667,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}
@@ -84,6 +84,13 @@ pub(crate) async fn lock_screen_orientation<R: Runtime>(
app.native_bridge().lock_screen_orientation(payload)
}
#[command]
pub(crate) async fn iap_is_available<R: Runtime>(
app: AppHandle<R>,
) -> Result<IAPIsAvailableResponse> {
app.native_bridge().iap_is_available()
}
#[command]
pub(crate) async fn iap_initialize<R: Runtime>(
app: AppHandle<R>,
@@ -74,6 +74,10 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_initialize(
&self,
_payload: IAPInitializeRequest,
@@ -65,6 +65,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::get_sys_fonts_list,
commands::intercept_keys,
commands::lock_screen_orientation,
commands::iap_is_available,
commands::iap_initialize,
commands::iap_fetch_products,
commands::iap_purchase_product,
@@ -113,6 +113,14 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_is_available(&self) -> crate::Result<IAPIsAvailableResponse> {
self.0
.run_mobile_plugin("iap_is_available", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_initialize(
&self,
@@ -113,6 +113,12 @@ pub struct Purchase {
pub purchase_state: String, // "purchased", "pending", "cancelled", "restored"
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPIsAvailableResponse {
pub available: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPInitializeRequest {
+3 -2
View File
@@ -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();
}));
+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);
}
});
}
@@ -72,6 +72,7 @@
},
"iOS": {
"developmentTeam": "J5W48D69VR",
"infoPlist": "./Info-ios.plist",
"minimumSystemVersion": "14.0"
},
"fileAssociations": [
@@ -0,0 +1,390 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import ReplacementOptions from '@/app/reader/components/annotator/ReplacementOptions';
describe('ReplacementOptions Component', () => {
// IMPORTANT: ReplacementOptions should ONLY be rendered for EPUB books.
// for non-EPUB formats (PDF, TXT, etc), the button is disabled
// and ReplacementOptions is never rendered/shown to the user.
//
const mockOnConfirm = vi.fn();
const mockOnClose = vi.fn();
const defaultProps = {
isVertical: false,
style: { left: '100px', top: '100px' },
selectedText: 'test word',
onConfirm: mockOnConfirm,
onClose: mockOnClose,
};
// Note: ReplacementOptions component should only be rendered for EPUB books.
// All tests here implicitly test EPUB book scenarios.
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe('Rendering', () => {
it('should render all three replacement scope options', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Fix this once')).toBeTruthy();
expect(screen.getByText('Fix in this book')).toBeTruthy();
expect(screen.getByText('Fix in library')).toBeTruthy();
});
it('should render the replacement text input field', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
expect(input).toBeTruthy();
});
it('should render the Case Sensitive checkbox', () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Case Sensitive')).toBeTruthy();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
});
it('should render the Cancel button', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText('Cancel')).toBeTruthy();
});
it('should display selected text preview', () => {
render(<ReplacementOptions {...defaultProps} />);
expect(screen.getByText(/Selected:/)).toBeTruthy();
expect(screen.getByText(/"test word"/)).toBeTruthy();
});
it('should truncate long selected text in preview', () => {
const longText = 'a'.repeat(100);
render(<ReplacementOptions {...defaultProps} selectedText={longText} />);
// Should show truncated version with ellipsis
const preview = screen.getByText(/Selected:/);
expect(preview.parentElement?.textContent).toContain('...');
});
});
describe('Case Sensitive Checkbox', () => {
it('should be checked by default (case-sensitive)', () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it('should toggle when clicked', async () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
it('should pass case sensitivity value to onConfirm when checked', async () => {
render(<ReplacementOptions {...defaultProps} />);
// Enter replacement text
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
// Checkbox is checked by default (case sensitive = true)
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Should show confirmation dialog
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('Yes')).toBeTruthy(); // Case sensitive: Yes
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith({
replacementText: 'replacement',
caseSensitive: true,
scope: 'once',
});
});
it('should pass case sensitivity value to onConfirm when unchecked', async () => {
const { container } = render(<ReplacementOptions {...defaultProps} />);
// Enter replacement text
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
// Uncheck the checkbox (default is true, so we click to toggle to false)
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith({
replacementText: 'replacement',
caseSensitive: false,
scope: 'once',
});
});
});
describe('Replacement Text Input', () => {
it('should update value when user types', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'new text' } });
expect(input.value).toBe('new text');
});
it('should disable scope buttons when input is empty', () => {
render(<ReplacementOptions {...defaultProps} />);
const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement;
const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement;
const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement;
expect(fixOnceButton.disabled).toBe(true);
expect(fixInBookButton.disabled).toBe(true);
expect(fixInLibraryButton.disabled).toBe(true);
});
it('should enable scope buttons when input has text', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const fixOnceButton = screen.getByText('Fix this once') as HTMLButtonElement;
const fixInBookButton = screen.getByText('Fix in this book') as HTMLButtonElement;
const fixInLibraryButton = screen.getByText('Fix in library') as HTMLButtonElement;
expect(fixOnceButton.disabled).toBe(false);
expect(fixInBookButton.disabled).toBe(false);
expect(fixInLibraryButton.disabled).toBe(false);
});
it('should trim whitespace from replacement text', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: ' trimmed ' } });
// Click a scope button
const fixOnceButton = screen.getByText('Fix this once');
fireEvent.click(fixOnceButton);
// Confirm
const confirmButton = screen.getByText('Confirm');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalledWith(
expect.objectContaining({
replacementText: 'trimmed',
}),
);
});
});
describe('Scope Button Click Handlers', () => {
it('should show confirmation dialog when "Fix this once" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix this once');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('this instance')).toBeTruthy();
});
it('should show confirmation dialog when "Fix in this book" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix in this book');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('all instances in this book')).toBeTruthy();
});
it('should show confirmation dialog when "Fix in library" is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
const button = screen.getByText('Fix in library');
fireEvent.click(button);
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
expect(screen.getByText('all instances in your library')).toBeTruthy();
});
it('should call onConfirm with correct scope for "once"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'once' }));
});
it('should call onConfirm with correct scope for "book"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix in this book'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' }));
});
it('should call onConfirm with correct scope for "library"', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix in library'));
const confirmButtons = screen.getAllByText('Confirm');
if (!confirmButtons[0]) {
throw new Error('Confirm button not found');
}
fireEvent.click(confirmButtons[0]);
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' }));
});
});
describe('Confirmation Dialog', () => {
it('should display original text in confirmation', () => {
render(<ReplacementOptions {...defaultProps} selectedText='original' />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('"original"')).toBeTruthy();
});
it('should display replacement text in confirmation', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'new text' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('"new text"')).toBeTruthy();
});
it('should go back to main view when Back is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
expect(screen.getByText('Confirm Replacement')).toBeTruthy();
fireEvent.click(screen.getByText('Back'));
// Should be back to main view
expect(screen.queryByText('Confirm Replacement')).toBeNull();
expect(screen.getByText('Fix this once')).toBeTruthy();
});
it('should not call onConfirm when Back is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.change(input, { target: { value: 'replacement' } });
fireEvent.click(screen.getByText('Fix this once'));
fireEvent.click(screen.getByText('Back'));
expect(mockOnConfirm).not.toHaveBeenCalled();
});
});
describe('Cancel Button', () => {
it('should call onClose when Cancel is clicked', () => {
render(<ReplacementOptions {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('Click Outside Behavior', () => {
it('should call onClose when clicking outside the menu', () => {
render(
<div>
<div data-testid='outside'>Outside element</div>
<ReplacementOptions {...defaultProps} />
</div>,
);
const outsideElement = screen.getByTestId('outside');
fireEvent.mouseDown(outsideElement);
expect(mockOnClose).toHaveBeenCalled();
});
it('should not call onClose when clicking inside the menu', () => {
render(<ReplacementOptions {...defaultProps} />);
const input = screen.getByPlaceholderText('Enter replacement text...');
fireEvent.mouseDown(input);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import React from 'react';
import { vi } from 'vitest';
import {
ReplacementRulesWindow,
setReplacementRulesWindowVisible,
} from '@/app/reader/components/ReplacementRulesWindow';
import BookMenu from '@/app/reader/components/sidebar/BookMenu';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ReplacementRule } from '@/types/book';
// ------------------------------
// NEXT.JS ROUTER MOCK
// ------------------------------
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
toString: () => '',
}),
}));
// ------------------------------
// TRANSLATION MOCK
// ------------------------------
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
vi.mock('@/services/translators/cache', () => ({
initCache: vi.fn(),
loadCacheFromDB: vi.fn(),
pruneCache: vi.fn(),
}));
// ------------------------------
// ENV PROVIDER WRAPPER
// ------------------------------
// mock environment module so EnvProvider uses fake values
vi.mock('@/services/environment', async (importOriginal) => {
const actual = await importOriginal();
return {
...(typeof actual === 'object' && actual !== null ? actual : {}), // keep all real exports (e.g., isTauriAppPlatform)
default: {
...(typeof actual === 'object' &&
actual !== null &&
'default' in actual &&
typeof actual.default === 'object' &&
actual.default !== null
? actual.default
: {}), // keep all real default fields
API_BASE: 'http://localhost',
ENABLE_TRANSLATOR: false,
getAppService: vi.fn().mockResolvedValue(null),
},
};
});
import { EnvProvider } from '@/context/EnvContext';
function renderWithProviders(ui: React.ReactNode) {
return render(<EnvProvider>{ui}</EnvProvider>);
}
describe.skip('ReplacementRulesWindow', () => {
beforeEach(() => {
// Reset stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: {
enabled: false,
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({ viewStates: {} });
useSidebarStore.setState({ sideBarBookKey: null });
(useBookDataStore.setState as unknown as (state: unknown) => void)({ booksData: {} });
});
afterEach(() => {
cleanup();
});
it('renders book and global replacement rules from stores', async () => {
// Arrange: populate stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: {
replacementRules: [
{
id: 'g1',
pattern: 'foo',
replacement: 'bar',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
},
{
id: 'b1',
pattern: 'hello',
replacement: 'world',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
},
],
kosync: { enabled: false },
},
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
replacementRules: [],
},
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ReplacementRulesWindow />);
// wait a tick so the component's effect attaches the event listener
await Promise.resolve();
// open via helper which dispatches the custom event
setReplacementRulesWindowVisible(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Global rules
expect(screen.getByText('foo')).toBeTruthy();
expect(screen.getByText('bar')).toBeTruthy();
expect(screen.getByText('hello')).toBeTruthy();
expect(screen.getByText('world')).toBeTruthy();
});
it('renders single-instance rules separately from book/global rules', async () => {
// Arrange: populate stores with a single rule persisted in book config
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: { enabled: false },
},
});
const singleRule: ReplacementRule = {
id: 's1',
pattern: 'only-once',
replacement: 'single-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
singleInstance: true,
};
const bookRule: ReplacementRule = {
id: 'b1',
pattern: 'book-wide',
replacement: 'book-hit',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
};
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: {
viewSettings: {
replacementRules: [singleRule, bookRule],
},
},
},
});
(useBookDataStore.setState as unknown as (state: unknown) => void)({
booksData: {
book1: {
id: 'book1',
book: null,
file: null,
config: {
viewSettings: {
replacementRules: [singleRule, bookRule],
},
},
bookDoc: null,
isFixedLayout: false,
},
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Act: render and open dialog
renderWithProviders(<ReplacementRulesWindow />);
await Promise.resolve();
setReplacementRulesWindowVisible(true);
// Assert
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeTruthy();
// Single-instance section
expect(screen.getByText('Single Instance Rules')).toBeTruthy();
expect(screen.getByText('only-once')).toBeTruthy();
expect(screen.getByText('single-hit')).toBeTruthy();
// Book section should still show book-wide rule
expect(screen.getByText('book-wide')).toBeTruthy();
expect(screen.getByText('book-hit')).toBeTruthy();
});
it('opens when BookMenu item is clicked (integration)', async () => {
// Arrange stores
(useSettingsStore.setState as unknown as (state: unknown) => void)({
settings: {
globalViewSettings: { replacementRules: [] },
kosync: { enabled: false },
},
});
(useReaderStore.setState as unknown as (state: unknown) => void)({
viewStates: {
book1: { viewSettings: { replacementRules: [] } },
},
});
useSidebarStore.setState({ sideBarBookKey: 'book1' });
// Render both menu and window
renderWithProviders(
<div>
<BookMenu />
<ReplacementRulesWindow />
</div>,
);
// wait a tick so effects attach
await Promise.resolve();
// Click the menu item
const menuItem = screen.getByRole('menuitem', { name: 'Replacement Rules' });
fireEvent.click(menuItem);
// The dialog should open
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Replacement Rules')).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest';
import { getWordCount, isWordLimitExceeded } from '../../utils/wordLimit';
describe('Word Limit Feature', () => {
describe('getWordCount', () => {
it('should count single word correctly', () => {
expect(getWordCount('hello')).toBe(1);
});
it('should count multiple words correctly', () => {
expect(getWordCount('hello world')).toBe(2);
expect(getWordCount('the quick brown fox')).toBe(4);
});
it('should handle multiple spaces between words', () => {
expect(getWordCount('hello world')).toBe(2);
});
it('should handle leading and trailing spaces', () => {
expect(getWordCount(' hello world ')).toBe(2);
});
it('should handle newlines and tabs', () => {
expect(getWordCount('hello\nworld')).toBe(2);
expect(getWordCount('hello\tworld')).toBe(2);
expect(getWordCount('hello\n\t world')).toBe(2);
});
it('should return 0 for empty string', () => {
expect(getWordCount('')).toBe(0);
});
it('should return 0 for whitespace only', () => {
expect(getWordCount(' ')).toBe(0);
expect(getWordCount('\n\t ')).toBe(0);
});
it('should handle punctuation as part of words', () => {
expect(getWordCount("don't")).toBe(1);
expect(getWordCount('hello, world!')).toBe(2);
});
it('should count exactly 30 words', () => {
const thirtyWords = Array(30).fill('word').join(' ');
expect(getWordCount(thirtyWords)).toBe(30);
});
it('should count more than 30 words', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(getWordCount(thirtyOneWords)).toBe(31);
});
});
describe('isWordLimitExceeded', () => {
it('should return false for text under 30 words', () => {
expect(isWordLimitExceeded('hello world')).toBe(false);
expect(isWordLimitExceeded('a')).toBe(false);
});
it('should return false for exactly 30 words', () => {
const thirtyWords = Array(30).fill('word').join(' ');
expect(isWordLimitExceeded(thirtyWords)).toBe(false);
});
it('should return true for 31 words', () => {
const thirtyOneWords = Array(31).fill('word').join(' ');
expect(isWordLimitExceeded(thirtyOneWords)).toBe(true);
});
it('should return true for many words', () => {
const manyWords = Array(100).fill('word').join(' ');
expect(isWordLimitExceeded(manyWords)).toBe(true);
});
it('should return false for empty string', () => {
expect(isWordLimitExceeded('')).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle very long words', () => {
const longWord = 'a'.repeat(1000);
expect(getWordCount(longWord)).toBe(1);
expect(isWordLimitExceeded(longWord)).toBe(false);
});
it('should handle mixed content with newlines', () => {
const text = `Line one with words.
Line two with more words.
Line three.`;
// "Line one with words." = 4, "Line two with more words." = 5, "Line three." = 2 = 11 total
expect(getWordCount(text)).toBe(11);
expect(isWordLimitExceeded(text)).toBe(false);
});
it('should handle unicode characters', () => {
expect(getWordCount('你好 世界')).toBe(2);
expect(getWordCount('café résumé')).toBe(2);
expect(getWordCount('🎉 hello 🎊 world')).toBe(4);
});
it('should handle numbers as words', () => {
expect(getWordCount('1 2 3 4 5')).toBe(5);
expect(getWordCount('chapter 1 section 2')).toBe(4);
});
});
});
describe('Case Sensitivity Matching', () => {
// Helper function to simulate matching logic
const matchText = (text: string, pattern: string, caseSensitive: boolean): boolean => {
if (caseSensitive) {
return text === pattern;
}
return text.toLowerCase() === pattern.toLowerCase();
};
describe('Case-Sensitive Mode', () => {
it('should match exact case only', () => {
expect(matchText('Where', 'Where', true)).toBe(true);
});
it('should not match different case', () => {
expect(matchText('where', 'Where', true)).toBe(false);
expect(matchText('WHERE', 'Where', true)).toBe(false);
expect(matchText('wHeRe', 'Where', true)).toBe(false);
});
it('should handle all uppercase pattern', () => {
expect(matchText('HELLO', 'HELLO', true)).toBe(true);
expect(matchText('hello', 'HELLO', true)).toBe(false);
});
it('should handle all lowercase pattern', () => {
expect(matchText('hello', 'hello', true)).toBe(true);
expect(matchText('Hello', 'hello', true)).toBe(false);
});
});
describe('Case-Insensitive Mode', () => {
it('should match same case', () => {
expect(matchText('where', 'where', false)).toBe(true);
});
it('should match different cases', () => {
expect(matchText('where', 'Where', false)).toBe(true);
expect(matchText('Where', 'where', false)).toBe(true);
expect(matchText('WHERE', 'where', false)).toBe(true);
expect(matchText('wHeRe', 'where', false)).toBe(true);
});
it('should match title case to lowercase', () => {
expect(matchText('The', 'the', false)).toBe(true);
});
it('should match with mixed input', () => {
expect(matchText('HeLLo', 'HELLO', false)).toBe(true);
expect(matchText('HeLLo', 'hello', false)).toBe(true);
});
});
describe('Real-world examples', () => {
it('should handle "the" in different cases', () => {
const pattern = 'the';
// Case-insensitive (default behavior)
expect(matchText('The', pattern, false)).toBe(true);
expect(matchText('the', pattern, false)).toBe(true);
expect(matchText('THE', pattern, false)).toBe(true);
// Case-sensitive
expect(matchText('The', pattern, true)).toBe(false);
expect(matchText('the', pattern, true)).toBe(true);
expect(matchText('THE', pattern, true)).toBe(false);
});
it('should handle proper nouns correctly when case-sensitive', () => {
const pattern = 'John';
// Case-sensitive - only exact match
expect(matchText('John', pattern, true)).toBe(true);
expect(matchText('john', pattern, true)).toBe(false);
expect(matchText('JOHN', pattern, true)).toBe(false);
});
});
});
@@ -1,3 +1,4 @@
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { NextRequest, NextResponse } from 'next/server';
async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
@@ -35,7 +36,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
const headers: HeadersInit = {
'User-Agent': 'Readest/1.0 (OPDS Browser)',
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*',
};
@@ -124,6 +125,7 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Expose-Headers': 'X-Content-Length',
},
});
} else {
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { validateUserAndToken } from '@/utils/access';
const getLangFromVoice = (voiceId: string): string => {
const match = voiceId.match(/^([a-z]{2}-[A-Z]{2})/);
return match ? match[1]! : 'en-US';
};
const isValidVoice = (voiceId: string): boolean => {
return EdgeSpeechTTS.voices.some((v) => v.id === voiceId);
};
export async function POST(request: NextRequest) {
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
}
try {
const body = await request.json();
const { input: text, voice, speed = 1.0 } = body;
let { rate, lang } = body;
if (!text || typeof text !== 'string') {
return NextResponse.json(
{ error: { message: 'Missing or invalid "input" field', type: 'invalid_request_error' } },
{ status: 400 },
);
}
if (!voice || typeof voice !== 'string') {
return NextResponse.json(
{ error: { message: 'Missing or invalid "voice" field', type: 'invalid_request_error' } },
{ status: 400 },
);
}
if (!isValidVoice(voice)) {
return NextResponse.json(
{
error: {
message: `Invalid voice "${voice}". Use GET /api/tts/edge to list available voices.`,
type: 'invalid_request_error',
},
},
{ status: 400 },
);
}
lang = lang || getLangFromVoice(voice);
// Calculate rate (OpenAI speed ranges from 0.25 to 4.0, Edge TTS rate is 0.5 to 2.0)
const clampedSpeed = Math.max(0.25, Math.min(4.0, speed));
let mappedSpeed: number;
if (clampedSpeed <= 1.0) {
mappedSpeed = 0.5 + ((clampedSpeed - 0.25) / (1.0 - 0.25)) * (1.0 - 0.5);
} else {
mappedSpeed = 1.0 + ((clampedSpeed - 1.0) / (4.0 - 1.0)) * (2.0 - 1.0);
}
rate = rate || mappedSpeed;
const payload: EdgeTTSPayload = {
lang,
text,
voice,
rate,
pitch: 1.0,
};
const tts = new EdgeSpeechTTS();
const response = await tts.create(payload);
const arrayBuffer = await response.arrayBuffer();
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'audio/mpeg',
'Content-Length': arrayBuffer.byteLength.toString(),
},
});
} catch (error) {
console.error('Edge TTS API error:', error);
return NextResponse.json(
{
error: {
message: error instanceof Error ? error.message : 'Internal server error',
type: 'internal_error',
},
},
{ status: 500 },
);
}
}
export async function GET(request: NextRequest) {
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
}
try {
const query = request.nextUrl.searchParams;
const lang = query.get('lang') || '';
let voices = EdgeSpeechTTS.voices;
if (lang) {
voices = voices.filter((v) => v.lang.toLowerCase().includes(lang.toLowerCase()));
}
const formattedVoices = voices.map((voice) => ({
id: voice.id,
name: voice.name,
language: voice.lang,
}));
return NextResponse.json({
voices: formattedVoices,
});
} catch (error) {
console.error('Error listing voices:', error);
return NextResponse.json(
{
error: {
message: 'Failed to list voices',
type: 'internal_error',
},
},
{ status: 500 },
);
}
}
@@ -304,6 +304,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleGroupBooks={groupSelectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
@@ -81,6 +81,7 @@ interface BookshelfItemProps {
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleGroupBooks: () => void;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
@@ -97,6 +98,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
transferProgress,
setLoading,
toggleSelection,
handleGroupBooks,
handleBookUpload,
handleBookDownload,
handleSetSelectMode,
@@ -188,6 +190,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
toggleSelection(book.hash);
},
});
const groupBooksMenuItem = await MenuItem.new({
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
toggleSelection(book.hash);
}
handleGroupBooks();
},
});
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
@@ -221,6 +233,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
const menu = await Menu.new();
menu.append(selectBookMenuItem);
menu.append(groupBooksMenuItem);
menu.append(showBookDetailsMenuItem);
menu.append(showBookInFinderMenuItem);
if (book.uploadedAt && !book.downloadedAt) {
@@ -242,6 +255,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
toggleSelection(group.id);
},
});
const groupBooksMenuItem = await MenuItem.new({
text: _('Group Books'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
if (!itemSelected) {
toggleSelection(group.id);
}
handleGroupBooks();
},
});
const deleteGroupMenuItem = await MenuItem.new({
text: _('Delete'),
action: async () => {
@@ -250,6 +273,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
});
const menu = await Menu.new();
menu.append(selectGroupMenuItem);
menu.append(groupBooksMenuItem);
menu.append(deleteGroupMenuItem);
menu.popup();
};
+17 -4
View File
@@ -93,7 +93,9 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { clearBookData } = useBookDataStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const [showCatalogManager, setShowCatalogManager] = useState(false);
const [showCatalogManager, setShowCatalogManager] = useState(
searchParams?.get('opds') === 'true',
);
const [loading, setLoading] = useState(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
const [isSelectMode, setIsSelectMode] = useState(false);
@@ -270,6 +272,17 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return false;
};
const handleShowOPDSDialog = () => {
setShowCatalogManager(true);
};
const handleDismissOPDSDialog = () => {
setShowCatalogManager(false);
const params = new URLSearchParams(searchParams?.toString());
params.delete('opds');
navigateToLibrary(router, `${params.toString()}`);
};
useEffect(() => {
if (pendingNavigationBookIds) {
const bookIds = pendingNavigationBookIds;
@@ -323,7 +336,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const handleOpenWithBooks = async (appService: AppService, library: Book[]) => {
const openWithFiles = (await parseOpenWithFiles()) || [];
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
if (openWithFiles.length > 0) {
return await processOpenWithFiles(appService, openWithFiles, library);
@@ -716,7 +729,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onOpenCatalogManager={() => setShowCatalogManager(true)}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
@@ -847,7 +860,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
<UpdaterWindow />
<MigrateDataWindow />
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
{showCatalogManager && <CatalogDialog onClose={() => setShowCatalogManager(false)} />}
{showCatalogManager && <CatalogDialog onClose={handleDismissOPDSDialog} />}
<Toast />
</div>
);
@@ -21,6 +21,13 @@ const POPULAR_CATALOGS: OPDSCatalog[] = [
description: "World's largest collection of free ebooks",
icon: '🏛️',
},
{
id: 'standardebooks',
name: 'Standard Ebooks',
url: 'https://standardebooks.org/feeds/opds',
description: 'Free and liberated ebooks, carefully produced for the true book lover',
icon: '📚',
},
{
id: 'manybooks',
name: 'ManyBooks',
@@ -28,20 +35,12 @@ const POPULAR_CATALOGS: OPDSCatalog[] = [
description: 'Over 50,000 free ebooks',
icon: '📖',
},
{
id: 'standardebooks',
name: 'Standard Ebooks',
url: 'https://standardebooks.org/feeds/opds',
description: 'Carefully formatted and lovingly produced free ebooks',
icon: '📚',
disabled: true,
},
{
id: 'unglue.it',
name: 'Unglue.it',
url: 'https://unglue.it/api/opds/',
description: 'Free ebooks from authors who have "unglued" their books',
icon: '📚',
icon: '🔓',
},
];
@@ -192,7 +191,7 @@ export function CatalogManager() {
<div className='flex items-center justify-between'>
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center justify-between'>
<h3 className='card-title truncate text-sm'>
<h3 className='card-title line-clamp-1 text-sm'>
{catalog.icon && <span className=''>{catalog.icon}</span>}
{catalog.name}
</h3>
@@ -209,7 +208,7 @@ export function CatalogManager() {
{catalog.description}
</p>
)}
<p className='text-base-content/50 truncate text-xs'>{catalog.url}</p>
<p className='text-base-content/50 line-clamp-1 text-xs'>{catalog.url}</p>
{catalog.username && (
<p className='text-base-content/50 mt-1 text-xs'>
{_('Username')}: {catalog.username}
@@ -1,6 +1,7 @@
'use client';
import { useMemo } from 'react';
import { useMemo, useCallback } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { OPDSFeed, OPDSLink } from '@/types/opds';
@@ -18,9 +19,9 @@ interface FeedViewProps {
isOPDSCatalog: (type?: string) => boolean;
}
const gridClassName = 'grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
const gridClassName = 'grid grid-cols-3 gap-4 px-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
const navigationClassName =
'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
'grid grid-cols-2 gap-4 px-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
export function FeedView({
feed,
@@ -51,21 +52,34 @@ export function FeedView({
onNavigate(url);
};
const itemContent = useCallback(
(index: number) => (
<PublicationCard
publication={feed.publications![index]!}
baseURL={baseURL}
onClick={() => onPublicationSelect(-1, index)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
),
[feed.publications, baseURL, onPublicationSelect, resolveURL, onGenerateCachedImageUrl],
);
return (
<div className='container mx-auto max-w-7xl px-4 py-6'>
<div className='flex h-full flex-col'>
{/* Header */}
<div className='opds-header mb-6'>
<div className='opds-header flex-shrink-0 px-4 py-6'>
{feed.metadata?.title && <h1 className='mb-2 text-xl font-bold'>{feed.metadata.title}</h1>}
{feed.metadata?.subtitle && (
<p className='text-base-content/70 text-sm'>{feed.metadata.subtitle}</p>
)}
</div>
<div className='flex gap-6'>
<div className='flex min-h-0 flex-1 gap-6'>
{/* Facets Sidebar */}
{hasFacets && (
<aside className='hidden w-64 flex-shrink-0 lg:block'>
<div className='sticky top-6'>
<aside className='hidden w-64 flex-shrink-0 overflow-y-auto lg:block'>
<div className='px-4'>
<div className='mb-4 flex items-center gap-2'>
<IoFilter className='h-5 w-5' />
<h2 className='text-lg font-semibold'>Filters</h2>
@@ -113,10 +127,10 @@ export function FeedView({
)}
{/* Main Content */}
<div className='min-w-0 flex-1'>
<div className='flex min-w-0 flex-1 flex-col'>
{/* Navigation Items */}
{feed.navigation && feed.navigation.length > 0 && (
<section className='opds-navigation mb-8'>
<section className='opds-navigation flex-shrink-0 pb-6'>
<div className={navigationClassName}>
{feed.navigation.map((item, index: number) => (
<NavigationCard
@@ -131,29 +145,23 @@ export function FeedView({
</section>
)}
{/* Publications */}
{/* Publications Grid - Takes remaining space */}
{feed.publications && feed.publications.length > 0 && (
<section className='opds-publications mb-8'>
<div className={gridClassName}>
{feed.publications.map((pub, index: number) => (
<PublicationCard
key={index}
publication={pub}
baseURL={baseURL}
onClick={() => onPublicationSelect(-1, index)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
))}
</div>
<section className='opds-publications min-h-0 flex-1'>
<VirtuosoGrid
style={{ height: '100%' }}
totalCount={feed.publications.length}
listClassName={gridClassName}
itemContent={itemContent}
/>
</section>
)}
{/* Groups */}
{feed.groups?.map((group, groupIndex: number) => (
<section key={groupIndex} className='mb-12'>
<section key={groupIndex} className='mb-12 flex-shrink-0'>
{group.metadata && (
<div className='mb-4 flex items-center justify-between'>
<div className='mb-4 flex items-center justify-between px-4'>
<h2 className='text-2xl font-bold'>{group.metadata.title}</h2>
{group.links && group.links.length > 0 && (
<button
@@ -203,7 +211,7 @@ export function FeedView({
{/* Pagination */}
{pagination.some((links) => links && links.length > 0) && (
<nav className='mt-8 flex justify-center gap-2'>
<nav className='flex flex-shrink-0 justify-center gap-2 py-4'>
<button
onClick={() => handlePaginationClick(pagination[0])}
disabled={!pagination[0]}
@@ -1,32 +1,35 @@
'use client';
import clsx from 'clsx';
import { useCallback } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { GiBookshelf } from 'react-icons/gi';
import { IoChevronBack, IoChevronForward, IoHome, IoSearch } from 'react-icons/io5';
import { FaSearch } from 'react-icons/fa';
import { IoMdCloseCircle } from 'react-icons/io';
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useSettingsStore } from '@/store/settingsStore';
import { navigateToLibrary } from '@/utils/nav';
import { debounce } from '@/utils/debounce';
import WindowButtons from '@/components/WindowButtons';
interface NavigationProps {
currentURL: string;
startURL?: string;
onNavigate: (url: string) => void;
searchTerm?: string;
onBack?: () => void;
onForward?: () => void;
onSearch: () => void;
onGoStart: () => void;
onSearch: (queryTerm: string) => void;
canGoBack: boolean;
canGoForward: boolean;
hasSearch: boolean;
}
export function Navigation({
startURL,
onNavigate,
searchTerm,
onBack,
onForward,
onGoStart,
onSearch,
canGoBack,
canGoForward,
@@ -35,70 +38,127 @@ export function Navigation({
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const viewSettings = settings.globalViewSettings;
const inputRef = useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const { isTrafficLightVisible } = useTrafficLight();
const handleGoHome = useCallback(() => {
if (startURL) {
onNavigate(startURL);
useEffect(() => {
setSearchQuery(searchTerm || '');
}, [searchTerm]);
useEffect(() => {
if (hasSearch && inputRef.current) {
inputRef.current.focus();
}
}, [startURL, onNavigate]);
}, [hasSearch]);
const handleGoLibrary = useCallback(() => {
navigateToLibrary(router, '', {}, true);
navigateToLibrary(router, 'opds=true', {}, true);
}, [router]);
const handleSearch = useCallback(() => {
onSearch();
}, [onSearch]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdateQueryParam = useCallback(
debounce((value: string) => {
if (value) {
onSearch(value);
}
}, 1000),
[onSearch],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
debouncedUpdateQueryParam(newQuery);
};
return (
<header
className={clsx(
'navbar min-h-0 px-2',
'flex h-[48px] w-full items-center',
appService?.isMobile ? '' : 'border-base-300 bg-base-200 border-b',
appService?.isMobile ? '' : 'bg-base-100',
)}
>
<div className={clsx('navbar-start gap-1', isTrafficLightVisible && '!pl-16')}>
{onBack && (
<button
className='btn btn-ghost btn-sm px-3 disabled:bg-transparent'
onClick={onBack}
disabled={!canGoBack}
title={_('Back')}
>
<IoChevronBack className='h-6 w-6' />
</button>
)}
{onForward && (
<button
className='btn btn-ghost btn-sm disabled:bg-transparent'
onClick={onForward}
disabled={!canGoForward}
title={_('Forward')}
>
<IoChevronForward className='h-6 w-6' />
</button>
)}
</div>
<div className='navbar-center'>
<h1 className='max-w-md truncate text-base font-semibold'>{_('OPDS Catalog')}</h1>
</div>
<div className='navbar-end gap-2'>
{hasSearch && (
<button className='btn btn-ghost btn-sm' onClick={handleSearch} title={_('Search')}>
<IoSearch className='h-5 w-5' />
</button>
)}
<button className='btn btn-ghost btn-sm' onClick={handleGoHome} title={_('Home')}>
<div className={clsx('justify-start gap-1 sm:gap-3', isTrafficLightVisible && '!pl-16')}>
<div className='flex gap-1'>
{onBack && (
<button
className='btn btn-ghost btn-sm px-1 disabled:bg-transparent'
onClick={onBack}
disabled={!canGoBack}
title={_('Back')}
>
<IoChevronBack className='h-6 w-6' />
</button>
)}
{onForward && (
<button
className='btn btn-ghost btn-sm px-1 disabled:bg-transparent'
onClick={onForward}
disabled={!canGoForward}
title={_('Forward')}
>
<IoChevronForward className='h-6 w-6' />
</button>
)}
</div>
<button className='btn btn-ghost btn-sm px-1' onClick={onGoStart} title={_('Home')}>
<IoHome className='h-5 w-5' />
</button>
<button className='btn btn-ghost btn-sm' onClick={handleGoLibrary} title={_('Library')}>
<GiBookshelf className='h-5 w-5' />
</button>
</div>
<div className='flex-grow px-3 sm:px-5'>
<div className='relative flex w-full items-center'>
<span className='text-base-content/50 absolute left-3'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
ref={inputRef}
value={searchQuery}
placeholder={_('Search in OPDS Catalog...')}
disabled={!hasSearch}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge h-9 w-full pl-10 pr-4 sm:h-7',
viewSettings?.isEink
? 'border-1 border-base-content focus:border-base-content'
: 'bg-base-300/45 border-none',
'font-sans text-sm font-light',
'placeholder:text-base-content/50 truncate',
'focus:outline-none focus:ring-0',
)}
/>
<div className='text-base-content/50 absolute right-2 flex items-center space-x-2 sm:space-x-4'>
{searchQuery && (
<button
type='button'
onClick={() => {
setSearchQuery('');
onGoStart();
}}
className='text-base-content/40 hover:text-base-content/60 pe-1'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
</button>
)}
</div>
</div>
</div>
<div className='justify-end gap-2 px-1'>
<WindowButtons
className='window-buttons flex h-full items-center'
onClose={() => {
handleGoLibrary();
}}
/>
</div>
</header>
);
@@ -38,7 +38,6 @@ export function PublicationView({
const _ = useTranslation();
const router = useRouter();
const [downloading, setDownloading] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const [downloadedBook, setDownloadedBook] = useState<Book | null>(null);
const [progress, setProgress] = useState<number | null>(null);
@@ -79,7 +78,6 @@ export function PublicationView({
}
setDownloading(true);
setDownloaded(false);
setProgress(null);
try {
@@ -150,13 +148,13 @@ export function PublicationView({
<div className='flex flex-wrap gap-2'>
{acquisitionLinks.map(({ rel, links }) => (
<div key={rel} className='flex gap-1'>
{links.length === 1 ? (
{links.length === 1 || downloadedBook ? (
<button
onClick={() => handleActionButton(links[0]!.href, links[0]!.type)}
disabled={downloading}
className={clsx(
'btn btn-primary min-w-20 rounded-3xl',
downloaded && 'btn-success',
downloadedBook && 'btn-success',
)}
>
{downloadedBook ? _('Open & Read') : getAcquisitionLabel(rel)}
@@ -173,7 +171,7 @@ export function PublicationView({
tabIndex={0}
className={clsx(
`btn btn-primary min-w-20 rounded-3xl ${downloading ? 'btn-disabled' : ''}`,
downloaded && 'btn-success',
downloadedBook && 'btn-success',
)}
>
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
@@ -17,7 +17,13 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
search.params?.forEach((param) => {
initial[param.name] = param.value || '';
if (param.name === 'count') {
initial[param.name] = '20';
} else if (param.name === 'startPage') {
initial[param.name] = '1';
} else {
initial[param.name] = param.value || '';
}
});
return initial;
});
@@ -56,6 +62,8 @@ export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchVi
publisher: _('Publisher'),
language: _('Language'),
subject: _('Subject'),
count: _('Count'),
startPage: _('Start Page'),
};
return labels[name] || name;
};
+104 -54
View File
@@ -21,6 +21,7 @@ import { getFileExtFromMimeType } from '@/libs/document';
import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
import { isSearchLink, MIME, parseMediaType, resolveURL } from './utils/opdsUtils';
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { FeedView } from './components/FeedView';
import { PublicationView } from './components/PublicationView';
import { SearchView } from './components/SearchView';
@@ -66,12 +67,15 @@ export default function BrowserPage() {
const [historyIndex, setHistoryIndex] = useState(-1);
const searchParams = useSearchParams();
const catalogUrl = searchParams?.get('url') || '';
const catalogId = searchParams?.get('id') || '';
const usernameRef = useRef<string | null | undefined>(undefined);
const passwordRef = useRef<string | null | undefined>(undefined);
const startURLRef = useRef<string | null | undefined>(undefined);
const loadingOPDSRef = useRef(false);
const historyIndexRef = useRef(-1);
const isNavigatingHistoryRef = useRef(false);
const searchTermRef = useRef('');
useTheme({ systemUIVisible: false });
@@ -102,6 +106,40 @@ export default function BrowserPage() {
[],
);
const quickSearch = useCallback((search: OPDSSearch, baseURL: string, searchTerms: string) => {
if (searchTerms) {
const formData: Record<string, string> = {};
search.params?.forEach((param) => {
if (param.name === 'count') {
formData[param.name] = '20';
} else if (param.name === 'startPage') {
formData[param.name] = '1';
} else if (param.name === 'searchTerms') {
formData[param.name] = searchTerms;
} else {
formData[param.name] = param.value || '';
}
});
const map = new Map<string | null, Map<string | null, string>>();
for (const param of search.params || []) {
const value = formData[param.name] || '';
const ns = param.ns ?? null;
if (map.has(ns)) {
map.get(ns)!.set(param.name, value);
} else {
map.set(ns, new Map([[param.name, value]]));
}
}
const searchURL = search.search(map);
const resolvedURL = resolveURL(searchURL, baseURL);
handleNavigate(resolvedURL, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadOPDS = useCallback(
async (url: string, options: { skipHistory?: boolean; isSearch?: boolean } = {}) => {
const { skipHistory = false, isSearch = false } = options;
@@ -193,9 +231,12 @@ export default function BrowserPage() {
startURL: currentStartURL || responseURL,
};
setState(newState);
setViewMode('search');
setSelectedPublication(null);
if (searchTermRef.current) {
quickSearch(search, responseURL, searchTermRef.current);
} else {
setViewMode('search');
setSelectedPublication(null);
}
if (!skipHistory) {
addToHistory(url, newState, 'search', null);
}
@@ -248,13 +289,12 @@ export default function BrowserPage() {
loadingOPDSRef.current = false;
}
},
[_, router, addToHistory],
[_, router, quickSearch, addToHistory],
);
useEffect(() => {
const url = searchParams?.get('url');
const url = catalogUrl;
if (url && !isNavigatingHistoryRef.current) {
const catalogId = searchParams?.get('id') || '';
const catalog = settings.opdsCatalogs?.find((cat) => cat.id === catalogId);
const { username, password } = catalog || {};
if (username || password) {
@@ -273,7 +313,7 @@ export default function BrowserPage() {
setViewMode('error');
setError(new Error('No OPDS URL provided'));
}
}, [searchParams, settings, libraryLoaded, loadOPDS]);
}, [catalogUrl, catalogId, settings, libraryLoaded, loadOPDS]);
const handleNavigate = useCallback(
(url: string, isSearch = false) => {
@@ -289,50 +329,61 @@ export default function BrowserPage() {
return !!state.feed?.links?.find(isSearchLink);
}, [state.feed]);
const handleSearch = useCallback(() => {
if (!state.feed) return;
const searchLink = state.feed.links?.find(isSearchLink);
if (searchLink && searchLink.href) {
const searchURL = resolveURL(searchLink.href, state.baseURL);
if (searchLink.type === MIME.OPENSEARCH) {
handleNavigate(searchURL, true);
} else if (searchLink.type === MIME.ATOM) {
const search: OPDSSearch = {
metadata: {
title: _('Search'),
description: state.feed.metadata?.title
? _('Search in {{title}}', { title: state.feed.metadata.title })
: undefined,
},
params: [
{
name: 'searchTerms',
required: true,
},
],
search: (map: Map<string | null, Map<string | null, string>>) => {
const defaultParams = map.get(null);
const searchTerms = defaultParams?.get('searchTerms') || '';
const decodedURL = decodeURIComponent(searchURL);
return decodedURL.replace('{searchTerms}', encodeURIComponent(searchTerms));
},
};
const newState: OPDSState = {
feed: state.feed,
search,
baseURL: state.baseURL,
currentURL: state.currentURL,
startURL: state.startURL,
};
setState(newState);
setViewMode('search');
setSelectedPublication(null);
addToHistory(state.currentURL, newState, 'search', null);
}
const handleGoStart = useCallback(() => {
if (startURLRef.current) {
handleNavigate(startURLRef.current);
}
}, [_, state, handleNavigate, addToHistory]);
searchTermRef.current = '';
}, [startURLRef, handleNavigate]);
const handleSearch = useCallback(
(queryTerm: string) => {
if (!state.feed) return;
searchTermRef.current = queryTerm;
const searchLink = state.feed.links?.find(isSearchLink);
if (searchLink && searchLink.href) {
const searchURL = resolveURL(searchLink.href, state.baseURL);
if (searchLink.type === MIME.OPENSEARCH) {
handleNavigate(searchURL, true);
} else if (searchLink.type === MIME.ATOM) {
const search: OPDSSearch = {
metadata: {
title: _('Search'),
description: state.feed.metadata?.title
? _('Search in {{title}}', { title: state.feed.metadata.title })
: undefined,
},
params: [
{
name: 'searchTerms',
required: true,
},
],
search: (map: Map<string | null, Map<string | null, string>>) => {
const defaultParams = map.get(null);
const searchTerms = defaultParams?.get('searchTerms') || '';
const decodedURL = decodeURIComponent(searchURL);
return decodedURL.replace('{searchTerms}', encodeURIComponent(searchTerms));
},
};
const newState: OPDSState = {
feed: state.feed,
search,
baseURL: state.baseURL,
currentURL: state.currentURL,
startURL: state.startURL,
};
setState(newState);
setSelectedPublication(null);
setViewMode('search');
addToHistory(state.currentURL, newState, 'search', null);
}
}
},
[_, state, handleNavigate, addToHistory],
);
const handleDownload = useCallback(
async (
@@ -362,7 +413,7 @@ export default function BrowserPage() {
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
const headers: Record<string, string> = {
'User-Agent': 'Readest/1.0 (OPDS Browser)',
'User-Agent': READEST_OPDS_USER_AGENT,
};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy);
@@ -517,11 +568,10 @@ export default function BrowserPage() {
}}
>
<Navigation
currentURL={state.currentURL}
startURL={state.startURL}
onNavigate={handleNavigate}
searchTerm={searchTermRef.current}
onBack={handleBack}
onForward={handleForward}
onGoStart={handleGoStart}
onSearch={handleSearch}
canGoBack={canGoBack}
canGoForward={canGoForward}
+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>),
};
@@ -131,7 +131,7 @@ const FoliateViewer: React.FC<{
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(width, height, data);
return transformStylesheet(data, width, height, viewSettings.vertical);
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
const ctx: TransformContext = {
bookKey,
@@ -149,7 +149,9 @@ const FoliateViewer: React.FC<{
'language',
'sanitizer',
'simplecc',
'replacement',
],
sectionHref: detail.name, // Pass section href for single-instance replacements
};
return Promise.resolve(transformContent(ctx));
}
@@ -107,11 +107,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
const { renderer } = view as FoliateView;
const viewSettings = getViewSettings(bookKey)!;
if (viewSettings.vertical) {
setResponsiveWidth(getResponsivePopupSize(renderer.viewSize, true));
setResponsiveHeight(clipPopupHeight(popupHeight));
setResponsiveWidth(clipPopupWith(getResponsivePopupSize(renderer.viewSize, true)));
} else {
setResponsiveWidth(clipPopupWith(popupWidth));
setResponsiveHeight(getResponsivePopupSize(renderer.viewSize, false));
setResponsiveHeight(clipPopupHeight(getResponsivePopupSize(renderer.viewSize, false)));
}
setShowPopup(true);
});
@@ -135,9 +133,9 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
useEffect(() => {
if (viewSettings.vertical) {
setResponsiveWidth(clipPopupWith(popupHeight));
setResponsiveHeight(Math.max(popupWidth, window.innerHeight / 4));
setResponsiveHeight(clipPopupHeight(Math.max(popupWidth, window.innerHeight / 4)));
} else {
setResponsiveWidth(Math.max(popupWidth, window.innerWidth / 4));
setResponsiveWidth(clipPopupWith(Math.max(popupWidth, window.innerWidth / 4)));
setResponsiveHeight(clipPopupHeight(popupHeight));
}
}, [viewSettings]);
@@ -90,11 +90,11 @@ const HintInfo: React.FC<SectionInfoProps> = ({
right: showDoubleBorder
? `calc(${contentInsets.right}px)`
: `calc(${Math.max(0, contentInsets.right - 32)}px)`,
width: showDoubleBorder ? '30px' : `${horizontalGap}%`,
width: showDoubleBorder ? '30px' : `${contentInsets.right}px`,
}
: {
top: `${topInset}px`,
insetInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
insetInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right / 2}px)`,
}
}
>
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Insets } from '@/types/misc';
import { PageInfo, TimeInfo } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
@@ -7,6 +7,7 @@ import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { formatNumber, formatProgress } from '@/utils/progress';
import { saveViewSettings } from '@/helpers/settings';
interface PageInfoProps {
bookKey: string;
@@ -28,7 +29,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { envConfig, appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
@@ -70,15 +71,59 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
})
: '';
const [progressInfoMode, setProgressInfoMode] = useState(viewSettings.progressInfoMode);
const cycleProgressInfoModes = () => {
const hasRemainingInfo = viewSettings.showRemainingTime || viewSettings.showRemainingPages;
const hasProgressInfo = viewSettings.showProgressInfo;
const modeSequence: (typeof progressInfoMode)[] = ['all', 'remaining', 'progress', 'none'];
const currentIndex = modeSequence.indexOf(progressInfoMode);
for (let i = 1; i <= modeSequence.length; i++) {
const nextIndex = (currentIndex + i) % modeSequence.length;
const nextMode = modeSequence[nextIndex]!;
const currentRenders = {
remaining:
progressInfoMode === 'all' || progressInfoMode === 'remaining' ? hasRemainingInfo : false,
progress:
progressInfoMode === 'all' || progressInfoMode === 'progress' ? hasProgressInfo : false,
};
const nextRenders = {
remaining: nextMode === 'all' || nextMode === 'remaining' ? hasRemainingInfo : false,
progress: nextMode === 'all' || nextMode === 'progress' ? hasProgressInfo : false,
};
const isDifferent =
currentRenders.remaining !== nextRenders.remaining ||
currentRenders.progress !== nextRenders.progress;
if (isDifferent) {
setProgressInfoMode(nextMode);
return;
}
}
const nextIndex = (currentIndex + 1) % modeSequence.length;
setProgressInfoMode(modeSequence[nextIndex]!);
};
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'progressInfoMode', progressInfoMode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progressInfoMode]);
return (
<div
role='presentation'
className={clsx(
'progressinfo absolute flex items-center justify-between font-sans',
'pointer-events-none bottom-0',
'pointer-events-auto bottom-0',
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
)}
onClick={() => cycleProgressInfoModes()}
aria-label={[
progress
? _('On {{current}} of {{total}} page', {
@@ -98,12 +143,12 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
left: showDoubleBorder
? `calc(${contentInsets.left}px)`
: `calc(${Math.max(0, contentInsets.left - 32)}px)`,
width: showDoubleBorder ? '32px' : `${horizontalGap}%`,
width: showDoubleBorder ? '32px' : `${contentInsets.left}px`,
height: `calc(100% - ${((contentInsets.top + contentInsets.bottom) / 2) * 3}px)`,
}
: {
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right}px)`,
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left / 2}px)`,
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right / 2}px)`,
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
}
}
@@ -115,15 +160,24 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
isVertical ? 'h-full' : 'h-[52px] w-full',
)}
>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && (
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
{progressInfo}
</span>
{(progressInfoMode === 'all' || progressInfoMode === 'remaining') && (
<>
{viewSettings.showRemainingTime ? (
<span className='text-start'>{timeLeft}</span>
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
</>
)}
{(progressInfoMode === 'all' || progressInfoMode === 'progress') && (
<>
{viewSettings.showProgressInfo && (
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
{progressInfo}
</span>
)}
</>
)}
</div>
</div>
@@ -23,6 +23,7 @@ import { getSysFontsList, setSystemUIVisibility } from '@/utils/bridge';
import { AboutWindow } from '@/components/AboutWindow';
import { UpdaterWindow } from '@/components/UpdaterWindow';
import { KOSyncSettingsWindow } from './KOSyncSettings';
import { ReplacementRulesWindow } from './ReplacementRulesWindow';
import { Toast } from '@/components/Toast';
import { getLocale } from '@/utils/misc';
import { initDayjs } from '@/utils/time';
@@ -33,8 +34,8 @@ Z-Index Layering Guide:
---------------------------------
99 Window Border (Linux only)
Ensures the border stays on top of all UI elements.
50 Loading Progress / Toast Notifications / Dialogs
Includes Settings, About, Updater, and KOSync dialogs.
50 Loading Progress / Toast Notifications / Dialogs / Popups
Includes Settings, About, Updater, KOSync dialogs and Annotation popups.
45 Sidebar / Notebook (Unpinned)
Floats above the content but below global dialogs.
40 TTS Bar
@@ -52,9 +53,10 @@ Z-Index Layering Guide:
const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const router = useRouter();
const { appService } = useEnv();
const { hoveredBookKey, getView } = useReaderStore();
const { settings } = useSettingsStore();
const { sideBarBookKey } = useSidebarStore();
const { hoveredBookKey, getView } = useReaderStore();
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { isSideBarVisible, getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
const { isNotebookVisible, getIsNotebookVisible, setNotebookVisible } = useNotebookStore();
const { isDarkMode, systemUIAlwaysHidden, isRoundedWindow } = useThemeStore();
@@ -74,6 +76,27 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
initDayjs(getLocale());
}, []);
useEffect(() => {
const brightness = settings.screenBrightness;
const autoBrightness = settings.autoScreenBrightness;
if (appService?.hasScreenBrightness && !autoBrightness && brightness >= 0) {
setScreenBrightness(brightness / 100);
}
let previousBrightness = -1;
if (appService?.isIOSApp) {
getScreenBrightness().then((b) => {
previousBrightness = b;
});
}
return () => {
if (appService?.hasScreenBrightness && !autoBrightness) {
setScreenBrightness(previousBrightness);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
const handleKeyDown = (event: CustomEvent) => {
const view = getView(sideBarBookKey!);
if (event.detail.keyName === 'Back') {
@@ -138,6 +161,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
<AboutWindow />
<UpdaterWindow />
<KOSyncSettingsWindow />
<ReplacementRulesWindow />
<Toast />
</Suspense>
</div>
@@ -52,7 +52,8 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
if (isInitiating.current) return;
isInitiating.current = true;
const bookIds = ids || searchParams?.get('ids') || '';
const pathname = window.location.pathname;
const bookIds = ids || searchParams?.get('ids') || pathname.split('/reader/')[1] || '';
const initialIds = bookIds.split(BOOK_IDS_SEPARATOR).filter(Boolean);
const initialBookKeys = initialIds.map((id) => `${id}-${uniqueId()}`);
setBookKeys(initialBookKeys);
@@ -177,7 +178,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
}
dismissBook(bookKey);
if (bookKeys.filter((key) => key !== bookKey).length == 0) {
const openWithFiles = (await parseOpenWithFiles()) || [];
const openWithFiles = (await parseOpenWithFiles(appService)) || [];
if (appService?.hasWindow) {
if (openWithFiles.length > 0) {
tauriHandleOnCloseWindow(handleCloseBooks);
@@ -0,0 +1,440 @@
import React, { useEffect, useState } from 'react';
import Dialog from '@/components/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { ReplacementRule } from '@/types/book';
import environmentConfig from '@/services/environment';
import { updateReplacementRule, removeReplacementRule } from '@/services/transformers/replacement';
import { eventDispatcher } from '@/utils/event';
import { RiEditLine, RiDeleteBin7Line } from 'react-icons/ri';
export const setReplacementRulesWindowVisible = (visible: boolean) => {
const dialog = document.getElementById('replacement_rules_window');
if (dialog) {
const event = new CustomEvent('setReplacementRulesVisibility', {
detail: { visible },
});
dialog.dispatchEvent(event);
}
};
export const ReplacementRulesWindow: React.FC = () => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { getConfig } = useBookDataStore();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleCustomEvent = (event: CustomEvent) => {
setIsOpen(!!event.detail?.visible);
};
const el = document.getElementById('replacement_rules_window');
el?.addEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener);
return () => {
el?.removeEventListener('setReplacementRulesVisibility', handleCustomEvent as EventListener);
};
}, []);
const viewSettings = sideBarBookKey ? getViewSettings(sideBarBookKey) : null;
const inMemoryRules = viewSettings?.replacementRules || [];
const persistedConfig = sideBarBookKey ? getConfig(sideBarBookKey) : null;
const persistedBookRules = persistedConfig?.viewSettings?.replacementRules || [];
// Prefer persisted rules; fall back to in-memory so we show unsaved edits in tests/dev
const bookRuleSource = persistedBookRules.length ? persistedBookRules : inMemoryRules;
const singleRules = bookRuleSource.filter((r: ReplacementRule) => !!r.singleInstance);
const bookScopedRules = bookRuleSource.filter((r: ReplacementRule) => !r.singleInstance);
// Book rules = book-scoped rules + global rules (merged for display)
// Merge logic:
// 1. Include all book-scoped rules (including disabled overrides of global rules)
// 2. Include global rules that aren't overridden at book level
// 3. Filter out orphaned overrides (disabled global rules that no longer exist globally)
const globalRules = settings?.globalViewSettings?.replacementRules || [];
// Create a map of global rule IDs to identify overridden rules
const globalRuleIds = new Set(globalRules.map((gr: ReplacementRule) => gr.id));
// Filter out book rules that are disabled overrides of non-existent global rules
const validBookRules = bookScopedRules.filter((br: ReplacementRule) => {
// If it's enabled, it's a real book rule
if (br.enabled !== false) return true;
// If it's disabled and the global rule still exists, keep it (it's an override)
// If the global rule doesn't exist, filter it out (orphaned override)
return globalRuleIds.has(br.id);
});
const mergedRules = validBookRules.concat(
globalRules.filter(
(gr: ReplacementRule) => !validBookRules.find((br: ReplacementRule) => br.id === gr.id),
),
);
// Create a map to track the scope of each rule for editing/deleting
const getRuleScope = (rule: ReplacementRule): 'single' | 'book' | 'global' => {
if (rule.singleInstance) return 'single';
// If the rule is in validBookRules and originates from global, it's an override
return globalRuleIds.has(rule.id) ? 'global' : 'book';
};
const bookRules = mergedRules;
const [editing, setEditing] = useState<{
id: string | null;
scope: 'single' | 'book' | 'global' | null;
pattern: string;
replacement: string;
enabled: boolean;
}>({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
// Track when a delete/edit operation is in progress to prevent rapid successive operations
const [isReloading, setIsReloading] = useState(false);
const startEdit = (r: ReplacementRule, scope: 'single' | 'book' | 'global') => {
setEditing({
id: r.id,
scope,
pattern: r.pattern,
replacement: r.replacement,
enabled: !!r.enabled,
});
};
const cancelEdit = () =>
setEditing({ id: null, scope: null, pattern: '', replacement: '', enabled: true });
const saveEdit = async () => {
if (!editing.id || !editing.scope) return;
// Prevent rapid successive operations
if (isReloading) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Please wait for the current operation to complete.'),
timeout: 3000,
});
return;
}
setIsReloading(true);
try {
const bookKey = sideBarBookKey || '';
if (editing.scope === 'global') {
await updateReplacementRule(
environmentConfig,
bookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'global',
);
} else if (editing.scope === 'book' && sideBarBookKey) {
await updateReplacementRule(
environmentConfig,
sideBarBookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'book',
);
} else if (editing.scope === 'single' && sideBarBookKey) {
await updateReplacementRule(
environmentConfig,
sideBarBookKey,
editing.id,
{
pattern: editing.pattern,
replacement: editing.replacement,
enabled: editing.enabled,
},
'single',
);
}
cancelEdit();
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Replacement rule updated. Reloading book to apply changes...'),
timeout: 3000,
});
if (sideBarBookKey) {
const { clearViewState, initViewState } = useReaderStore.getState();
const id = sideBarBookKey.split('-')[0]!;
// Hard reload: clear and reinit viewer to load from original source
clearViewState(sideBarBookKey);
await initViewState(environmentConfig, id, sideBarBookKey, true, true);
}
} catch (err) {
console.error('Failed to save replacement rule', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to update replacement rule'),
timeout: 3000,
});
} finally {
setIsReloading(false);
}
};
const deleteRule = async (ruleId: string, scope: 'single' | 'book' | 'global') => {
console.log('Deleting rule', ruleId, 'scope', scope);
// Prevent rapid successive deletions
if (isReloading) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Please wait for the book to finish reloading.'),
timeout: 3000,
});
return;
}
setIsReloading(true);
try {
const bookKey = sideBarBookKey || '';
if (scope === 'global') {
// delete global rule for all books
await removeReplacementRule(environmentConfig, '', ruleId, 'global');
} else {
await removeReplacementRule(environmentConfig, bookKey, ruleId, scope);
}
const successMessage =
scope === 'global'
? _(
'Global replacement rule deleted for all books in the library. Reloading book to apply changes...',
)
: _('Replacement rule deleted. Reloading book to apply changes...');
eventDispatcher.dispatch('toast', {
type: 'success',
message: successMessage,
timeout: 3000,
});
if (sideBarBookKey) {
const { clearViewState, initViewState } = useReaderStore.getState();
const id = sideBarBookKey.split('-')[0]!;
// Hard reload: clear and reinit viewer to load from original source
clearViewState(sideBarBookKey);
await initViewState(environmentConfig, id, sideBarBookKey, true, true);
}
} catch (err) {
console.error('Failed to delete replacement rule', err);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to delete replacement rule'),
timeout: 3000,
});
} finally {
setIsReloading(false);
}
};
return (
<Dialog
id='replacement_rules_window'
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={_('Replacement Rules')}
boxClassName='sm:!min-w-[520px] sm:h-auto'
>
{isOpen && (
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
<div>
<h3 className='text-sm font-semibold'>{_('Single Instance Rules')}</h3>
{singleRules.length === 0 ? (
<p className='text-base-content/70 mt-2 text-sm'>
{_('No single replacement rules')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{singleRules.map((r) => (
<li key={r.id} className='rounded border p-2'>
{editing.id === r.id && editing.scope === 'single' ? (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Selected phrase:')}
</label>
<input
className='input input-sm flex-1 text-sm opacity-60'
value={editing.pattern}
disabled
/>
</div>
<div className='flex items-center gap-2'>
<label className='text-base-content/70 whitespace-nowrap text-xs'>
{_('Replace with:')}
</label>
<input
className='input input-sm flex-1'
value={editing.replacement}
onChange={(e) =>
setEditing({ ...editing, replacement: e.target.value })
}
/>
</div>
<div className='flex gap-2'>
<button className='btn btn-sm btn-primary' onClick={saveEdit}>
{_('Save')}
</button>
<button className='btn btn-sm' onClick={cancelEdit}>
{_('Cancel')}
</button>
</div>
</div>
) : (
<div className='flex items-center justify-between'>
<div className='flex flex-col'>
<div className='text-base font-medium leading-tight'>{r.pattern}</div>
<div className='text-base-content/70 mt-1 break-all text-sm'>
<span className='text-base-content/80 mr-2 text-xs font-medium'>
{_('Replace with:')}
</span>
{r.replacement}
</div>
<div className='text-base-content/60 mt-1 text-xs'>
{_('Scope:')}&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`,
paddingInline: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
paddingInline: `calc(${horizontalGap / 2}% + ${contentInsets.left / 2}px)`,
width: '100%',
}
}
@@ -15,6 +15,7 @@ interface AnnotationPopupProps {
Icon: React.ElementType;
onClick: () => void;
disabled?: boolean;
visible?: boolean;
}>;
position: Position;
trianglePosition: Position;
@@ -51,6 +52,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
<Popup
width={isVertical ? popupHeight : popupWidth}
height={isVertical ? popupWidth : popupHeight}
minHeight={isVertical ? popupWidth : popupHeight}
position={position}
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 text-white'
@@ -59,23 +61,24 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
>
<div
className={clsx(
'selection-buttons flex items-center justify-between p-2',
isVertical ? 'flex-col' : 'flex-row',
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
)}
style={{
height: isVertical ? popupWidth : popupHeight,
}}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{buttons.map((button, index) => (
<PopupButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
))}
{buttons.map((button, index) => {
if (button.visible === false) return null;
return (
<PopupButton
key={index}
showTooltip={!highlightOptionsVisible}
tooltipText={button.tooltipText}
Icon={button.Icon}
onClick={button.onClick}
disabled={button.disabled}
/>
);
})}
</div>
</Popup>
{highlightOptionsVisible && (
@@ -8,6 +8,7 @@ import { RiDeleteBinLine } from 'react-icons/ri';
import { BsTranslate } from 'react-icons/bs';
import { TbHexagonLetterD } from 'react-icons/tb';
import { FaHeadphones } from 'react-icons/fa6';
import { MdBuildCircle } from 'react-icons/md';
import * as CFI from 'foliate-js/epubcfi.js';
import { Overlayer } from 'foliate-js/overlayer.js';
@@ -29,11 +30,15 @@ import { findTocItemBS } from '@/utils/toc';
import { throttle } from '@/utils/throttle';
import { runSimpleCC } from '@/utils/simplecc';
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import { addReplacementRule } from '@/services/transformers/replacement';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
import TranslatorPopup from './TranslatorPopup';
import useShortcuts from '@/hooks/useShortcuts';
import ReplacementOptions from './ReplacementOptions';
import { isWordLimitExceeded } from '@/utils/wordLimit';
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
@@ -59,6 +64,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [showWiktionaryPopup, setShowWiktionaryPopup] = useState(false);
const [showWikipediaPopup, setShowWikipediaPopup] = useState(false);
const [showDeepLPopup, setShowDeepLPopup] = useState(false);
const [showReplacementOptions, setShowReplacementOptions] = useState(false);
const [trianglePosition, setTrianglePosition] = useState<Position>();
const [annotPopupPosition, setAnnotPopupPosition] = useState<Position>();
const [dictPopupPosition, setDictPopupPosition] = useState<Position>();
@@ -150,6 +156,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowWiktionaryPopup(false);
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
setShowReplacementOptions(false);
}, 500),
[],
);
@@ -384,7 +391,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const handleCopy = (copyToNotebook = true) => {
if (!selection || !selection.text) return;
navigator.clipboard?.writeText(selection.text);
setTimeout(() => {
// Delay to ensure it won't be overridden by system clipboard actions
navigator.clipboard?.writeText(selection.text);
}, 100);
handleDismissPopupAndSelection();
if (!copyToNotebook) return;
@@ -519,6 +529,288 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range });
};
// Import type for ReplacementConfig
type ReplacementConfig = {
replacementText: string;
caseSensitive: boolean;
scope: 'once' | 'book' | 'library';
};
// Helper to check if selected text is a whole word (has word boundaries on both sides)
// Updated to be more lenient: allows phrases and lines, only prevents partial word matches
const isWholeWord = (range: Range, selectedText: string): boolean => {
try {
if (!selectedText || selectedText.trim().length === 0) return false;
// Verify the selection contains word characters
const hasWordCharInSelection = /[a-zA-Z0-9_]/.test(selectedText);
if (!hasWordCharInSelection) {
return false;
}
// If the selection contains spaces, punctuation, or multiple words, it's a phrase
// Phrases (including lines with quotes) are always allowed for single-instance replacements
const hasSpaces = /\s/.test(selectedText);
const hasPunctuation = /[^\w\s]/.test(selectedText);
const isPhrase = hasSpaces || hasPunctuation;
// Also allow selections that start or end with punctuation (e.g., "'tis", "off;", "look,")
// These are valid selections where the user intentionally includes punctuation
const startsWithPunctuation = /^[^\w\s]/.test(selectedText);
const endsWithPunctuation = /[^\w\s]$/.test(selectedText);
const hasBoundaryPunctuation = startsWithPunctuation || endsWithPunctuation;
if (isPhrase || hasBoundaryPunctuation) {
// For phrases or selections with boundary punctuation, we allow them
// The only thing we want to prevent is selecting "and" inside "England"
return true;
}
// For single words, check boundaries to prevent partial word matches
// Get characters immediately before and after the selection
let charBefore = '';
let charAfter = '';
try {
// Get character before
const startNode = range.startContainer;
if (startNode.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
const textNode = startNode as Text;
charBefore = textNode.textContent?.charAt(range.startOffset - 1) || '';
} else if (startNode.nodeType === Node.TEXT_NODE && range.startOffset === 0) {
// Check previous sibling text node
let prevSibling = startNode.previousSibling;
while (prevSibling && prevSibling.nodeType !== Node.TEXT_NODE) {
prevSibling = prevSibling.previousSibling;
}
if (prevSibling && prevSibling.nodeType === Node.TEXT_NODE) {
const prevText = (prevSibling as Text).textContent || '';
charBefore = prevText.charAt(prevText.length - 1);
}
}
// Get character after
const endNode = range.endContainer;
if (endNode.nodeType === Node.TEXT_NODE) {
const textNode = endNode as Text;
const textContent = textNode.textContent || '';
if (range.endOffset < textContent.length) {
charAfter = textContent.charAt(range.endOffset);
} else {
// Check next sibling text node
let nextSibling = textNode.nextSibling;
while (nextSibling && nextSibling.nodeType !== Node.TEXT_NODE) {
nextSibling = nextSibling.nextSibling;
}
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE) {
const nextText = (nextSibling as Text).textContent || '';
charAfter = nextText.charAt(0);
}
}
}
} catch (e) {
// If we can't determine boundaries for a single word, be lenient
// This handles edge cases with complex HTML
console.warn('[isWholeWord] Error checking boundaries:', e);
return true; // Allow if we can't verify (better to allow than reject valid selections)
}
// Word characters are: letters, digits, and underscore [a-zA-Z0-9_]
const isWordChar = (char: string) => /[a-zA-Z0-9_]/.test(char);
// Check boundaries for single words
// Empty means we're at start/end of text (valid boundary)
const hasBoundaryBefore = !charBefore || !isWordChar(charBefore);
const hasBoundaryAfter = !charAfter || !isWordChar(charAfter);
const isValid = hasBoundaryBefore && hasBoundaryAfter;
if (!isValid) {
console.log('[isWholeWord] Not a whole word:', {
selectedText,
charBefore: charBefore || '(start)',
charAfter: charAfter || '(end)',
hasBoundaryBefore,
hasBoundaryAfter,
});
}
return isValid;
} catch (e) {
console.warn('Failed to check whole word:', e);
// On error, be lenient - allow selections with word characters
// This prevents false rejections for complex selections (quotes, multi-node, etc.)
return /[a-zA-Z0-9_]/.test(selectedText);
}
};
// Helper to count which occurrence of a pattern was selected (using whole-word matching)
const getOccurrenceIndex = (range: Range, pattern: string): number => {
try {
const doc = range.startContainer.ownerDocument;
if (!doc || !doc.body) return 0;
// Create a range from start of body to start of selection
const beforeRange = doc.createRange();
beforeRange.setStart(doc.body, 0);
beforeRange.setEnd(range.startContainer, range.startOffset);
// Get text before selection and count occurrences using whole-word matching
const textBefore = beforeRange.toString();
// Escape pattern and add word boundaries for whole-word matching
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wholeWordPattern = `\\b${escapedPattern}\\b`;
const regex = new RegExp(wholeWordPattern, 'g');
const matches = textBefore.match(regex);
return matches ? matches.length : 0;
} catch (e) {
console.warn('Failed to get occurrence index:', e);
return 0;
}
};
const handleReplacementConfirm = async (config: ReplacementConfig) => {
if (!selection || !selection.text) return;
const { replacementText, caseSensitive, scope } = config;
console.log('Replacement confirmed:', {
originalText: selection.text,
replacementText,
caseSensitive,
scope,
});
try {
if (scope === 'once') {
// For single-instance: direct DOM modification + persistent rule
const range = selection.range;
if (range) {
// Validate that the selection is a whole word
// Single-instance replacements only work on whole words to prevent
// replacing substrings inside larger words (e.g., "and" in "England")
const isValidWholeWord = isWholeWord(range, selection.text);
if (!isValidWholeWord) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`,
timeout: 5000,
});
return;
}
// Get which occurrence this is BEFORE modifying the DOM
// Use whole-word matching to count occurrences correctly
const occurrenceIndex = getOccurrenceIndex(range, selection.text);
const sectionHref = progress?.sectionHref;
// Directly modify DOM for immediate effect
// Note: createTextNode automatically escapes HTML entities, so angle brackets will be preserved
range.deleteContents();
const textNode = document.createTextNode(replacementText);
range.insertNode(textNode);
// Create rule with occurrence tracking for persistence
await addReplacementRule(
envConfig,
bookKey,
{
pattern: selection.text,
replacement: replacementText,
isRegex: false,
enabled: true,
caseSensitive,
singleInstance: true,
sectionHref,
occurrenceIndex,
},
'single',
);
eventDispatcher.dispatch('toast', {
type: 'success',
message: 'Replacement applied! Will persist on refresh.',
timeout: 3000,
});
setShowReplacementOptions(false);
handleDismissPopupAndSelection();
}
} else {
// For book-wide and global: use the transformer approach
const backendScope = scope === 'book' ? 'book' : 'global';
const range = selection.range;
const isValidWholeWord = range ? isWholeWord(range, selection.text) : false;
if (!isValidWholeWord) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: `Cannot replace "${selection.text}" - please select a complete word. Partial word selections (like "and" in "England" or "errand") are not supported.`,
timeout: 5000,
});
return;
}
await addReplacementRule(
envConfig,
bookKey,
{
pattern: selection.text,
replacement: replacementText,
isRegex: false,
enabled: true,
caseSensitive,
singleInstance: false,
wholeWord: true,
},
backendScope as 'book' | 'global',
);
const scopeLabels = {
book: 'this book',
library: 'your library',
};
eventDispatcher.dispatch('toast', {
type: 'success',
message: `Replacement applied to ${scopeLabels[scope]}! Reloading...`,
timeout: 3000,
});
setShowReplacementOptions(false);
handleDismissPopupAndSelection();
// Reload the book view to apply the replacement
const { recreateViewer } = useReaderStore.getState();
await recreateViewer(envConfig, bookKey);
}
} catch (error) {
console.error('Failed to apply replacement:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: 'Failed to apply replacement. Please try again.',
timeout: 3000,
});
}
};
const handleShowReplacementOptions = () => {
if (!selection || !selection.text) {
return;
}
if (isWordLimitExceeded(selection.text)) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: 'Word limit exceeded. Please select 30 words or fewer.',
timeout: 3000,
});
return;
}
setShowReplacementOptions(!showReplacementOptions);
};
// Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden
useShortcuts(
{
@@ -668,6 +960,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onClick: handleSpeakText,
disabled: bookData.book?.format === 'PDF',
},
{
tooltipText: 'Text Replacement',
Icon: MdBuildCircle,
onClick: handleShowReplacementOptions,
disabled: bookData.book?.format !== 'EPUB',
visible: false,
},
];
return (
@@ -720,6 +1019,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showReplacementOptions && trianglePosition && annotPopupPosition && (
<ReplacementOptions
isVertical={viewSettings.vertical}
style={{
height: 'auto',
left: `${annotPopupPosition.point.x}px`,
top: `${
annotPopupPosition.point.y +
(annotPopupHeight + 16) * (trianglePosition.dir === 'up' ? -1 : 1)
}px`,
}}
selectedText={selection?.text || ''}
onConfirm={handleReplacementConfirm}
onClose={() => setShowReplacementOptions(false)}
/>
)}
</div>
);
};
@@ -0,0 +1,284 @@
'use client';
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
export interface ReplacementConfig {
replacementText: string;
caseSensitive: boolean;
scope: 'once' | 'book' | 'library';
}
interface ReplacementOptionsProps {
isVertical: boolean;
style: React.CSSProperties;
selectedText: string;
onConfirm: (config: ReplacementConfig) => void;
onClose: () => void;
}
const ReplacementOptions: React.FC<ReplacementOptionsProps> = ({
style,
isVertical,
selectedText,
onConfirm,
onClose,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [replacementText, setReplacementText] = useState('');
const [caseSensitive, setCaseSensitive] = useState(true);
const [selectedScope, setSelectedScope] = useState<'once' | 'book' | 'library' | null>(null);
const [showConfirmation, setShowConfirmation] = useState(false);
const [adjustedStyle, setAdjustedStyle] = useState<React.CSSProperties | null>(null);
const [isPositioned, setIsPositioned] = useState(false);
const hasAdjusted = useRef(false);
// Adjust position to stay within viewport - only once on initial render
useEffect(() => {
// Only adjust once to prevent jumping when other UI elements appear
if (menuRef.current && !hasAdjusted.current) {
// Use requestAnimationFrame to ensure the element is rendered before measuring
requestAnimationFrame(() => {
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const padding = 10;
const newStyle = { ...style };
// Check if popup extends beyond bottom of viewport
if (rect.bottom > viewportHeight - padding) {
const currentTop = parseFloat(String(style.top)) || 0;
// Move popup above the selection instead
newStyle.top = `${Math.max(padding, currentTop - rect.height - 40)}px`;
}
// Check if popup extends beyond right of viewport
if (rect.right > viewportWidth - padding) {
newStyle.left = `${Math.max(padding, viewportWidth - rect.width - padding)}px`;
}
// Check if popup extends beyond left of viewport
if (rect.left < padding) {
newStyle.left = `${padding}px`;
}
setAdjustedStyle(newStyle);
hasAdjusted.current = true;
setIsPositioned(true);
}
});
}
}, [style]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Focus input on mount
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleScopeClick = (scope: 'once' | 'book' | 'library') => {
if (!replacementText.trim()) {
// Show error if no replacement text
return;
}
setSelectedScope(scope);
setShowConfirmation(true);
};
const handleConfirm = () => {
if (selectedScope && replacementText.trim()) {
onConfirm({
replacementText: replacementText.trim(),
caseSensitive,
scope: selectedScope,
});
}
};
const handleCancelConfirmation = () => {
setShowConfirmation(false);
setSelectedScope(null);
};
const handleCancel = () => {
onClose();
};
const getScopeLabel = (scope: 'once' | 'book' | 'library' | null) => {
switch (scope) {
case 'once':
return 'this instance';
case 'book':
return 'all instances in this book';
case 'library':
return 'all instances in your library';
default:
return '';
}
};
// Secondary confirmation dialog
if (showConfirmation) {
return (
<div
ref={menuRef}
className={clsx(
'replacement-options absolute flex flex-col gap-3 rounded-lg bg-gray-700 p-4',
)}
style={{
...(adjustedStyle || style),
minWidth: '320px',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
<div className='text-sm text-white'>
<p className='mb-2 font-semibold'>Confirm Replacement</p>
<p className='mb-1 text-gray-300'>
Replace: <span className='text-yellow-300'>&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>
@@ -204,7 +204,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
@@ -214,6 +214,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
);
const isMobile = appService?.isMobile || window.innerWidth < 640;
return (
<>
{/* Hover trigger area */}
@@ -222,10 +224,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
className={clsx(
'absolute bottom-0 left-0 z-10 flex h-[52px] w-full',
needHorizontalScroll && 'sm:!bottom-3 sm:!h-7',
isMobile ? 'pointer-events-none' : '',
)}
onClick={() => setHoveredBookKey(bookKey)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onMouseEnter={() => !isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !isMobile && setHoveredBookKey(bookKey)}
/>
{/* Main footer container */}
@@ -16,6 +16,7 @@ import { uniqueId } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { getBookDirFromLanguage } from '@/utils/book';
import { Overlay } from '@/components/Overlay';
import { saveSysSettings } from '@/helpers/settings';
import useShortcuts from '@/hooks/useShortcuts';
import BooknoteItem from '../sidebar/BooknoteItem';
import NotebookHeader from './Header';
@@ -88,7 +89,9 @@ const Notebook: React.FC = ({}) => {
const handleTogglePin = () => {
toggleNotebookPin();
settings.globalReadSettings.isNotebookPinned = !isNotebookPinned;
const globalReadSettings = settings.globalReadSettings;
const newGlobalReadSettings = { ...globalReadSettings, isNotebookPinned: !isNotebookPinned };
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
};
const handleClickOverlay = () => {
@@ -14,6 +14,7 @@ import { isWebAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
import { setReplacementRulesWindowVisible } from '@/app/reader/components/ReplacementRulesWindow';
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
import useBooksManager from '../../hooks/useBooksManager';
import MenuItem from '@/components/MenuItem';
@@ -78,6 +79,10 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
setKOSyncSettingsWindowVisible(true);
setIsDropdownOpen?.(false);
};
const showReplacementRulesWindow = () => {
setReplacementRulesWindowVisible(true);
setIsDropdownOpen?.(false);
};
const handlePullKOSync = () => {
eventDispatcher.dispatch('pull-kosync', { bookKey: sideBarBookKey });
setIsDropdownOpen?.(false);
@@ -140,6 +145,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
))}
<hr className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
{false && <MenuItem label={_('Replacement Rules')} onClick={showReplacementRulesWindow} />}
{settings.kosync.enabled && (
<>
<MenuItem label={_('Push Progress')} onClick={handlePushKOSync} />
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
@@ -36,6 +37,7 @@ interface TTSControlProps {
const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { user } = useAuth();
const { safeAreaInsets } = useThemeStore();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
@@ -173,6 +175,14 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const handleNeedAuth = () => {
eventDispatcher.dispatch('toast', {
message: _('Please log in to use advanced TTS features.'),
type: 'error',
timeout: 5000,
});
};
const handleSpeakMark = (e: Event) => {
const progress = getProgress(bookKey);
const { sectionLabel } = progress || {};
@@ -243,9 +253,11 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
}
};
ttsController.addEventListener('tts-need-auth', handleNeedAuth);
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
return () => {
ttsController.removeEventListener('tts-need-auth', handleNeedAuth);
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
};
@@ -326,7 +338,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
setTtsClientsInitialized(false);
setShowIndicator(true);
const ttsController = new TTSController(appService, view);
const ttsController = new TTSController(appService, view, !!user?.id);
await ttsController.init();
await ttsController.initViewTTS(viewSettings.ttsHighlightOptions);
const ssml = view.tts?.from(ttsFromRange);
@@ -1,8 +1,11 @@
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useEffect } from 'react';
import { saveSysSettings } from '@/helpers/settings';
const useSidebar = (initialWidth: string, isPinned: boolean) => {
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const {
sideBarWidth,
@@ -30,8 +33,10 @@ const useSidebar = (initialWidth: string, isPinned: boolean) => {
const handleSideBarTogglePin = () => {
toggleSideBarPin();
settings.globalReadSettings.isSideBarPinned = !isSideBarPinned;
if (isSideBarPinned && isSideBarVisible) setSideBarVisible(false);
const globalReadSettings = settings.globalReadSettings;
const newGlobalReadSettings = { ...globalReadSettings, isSideBarPinned: !isSideBarPinned };
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
};
return {
@@ -176,27 +176,22 @@ export const handleClick = (
lastClickTime = now;
const postSingleClick = () => {
let element: HTMLElement | null = event.target as HTMLElement;
while (element) {
if (['sup', 'a', 'audio', 'video'].includes(element.tagName.toLowerCase())) {
return;
}
if (
element.classList.contains('js_readerFooterNote') ||
element.classList.contains('zhangyue-footnote')
) {
eventDispatcher.dispatch('footnote-popup', {
bookKey,
element,
footnote:
element.getAttribute('data-wr-footernote') ||
element.getAttribute('zy-footnote') ||
element.getAttribute('alt') ||
'',
});
return;
}
element = element.parentElement;
const element = event.target as HTMLElement | null;
if (element?.closest('sup, a, audio, video')) {
return;
}
const footnote = element?.closest('.js_readerFooterNote, .zhangyue-footnote');
if (footnote) {
eventDispatcher.dispatch('footnote-popup', {
bookKey,
element: footnote,
footnote:
footnote.getAttribute('data-wr-footernote') ||
footnote.getAttribute('zy-footnote') ||
footnote.getAttribute('alt') ||
'',
});
return;
}
// if long hold is detected, we don't want to send single click event
@@ -47,6 +47,7 @@ const DeleteConfirmationModal: React.FC<DeleteConfirmationModalProps> = ({
interface AccountActionsProps {
userPlan: UserPlan;
iapAvailable: boolean;
onLogout: () => void;
onResetPassword: () => void;
onUpdateEmail: () => void;
@@ -58,6 +59,7 @@ interface AccountActionsProps {
const AccountActions: React.FC<AccountActionsProps> = ({
userPlan,
iapAvailable,
onLogout,
onResetPassword,
onUpdateEmail,
@@ -89,7 +91,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
}}
/>
<div className='flex flex-col gap-4 md:grid md:grid-cols-2 lg:grid-cols-3'>
{appService?.hasIAP ? (
{appService?.hasIAP && iapAvailable ? (
<button
onClick={onRestorePurchase}
className='w-full rounded-lg bg-blue-100 px-6 py-3 font-medium text-blue-600 transition-colors hover:bg-blue-200 md:w-auto'
+9 -3
View File
@@ -52,7 +52,7 @@ const ProfilePage = () => {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { token, user } = useAuth();
const { token, user, refresh } = useAuth();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const [loading, setLoading] = useState(false);
@@ -73,7 +73,7 @@ const ProfilePage = () => {
const { handleLogout, handleResetPassword, handleUpdateEmail, handleConfirmDelete } =
useUserActions();
const { availablePlans } = useAvailablePlans({
const { availablePlans, iapAvailable } = useAvailablePlans({
hasIAP: appService?.hasIAP || false,
onError: useCallback(
(message: string) => {
@@ -91,6 +91,7 @@ const ProfilePage = () => {
setShowEmbeddedCheckout(false);
} else if (showStorageManager) {
setShowStorageManager(false);
refresh();
} else {
navigateToLibrary(router);
}
@@ -283,12 +284,17 @@ const ProfilePage = () => {
<PlansComparison
availablePlans={availablePlans}
userPlan={userProfilePlan}
onSubscribe={appService.hasIAP ? handleIAPSubscribe : handleStripeSubscribe}
onSubscribe={
appService.hasIAP && iapAvailable
? handleIAPSubscribe
: handleStripeSubscribe
}
/>
</div>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userProfilePlan}
iapAvailable={iapAvailable}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onUpdateEmail={handleUpdateEmail}
@@ -2,13 +2,13 @@
import Stripe from 'stripe';
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useAuth } from '@/context/AuthContext';
import { useTranslation } from '@/hooks/useTranslation';
import { getAPIBaseUrl, getNodeAPIBaseUrl } from '@/services/environment';
import { getAccessToken } from '@/utils/access';
import { supabase } from '@/utils/supabase';
import { PlanType } from '@/types/quota';
import Spinner from '@/components/Spinner';
import { VerifiedIAP } from '@/libs/payment/iap/types';
import Spinner from '@/components/Spinner';
const STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
const APPLE_IAP_VERIFY_URL = `${getNodeAPIBaseUrl()}/apple/iap-verify`;
@@ -34,6 +34,7 @@ const SuccessPageWithSearchParams = () => {
const [retryCount, setRetryCount] = useState(0);
const searchParams = useSearchParams();
const router = useRouter();
const { refresh } = useAuth();
const payment = searchParams?.get('payment');
const platform = searchParams?.get('platform');
const sessionId = searchParams?.get('session_id');
@@ -86,9 +87,7 @@ const SuccessPageWithSearchParams = () => {
currency: session.currency || undefined,
});
try {
await supabase.auth.refreshSession();
} catch {}
refresh();
} catch (error) {
console.error('Failed to fetch session status:', error);
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
@@ -138,9 +137,7 @@ const SuccessPageWithSearchParams = () => {
planType: purchase.planType,
});
try {
await supabase.auth.refreshSession();
} catch {}
refresh();
} catch (error) {
console.error('Failed to verify IAP transaction:', error);
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
@@ -198,9 +195,7 @@ const SuccessPageWithSearchParams = () => {
currency: purchase.currency,
});
try {
await supabase.auth.refreshSession();
} catch {}
refresh();
} catch (error) {
console.error('Failed to verify Android IAP transaction:', error);
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
@@ -1,7 +1,7 @@
'use client';
import Image from 'next/image';
import { useState, useEffect } from 'react';
import { useState, useEffect, memo } from 'react';
interface CachedImageProps {
src: string | null;
@@ -15,7 +15,10 @@ interface CachedImageProps {
fallback?: React.ReactNode;
}
export function CachedImage({
const imageUrlCache = new Map<string, string>();
const loadingPromises = new Map<string, Promise<string>>();
const CachedImageComponent = ({
src,
alt,
fill,
@@ -25,9 +28,11 @@ export function CachedImage({
height,
onGenerateCachedImageUrl,
fallback,
}: CachedImageProps) {
const [cachedUrl, setCachedUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
}: CachedImageProps) => {
const [cachedUrl, setCachedUrl] = useState<string | null>(() => {
return src ? imageUrlCache.get(src) || null : null;
});
const [loading, setLoading] = useState(() => !src || !imageUrlCache.has(src));
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
@@ -38,6 +43,15 @@ export function CachedImage({
return;
}
const cached = imageUrlCache.get(src);
if (cached) {
setTimeout(() => {
setCachedUrl(cached);
setLoading(false);
}, 0);
return;
}
let cancelled = false;
const loadImage = async () => {
@@ -45,9 +59,20 @@ export function CachedImage({
setLoading(true);
setError(null);
const url = await onGenerateCachedImageUrl(src);
let loadPromise = loadingPromises.get(src);
if (!loadPromise) {
loadPromise = onGenerateCachedImageUrl(src);
loadingPromises.set(src, loadPromise);
loadPromise.finally(() => {
loadingPromises.delete(src);
});
}
const url = await loadPromise;
if (!cancelled) {
imageUrlCache.set(src, url);
setCachedUrl(url);
setLoading(false);
}
@@ -78,7 +103,6 @@ export function CachedImage({
if (fallback) {
return <>{fallback}</>;
}
return (
<div className={`flex h-full w-full items-center justify-center ${className || ''}`}>
<div className='text-base-content/30'>
@@ -109,4 +133,23 @@ export function CachedImage({
sizes={sizes}
/>
);
}
};
const arePropsEqual = (prevProps: CachedImageProps, nextProps: CachedImageProps) => {
return (
prevProps.src === nextProps.src &&
prevProps.alt === nextProps.alt &&
prevProps.fill === nextProps.fill &&
prevProps.className === nextProps.className &&
prevProps.sizes === nextProps.sizes &&
prevProps.width === nextProps.width &&
prevProps.height === nextProps.height
);
};
export const CachedImage = memo(CachedImageComponent, arePropsEqual);
export const clearImageCache = () => {
imageUrlCache.clear();
loadingPromises.clear();
};
+3 -6
View File
@@ -45,9 +45,6 @@ const Popup = ({
availableHeight = window.innerHeight - trianglePosition.point.y - popupPadding;
}
maxHeight = Math.min(maxHeight || availableHeight, availableHeight);
if (minHeight) {
minHeight = Math.min(minHeight, availableHeight);
}
useEffect(() => {
if (!containerRef.current) return;
@@ -79,11 +76,11 @@ const Popup = ({
...position,
point: {
...position.point,
y: trianglePosition.point.y - containerHeight,
y: Math.max(popupPadding, trianglePosition.point.y - containerHeight),
},
};
setAdjustedPosition(newPosition);
}, [position, trianglePosition, childrenHeight]);
}, [position, trianglePosition, popupPadding, childrenHeight]);
return (
<div>
@@ -91,7 +88,7 @@ const Popup = ({
id='popup-container'
ref={containerRef}
className={clsx(
'bg-base-300 absolute rounded-lg font-sans',
'bg-base-300 absolute z-50 rounded-lg font-sans',
trianglePosition?.dir !== 'up' && 'shadow-xl',
className,
)}
+1 -15
View File
@@ -10,7 +10,6 @@ import { CSPostHogProvider } from '@/context/PHContext';
import { SyncProvider } from '@/context/SyncContext';
import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
@@ -21,7 +20,6 @@ import { getDirFromUILanguage } from '@/utils/rtl';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { envConfig, appService } = useEnv();
const { applyUILanguage } = useSettingsStore();
const { setScreenBrightness } = useDeviceControlStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
const iconSize = useDefaultIconSize();
@@ -54,25 +52,13 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
appService.loadSettings().then((settings) => {
const globalViewSettings = settings.globalViewSettings;
applyUILanguage(globalViewSettings.uiLanguage);
const brightness = settings.screenBrightness;
const autoBrightness = settings.autoScreenBrightness;
if (appService.hasScreenBrightness && !autoBrightness && brightness >= 0) {
setScreenBrightness(brightness / 100);
}
applyBackgroundTexture(envConfig, globalViewSettings);
if (globalViewSettings.isEink) {
applyEinkMode(true);
}
});
}
}, [
envConfig,
appService,
applyUILanguage,
setScreenBrightness,
applyBackgroundTexture,
applyEinkMode,
]);
}, [envConfig, appService, applyUILanguage, applyBackgroundTexture, applyEinkMode]);
// Make sure appService is available in all children components
if (!appService) return;
+10 -1
View File
@@ -10,6 +10,7 @@ interface AuthContextType {
user: User | null;
login: (token: string, user: User) => void;
logout: () => void;
refresh: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -91,8 +92,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
}
};
const refresh = async () => {
try {
await supabase.auth.refreshSession();
} catch {}
};
return (
<AuthContext.Provider value={{ token, user, login, logout }}>{children}</AuthContext.Provider>
<AuthContext.Provider value={{ token, user, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
};
+9 -4
View File
@@ -1,4 +1,5 @@
import { isWebAppPlatform, hasCli } from '@/services/environment';
import { AppService } from '@/types/system';
import { getCurrent } from '@tauri-apps/plugin-deep-link';
declare global {
@@ -35,14 +36,18 @@ const parseCLIOpenWithFiles = async () => {
return files;
};
const parseIntentOpenWithFiles = async () => {
const parseIntentOpenWithFiles = async (appService: AppService | null) => {
const urls = await getCurrent();
if (urls && urls.length > 0) {
console.log('Intent Open with URL:', urls);
return urls
.map((url) => {
if (url.startsWith('file://')) {
return decodeURI(url.replace('file://', ''));
if (appService?.isIOSApp) {
return decodeURI(url);
} else {
return decodeURI(url.replace('file://', ''));
}
} else if (url.startsWith('content://')) {
return url;
} else {
@@ -55,7 +60,7 @@ const parseIntentOpenWithFiles = async () => {
return null;
};
export const parseOpenWithFiles = async () => {
export const parseOpenWithFiles = async (appService: AppService | null) => {
if (isWebAppPlatform()) return [];
let files = parseWindowOpenWithFiles();
@@ -63,7 +68,7 @@ export const parseOpenWithFiles = async () => {
files = await parseCLIOpenWithFiles();
}
if (!files || files.length === 0) {
files = await parseIntentOpenWithFiles();
files = await parseIntentOpenWithFiles(appService);
}
return files;
};
+2 -2
View File
@@ -1,6 +1,6 @@
const DEFAULT_SHORTCUTS = {
onSwitchSideBar: ['ctrl+Tab', 'opt+Tab', 'alt+Tab'],
onToggleSideBar: ['s', 'F9'],
onToggleSideBar: ['s'],
onToggleNotebook: ['n'],
onShowSearchBar: ['ctrl+f', 'cmd+f'],
onToggleScrollMode: ['shift+j'],
@@ -17,7 +17,7 @@ const DEFAULT_SHORTCUTS = {
onWikipediaSelection: ['ctrl+w', 'cmd+w'],
onReadAloudSelection: ['ctrl+r', 'cmd+r'],
onOpenFontLayoutSettings: ['shift+f', 'ctrl+,', 'cmd+,'],
onOpenBooks: ['ctrl+o', 'cmd+o'],
onOpenBooks: ['ctrl+o'],
onReloadPage: ['shift+r'],
onToggleFullscreen: ['F11'],
onCloseWindow: ['ctrl+w', 'cmd+w'],
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { fetchAndTransformIAPPlans } from '@/libs/payment/iap/client';
import { fetchAndTransformIAPPlans, isIAPAvailable } from '@/libs/payment/iap/client';
import { fetchStripePlans } from '@/libs/payment/stripe/client';
import { AvailablePlan } from '@/types/quota';
import { stubTranslation as _ } from '@/utils/misc';
@@ -20,6 +20,7 @@ interface UseAvailablePlansParams {
export const useAvailablePlans = ({ hasIAP, onError }: UseAvailablePlansParams) => {
const [availablePlans, setAvailablePlans] = useState<AvailablePlan[]>([]);
const [iapAvailable, setIapAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
@@ -29,9 +30,10 @@ export const useAvailablePlans = ({ hasIAP, onError }: UseAvailablePlansParams)
setError(null);
try {
if (hasIAP) {
if (hasIAP && (await isIAPAvailable())) {
const plans = await fetchAndTransformIAPPlans(IAP_PRODUCT_IDS);
setAvailablePlans(plans);
setIapAvailable(true);
} else {
const plans = await fetchStripePlans();
setAvailablePlans(plans);
@@ -52,5 +54,5 @@ export const useAvailablePlans = ({ hasIAP, onError }: UseAvailablePlansParams)
fetchPlans();
}, [hasIAP, onError]);
return { availablePlans, loading, error };
return { availablePlans, iapAvailable, loading, error };
};
+36 -8
View File
@@ -14,6 +14,10 @@ interface SingleInstancePayload {
cwd: string;
}
interface OpenFilesPayload {
files: string[];
}
interface SharedIntentPayload {
urls: string[];
}
@@ -36,7 +40,11 @@ export function useOpenWithBooks() {
const filePaths = [];
for (let url of urls) {
if (url.startsWith('file://')) {
url = decodeURI(url.replace('file://', ''));
if (appService?.isIOSApp) {
url = decodeURI(url);
} else {
url = decodeURI(url.replace('file://', ''));
}
}
if (!/^(https?:|data:|blob:)/i.test(url)) {
filePaths.push(url);
@@ -73,20 +81,39 @@ export function useOpenWithBooks() {
if (listenedOpenWithBooks.current) return;
listenedOpenWithBooks.current = true;
const unlistenDeeplink = getCurrentWindow().listen('single-instance', ({ event, payload }) => {
console.log('Received deep link:', event, payload);
const { args } = payload as SingleInstancePayload;
if (args?.[1]) {
handleOpenWithFileUrl([args[1]]);
}
});
// For Windows/Linux deep link and macOS open-file event
const unlistenDeeplink = getCurrentWindow().listen<SingleInstancePayload>(
'single-instance',
({ payload }) => {
console.log('Received deep link:', payload);
const { args } = payload;
if (args?.[1]) {
handleOpenWithFileUrl([args[1]]);
}
},
);
// macOS in-app open-files event
const unlistenOpenFiles = getCurrentWindow().listen<OpenFilesPayload>(
'open-files',
({ payload }) => {
console.log('Received open files:', payload);
const { files } = payload;
if (files && files.length > 0) {
handleOpenWithFileUrl(files);
}
},
);
// For Android "Share to Readest" intent
let unlistenSharedIntent: Promise<PluginListener> | null = null;
// FIXME: register/unregister plugin listeniner on iOS might cause app freeze for unknown reason
// so we only register it on Android for now to support "Shared to Readest" feature
if (appService?.isAndroidApp) {
unlistenSharedIntent = initializeListeners();
}
// iOS Open with URL event
const listenOpenWithFiles = async () => {
return await onOpenUrl((urls) => {
handleOpenWithFileUrl(urls);
@@ -95,6 +122,7 @@ export function useOpenWithBooks() {
const unlistenOpenUrl = listenOpenWithFiles();
return () => {
unlistenDeeplink.then((f) => f());
unlistenOpenFiles.then((f) => f());
unlistenOpenUrl.then((f) => f());
unlistenSharedIntent?.then((f) => f.unregister());
};
+2 -1
View File
@@ -4,6 +4,7 @@ import { ErrorCodes, getTranslator, getTranslators, TranslatorName } from '@/ser
import { getFromCache, storeInCache, UseTranslatorOptions } from '@/services/translators';
import { polish, preprocess } from '@/services/translators';
import { eventDispatcher } from '@/utils/event';
import { getLocale } from '@/utils/misc';
import { useTranslation } from './useTranslation';
export function useTranslator({
@@ -42,7 +43,7 @@ export function useTranslator({
options?: { source?: string; target?: string; useCache?: boolean },
): Promise<string[]> => {
const sourceLanguage = options?.source || sourceLang;
const targetLanguage = options?.target || targetLang;
const targetLanguage = options?.target || targetLang || getLocale();
const useCache = options?.useCache ?? false;
const textsToTranslate = enablePreprocessing ? preprocess(input) : input;

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