forked from akai/readest
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1088af023b | |||
| 011ad18a02 | |||
| e9d71b2936 | |||
| ec32614539 | |||
| 96678d85ec | |||
| 41b5e92563 | |||
| ef97a8ed02 | |||
| 8df8bc8b4a | |||
| f0e23a1503 | |||
| 4e1464ef17 | |||
| 7b60b1bb0c | |||
| cc780712b9 | |||
| 95ff526140 | |||
| f86bbbcc22 | |||
| 20940105fb | |||
| 2a49e93cf7 | |||
| 030a7c0823 | |||
| 7bf4822b27 | |||
| de11511c30 | |||
| ab7da981da | |||
| 3df75a67f9 | |||
| 07e3248780 | |||
| 23d5f3363d | |||
| c6daf72da9 | |||
| bd866cb049 | |||
| a5690e9a84 | |||
| ed7cfc31fc | |||
| a07bf23e18 | |||
| 41d014914b | |||
| baee85e7c8 | |||
| 1e259e87b2 | |||
| 4abbc17f66 | |||
| d7fd06ca82 | |||
| e43e533aca | |||
| dc788283ad | |||
| 373420bb0c | |||
| 6072b0dcbd | |||
| 13ff96db85 | |||
| bfbe92f355 | |||
| 799db40763 | |||
| 932c82aa49 | |||
| 184de9210d | |||
| 50a2957e35 | |||
| 637b7462c4 | |||
| 82deb85c64 | |||
| db35a4e203 | |||
| 017a9338b3 | |||
| cf21a752c6 | |||
| 16adf11258 | |||
| ae2c421938 |
@@ -0,0 +1,103 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: 'CodeQL Advanced'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
schedule:
|
||||
- cron: '38 20 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: rust
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
# or others). This is typically only required for manual builds.
|
||||
# - name: Setup runtime (example)
|
||||
# uses: actions/setup-example@v1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- name: Run manual build steps
|
||||
if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: install dependencies (ubuntu only - armhf specific)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
|
||||
@@ -391,6 +391,9 @@ jobs:
|
||||
|
||||
upload-to-r2:
|
||||
needs: [get-release, build-tauri]
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
uses: ./.github/workflows/upload-to-r2.yml
|
||||
with:
|
||||
tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
jobs:
|
||||
rerun:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: rerun ${{ inputs.run_id }}
|
||||
env:
|
||||
|
||||
@@ -17,6 +17,8 @@ on:
|
||||
jobs:
|
||||
upload-to-r2:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -70,6 +72,8 @@ jobs:
|
||||
if: failure() && fromJSON(github.run_attempt) < 3
|
||||
needs: [upload-to-r2]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
@@ -49,6 +49,7 @@ fastlane/metadata/android/en-US/changelogs
|
||||
result*
|
||||
|
||||
.playwright-mcp/
|
||||
.gstack
|
||||
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
|
||||
Generated
+21
@@ -27,6 +27,7 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
"read-progress-stream",
|
||||
"reqwest 0.12.28",
|
||||
"rsproperties",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -5080,6 +5081,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "pretty-hex"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a65843dfefbafd3c879c683306959a6de478443ffe9c9adf02f5976432402d7"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -5835,6 +5842,20 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsproperties"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b98d55abde44982a00d67a84809166ea8f5c143abc86818f555485f19766cfe4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pretty-hex",
|
||||
"rustix 1.1.4",
|
||||
"thiserror 2.0.18",
|
||||
"zerocopy",
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
|
||||
@@ -178,6 +178,7 @@ For Android:
|
||||
# Initialize the Android environment (run once)
|
||||
rm apps/readest-app/src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout apps/readest-app/src-tauri/gen/android
|
||||
|
||||
pnpm tauri android dev
|
||||
@@ -190,6 +191,7 @@ For iOS:
|
||||
```bash
|
||||
# Set up the iOS environment (run once)
|
||||
pnpm tauri ios init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
|
||||
pnpm tauri ios dev
|
||||
# or if you want to dev on a real device
|
||||
|
||||
+105
@@ -1,5 +1,69 @@
|
||||
# Security Policy
|
||||
|
||||
## Threat Model
|
||||
|
||||
### Overview
|
||||
|
||||
Readest is a cross-platform e-reader (macOS, Windows, Linux, Android, iOS, Web) built on Next.js and Tauri. It processes user-supplied ebook files, syncs data to the cloud, integrates with external services (OPDS catalogs, KOReader, DeepL, Yandex), and handles user authentication.
|
||||
|
||||
### Assets
|
||||
|
||||
| Asset | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| Ebook files | User-uploaded EPUB, MOBI, PDF, and other formats stored locally and in cloud storage |
|
||||
| Reading progress & annotations | Highlights, bookmarks, and notes synced across devices |
|
||||
| User credentials | Authentication tokens and session data for cloud sync |
|
||||
| User preferences & settings | Reading preferences, custom fonts, theme configurations |
|
||||
| External API keys | Translation service credentials (DeepL, Yandex) configured by users |
|
||||
|
||||
### Threat Actors
|
||||
|
||||
| Actor | Motivation |
|
||||
| ----------------------- | ---------------------------------------------------------- |
|
||||
| Malicious ebook author | Craft a malformed file to exploit the parser or renderer |
|
||||
| Network attacker (MitM) | Intercept sync traffic to steal credentials or inject data |
|
||||
| Malicious OPDS server | Serve crafted catalog responses to exploit the client |
|
||||
| Compromised dependency | Supply chain attack via npm or Cargo ecosystem |
|
||||
| Unauthorized user | Access another user's synced library or annotations |
|
||||
|
||||
### Attack Surfaces & Mitigations
|
||||
|
||||
#### 1. Ebook File Parsing
|
||||
|
||||
- **Risk:** Malformed EPUB/MOBI/PDF files could trigger parser bugs, path traversal, or script injection via embedded HTML/JS.
|
||||
- **Mitigations:** Ebook content is rendered in a sandboxed iframe. External script execution is blocked. File parsing is isolated from the main process.
|
||||
|
||||
#### 2. Cloud Sync & Authentication
|
||||
|
||||
- **Risk:** Credential theft, session hijacking, or unauthorized access to another user's library data.
|
||||
- **Mitigations:** All sync traffic uses HTTPS/TLS. Authentication tokens are stored securely (OS keychain/secure storage). Server-side authorization ensures users can only access their own data.
|
||||
|
||||
#### 3. OPDS / External Catalog Integration
|
||||
|
||||
- **Risk:** A malicious OPDS server could serve crafted XML to exploit the parser, or redirect downloads to malicious files.
|
||||
- **Mitigations:** OPDS responses are parsed defensively. Users explicitly add catalog sources. Downloaded files are treated as untrusted user content.
|
||||
|
||||
#### 4. Rendered HTML/JS in Ebook Content
|
||||
|
||||
- **Risk:** Embedded JavaScript in EPUB files could attempt XSS or data exfiltration.
|
||||
- **Mitigations:** Book content is rendered in a sandboxed iframe with scripting restrictions. Navigation outside the book context is blocked.
|
||||
|
||||
#### 5. Supply Chain
|
||||
|
||||
- **Risk:** Compromised npm or Cargo packages could introduce malicious code.
|
||||
- **Mitigations:** Dependencies are pinned via `pnpm-lock.yaml` and `Cargo.lock`. Dependabot and GitHub's dependency review are enabled for automated vulnerability detection.
|
||||
|
||||
#### 6. Desktop Native Code (Tauri)
|
||||
|
||||
- **Risk:** Tauri IPC commands could be abused by malicious web content to access the filesystem or OS APIs.
|
||||
- **Mitigations:** Tauri's allowlist restricts which IPC commands are exposed. File system access is scoped to the application data directory.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Vulnerabilities in user's operating system or browser outside of Readest's control
|
||||
- Physical access attacks to a user's device
|
||||
- Issues in third-party services (DeepL, Yandex, Calibre) themselves
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Readest does not currently maintain separate release channels. Security updates are provided only for the latest release series.
|
||||
@@ -35,3 +99,44 @@ What to expect after you report:
|
||||
|
||||
Please keep vulnerability details private until a fix is available and the
|
||||
maintainers have approved disclosure.
|
||||
|
||||
## Incident Response Plan
|
||||
|
||||
When a security vulnerability is confirmed, we follow this process:
|
||||
|
||||
### 1. Triage (Day 1–2)
|
||||
|
||||
- Assign a severity level (Critical / High / Medium / Low) based on impact and exploitability.
|
||||
- Identify affected versions, components, and users.
|
||||
- Assign an owner responsible for coordinating the response.
|
||||
|
||||
### 2. Containment (Day 1–3)
|
||||
|
||||
- Assess whether an immediate mitigation or workaround can be published.
|
||||
- Limit further exposure where possible (e.g., disable affected features, update dependencies).
|
||||
|
||||
### 3. Remediation (Day 3–14, depending on severity)
|
||||
|
||||
- Develop and internally review a fix.
|
||||
- Validate the fix does not introduce regressions.
|
||||
- Prepare a patched release and update changelog.
|
||||
|
||||
### 4. Disclosure & Release
|
||||
|
||||
- Coordinate disclosure timing with the reporter.
|
||||
- Publish a GitHub Security Advisory with CVE if applicable.
|
||||
- Release the patched version and notify users via release notes.
|
||||
|
||||
### 5. Post-Incident Review
|
||||
|
||||
- Document the root cause, timeline, and resolution.
|
||||
- Update processes or controls to prevent recurrence.
|
||||
|
||||
### Severity Definitions
|
||||
|
||||
| Severity | Description |
|
||||
| -------- | --------------------------------------------------------------------- |
|
||||
| Critical | Remote code execution, full data compromise, or authentication bypass |
|
||||
| High | Significant data exposure, privilege escalation, or denial of service |
|
||||
| Medium | Limited data exposure or functionality disruption |
|
||||
| Low | Minor issues with minimal security impact |
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
|
||||
## Feature Notes
|
||||
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
|
||||
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
|
||||
|
||||
## Patterns
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
|
||||
|
||||
## Architecture Notes
|
||||
- foliate-js is a git submodule at `packages/foliate-js/`
|
||||
@@ -29,6 +33,9 @@
|
||||
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
|
||||
|
||||
## Workflow
|
||||
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
|
||||
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
|
||||
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
|
||||
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
|
||||
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
|
||||
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: Cloudflare Workers WebSocket
|
||||
description: How to open and read WebSockets from Cloudflare Workers (the Node `ws` package does not work) and the Blob binary-frame gotcha
|
||||
type: project
|
||||
originSessionId: ec3d5424-adc2-4fca-836f-df323797489c
|
||||
---
|
||||
# Cloudflare Workers WebSocket on readest-app
|
||||
|
||||
## Why the Node `ws` package fails
|
||||
|
||||
The Node `ws` npm package (used transitively by `isomorphic-ws`) opens WebSockets by calling `http.request({ createConnection })`. The Cloudflare Workers runtime does not implement `options.createConnection`, so any attempt to `new WebSocket(url, { headers })` in a Worker throws:
|
||||
|
||||
```
|
||||
The options.createConnection option is not implemented
|
||||
```
|
||||
|
||||
This applies even with `compatibility_flags = ["nodejs_compat"]`.
|
||||
|
||||
## Correct pattern: fetch-based upgrade
|
||||
|
||||
On Workers you open a WebSocket by calling `fetch()` with an `Upgrade: websocket` header against the **https://** (not `wss://`) form of the URL. The response has `status === 101` and a non-standard `webSocket` property that must be `accept()`ed before use:
|
||||
|
||||
```ts
|
||||
const upgradeUrl = url.replace(/^wss:\/\//i, 'https://');
|
||||
const response = (await fetch(upgradeUrl, {
|
||||
headers: { ...baseHeaders, Upgrade: 'websocket' },
|
||||
})) as Response & { webSocket?: WebSocket & { accept(): void } };
|
||||
|
||||
if (response.status !== 101 || !response.webSocket) {
|
||||
throw new Error(`WebSocket upgrade failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const ws = response.webSocket;
|
||||
ws.addEventListener('message', onMessage);
|
||||
ws.accept();
|
||||
ws.send(payload);
|
||||
```
|
||||
|
||||
Detect the Workers runtime with `typeof globalThis.WebSocketPair !== 'undefined'` — `WebSocketPair` is a Workers-only global.
|
||||
|
||||
## Binary frames arrive as Blob (critical)
|
||||
|
||||
Cloudflare Workers deliver WebSocket binary frames as **`Blob`** — not `ArrayBuffer` (browsers) and not `Uint8Array` (Node `ws`). Blob decoding is async via `blob.arrayBuffer()`, so:
|
||||
|
||||
1. You must serialize decodes through a promise chain to keep frames in receive order — otherwise parallel awaits can merge bytes out of order.
|
||||
2. Any terminal text message (e.g. Edge TTS's `Path: turn.end`) arrives **synchronously** and will finalize the stream before the in-flight Blob decodes have flushed. Always `await pendingBinary` in the turn.end handler and the close handler before checking whether data was received.
|
||||
|
||||
Example skeleton:
|
||||
|
||||
```ts
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
const enqueue = (getBuf: () => Promise<ArrayBufferLike> | ArrayBufferLike) => {
|
||||
pending = pending.then(async () => {
|
||||
const buf = await getBuf();
|
||||
appendBinary(buf);
|
||||
});
|
||||
};
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (data instanceof Blob) enqueue(() => data.arrayBuffer());
|
||||
else if (data instanceof ArrayBuffer) enqueue(() => data);
|
||||
else if (data instanceof Uint8Array) enqueue(() => data.buffer.slice(
|
||||
data.byteOffset, data.byteOffset + data.byteLength,
|
||||
));
|
||||
// ... handle text path: turn.end
|
||||
// -> await pending, then resolve
|
||||
});
|
||||
```
|
||||
|
||||
## Where this is used
|
||||
|
||||
`src/libs/edgeTTS.ts` `#fetchEdgeSpeechWs` has three branches: Tauri (plugin-websocket), Cloudflare Workers (fetch upgrade + Blob handling), and browser/Node fallback (`isomorphic-ws`). The route that exercises the CF branch is `src/app/api/tts/edge/route.ts`, hit when the web client falls back from direct `wss://` (which browsers can't set headers on) to the `/api/tts/edge` HTTPS endpoint.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: No lookbehind regex
|
||||
description: Never use lookbehind assertions in JS/TS code — the build check rejects them for browser compatibility
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Never use lookbehind regex (`(?<=...)` or `(?<!...)`) in JavaScript/TypeScript source code. Use `(?:^|[^...])` or other alternatives instead.
|
||||
|
||||
**Why:** The project has a `check:lookbehind-regex` build check (`pnpm check:all`) that scans the Next.js output chunks and fails if any lookbehind assertions are found. Older WebViews (especially on some Android devices) don't support lookbehinds.
|
||||
|
||||
**How to apply:** When writing regex that needs to assert what comes before a match, use a non-capturing group with alternation (e.g., `(?:^|[^a-z-])`) instead of a negative lookbehind (`(?<![a-z-])`). This applies to all `.ts`/`.tsx`/`.js` files that end up in the build output.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: test-file-filter
|
||||
description: Use pnpm test/test:browser with path directly (no --) to run a single test file
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Run a specific test file with `pnpm test <path>` or `pnpm test:browser <path>` — no `--` separator.
|
||||
|
||||
**Why:** Adding `--` before the path (e.g. `pnpm test:browser -- <path>`) causes vitest to ignore the file filter and run all test files. Without `--`, pnpm appends the path directly to the vitest command, which correctly filters to that file only.
|
||||
|
||||
**How to apply:** Always use `pnpm test src/__tests__/foo.test.ts` or `pnpm test:browser src/__tests__/foo.browser.test.tsx` when verifying a specific test file.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Use worktree for PR/issue/feature work
|
||||
description: Always create a git worktree with pnpm worktree:new before reviewing PRs, fixing issues, or implementing features
|
||||
type: feedback
|
||||
originSessionId: 650f8ff2-980d-459f-ad23-ba0af56e28b5
|
||||
---
|
||||
Always use `pnpm worktree:new <branch-name|pr-number>` to create an isolated worktree before starting work on:
|
||||
- Reviewing a GitHub PR (e.g., `pnpm worktree:new 3809`) → worktree at `~/dev/readest-pr-3809`
|
||||
- Fixing a GitHub issue (e.g., `pnpm worktree:new fix/issue-123`) → worktree at `~/dev/readest-fix-issue-123`
|
||||
- Implementing a feature request (e.g., `pnpm worktree:new feat/my-feature`) → worktree at `~/dev/readest-feat-my-feature`
|
||||
|
||||
Worktree directory convention: `readest-<name>` in the parent of the repo root (`~/dev/`), with slashes replaced by dashes.
|
||||
|
||||
Use `pnpm worktree:rm <branch-name|pr-number>` to clean up when done.
|
||||
|
||||
**Why:** Keeps the current bare repo branch untouched. Each task gets its own isolated workspace with submodules, dependencies, env files, and vendor assets already set up.
|
||||
|
||||
**How to apply:** Before touching any code for a PR review, bug fix, or feature, run `pnpm worktree:new` first. Work inside the new worktree directory (e.g., `~/dev/readest-pr-3809/apps/readest-app/`). Clean up with `pnpm worktree:rm` after merging or finishing.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: Virtuoso + OverlayScrollbars pattern
|
||||
description: How to integrate OverlayScrollbars with react-virtuoso for overlay scrollbars on Android/iOS webviews
|
||||
type: reference
|
||||
originSessionId: 9da59a46-3dff-4a77-b7a4-8de4d07297b6
|
||||
---
|
||||
Virtuoso manages its own internal scroller. On Android WebView (and similar) native scrollbars auto-hide, so users see no scrollbar. The fix: wrap Virtuoso with OverlayScrollbars using the `useOverlayScrollbars` hook — **not** the `OverlayScrollbarsComponent`.
|
||||
|
||||
## Migration from `customScrollParent`
|
||||
|
||||
The previous approach used `customScrollParent` to let an outer `OverlayScrollbarsComponent` own the scroll. This was replaced: Virtuoso now owns its own scroller, and OverlayScrollbars wraps it. This means:
|
||||
- Remove `customScrollParent` prop from Virtuoso/VirtuosoGrid
|
||||
- Remove the outer `OverlayScrollbarsComponent` wrapper
|
||||
- Use `scrollerRef` instead to capture Virtuoso's scroller element
|
||||
- If the parent needs the scroller ref (e.g. for pull-to-refresh, scroll save/restore), expose it via a callback prop like `onScrollerRef`
|
||||
|
||||
## Boilerplate
|
||||
|
||||
```tsx
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
// Inside the component:
|
||||
const osRootRef = useRef<HTMLDivElement>(null);
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
options: { scrollbars: { autoHide: 'scroll' } },
|
||||
events: {
|
||||
initialized(instance) {
|
||||
const { viewport } = instance.elements();
|
||||
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
|
||||
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = osRootRef.current;
|
||||
if (scroller && root) {
|
||||
initialize({ target: root, elements: { viewport: scroller } });
|
||||
}
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => {
|
||||
const div = el instanceof HTMLElement ? el : null;
|
||||
setScroller(div);
|
||||
// If parent needs the scroller (e.g. for pull-to-refresh):
|
||||
onScrollerRef?.(div as HTMLDivElement | null);
|
||||
}, [onScrollerRef]);
|
||||
```
|
||||
|
||||
## JSX structure
|
||||
|
||||
```tsx
|
||||
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
|
||||
<Virtuoso
|
||||
scrollerRef={handleScrollerRef}
|
||||
style={{ height: containerHeight }}
|
||||
totalCount={items.length}
|
||||
itemContent={renderItem}
|
||||
overscan={200}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
For `VirtuosoGrid`, same pattern — pass `scrollerRef={handleScrollerRef}`.
|
||||
|
||||
## Footer spacer
|
||||
|
||||
When Virtuoso owns its own scroller (no `customScrollParent`), the last items may be hidden behind bottom UI (tab bars, safe area). Add a Virtuoso `Footer` component to the components config:
|
||||
|
||||
```tsx
|
||||
const VIRTUOSO_COMPONENTS = {
|
||||
List: MyListComponent,
|
||||
Footer: () => <div style={{ height: 34 }} />,
|
||||
};
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- **`useOverlayScrollbars`** hook, not `OverlayScrollbarsComponent` — the component can't share a viewport with Virtuoso
|
||||
- Wrapper div needs `ref={osRootRef}` and `data-overlayscrollbars-initialize=""`
|
||||
- `initialize({ target: root, elements: { viewport: scroller } })` tells OverlayScrollbars to use Virtuoso's existing scroller as its viewport (no new DOM element)
|
||||
- The `initialized` event **must** restore overflow CSS vars (`--os-viewport-overflow-x/y`) so OverlayScrollbars doesn't fight Virtuoso's scroll management
|
||||
- No custom Scroller component needed — `scrollerRef` replaces the old `Scroller` component pattern (e.g. `TOCScroller` was removed)
|
||||
|
||||
## Used in
|
||||
|
||||
- `src/app/library/components/Bookshelf.tsx` — library grid/list with parent scroller exposure for pull-to-refresh and scroll save/restore
|
||||
- `src/app/reader/components/sidebar/TOCView.tsx` — sidebar TOC (self-contained, no parent scroller needed)
|
||||
Submodule apps/readest-app/.claude/skills/gstack updated: 6169273d16...c6e6a21d1a
@@ -9,6 +9,7 @@
|
||||
# testing
|
||||
/coverage
|
||||
.test-sandbox-node/
|
||||
.vitest-attachments/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -56,6 +56,15 @@ pnpm clippy:check # Lint Rust code (src-tauri)
|
||||
|
||||
Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Custom Tauri plugins are in `src-tauri/plugins/`.
|
||||
|
||||
## Git Worktrees
|
||||
|
||||
Always use `pnpm worktree:new <branch-name|pr-number>` to create worktrees. Never use `git worktree add` directly — the script handles submodule initialization (simplecc WASM, foliate-js), dependency installation, `.env` copying, vendor assets, and Tauri gen symlinks that are required for lint and tests to pass.
|
||||
|
||||
```bash
|
||||
pnpm worktree:new feat/my-feature # New branch from origin/main
|
||||
pnpm worktree:new 3837 # Checkout PR #3837 with push access to fork
|
||||
```
|
||||
|
||||
## Project Rules
|
||||
|
||||
Rules are in `.claude/rules/`: test-first, typescript, verification.
|
||||
@@ -68,10 +77,6 @@ See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `s
|
||||
|
||||
See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges.
|
||||
|
||||
## Web Browsing & QA (gstack)
|
||||
|
||||
For all web browsing, QA testing, and site interaction, use the `/browse` skill from gstack. **Never use `mcp__claude-in-chrome__*` tools directly.**
|
||||
|
||||
Available gstack skills:
|
||||
|
||||
- `/plan-ceo-review` — CEO/founder-mode plan review
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
## Adding a Config to `ViewSettings`
|
||||
|
||||
`ViewSettings` is the per-book view state (layout, fonts, colors, TTS, etc.) composed from several sub-interfaces defined in `src/types/book.ts`. A matching `globalViewSettings` lives on `SystemSettings` and acts as the default for every book. The per-book value is derived by merging the global defaults with any overrides stored on the book's `BookConfig`.
|
||||
|
||||
This doc covers how to plumb a new config through the three layers:
|
||||
|
||||
1. **Types** — `src/types/book.ts`
|
||||
2. **Defaults** — `src/services/constants.ts` and `src/services/settingsService.ts`
|
||||
3. **Read/write** — components via `saveViewSettings` from `src/helpers/settings.ts`
|
||||
|
||||
### Pick a Pattern
|
||||
|
||||
**Pattern A — add a field to an existing sub-interface.** Use when the new option belongs to an existing bundle (`BookLayout`, `BookStyle`, `BookFont`, `ViewConfig`, `TTSConfig`, etc.).
|
||||
|
||||
**Pattern B — introduce a new sub-interface.** Use when several related fields cluster together, or when a single field is semantically its own concept (e.g. `ParagraphModeConfig`, `ViewSettingsConfig`). Then extend `ViewSettings` with it.
|
||||
|
||||
Both patterns follow the same three-layer flow. The only difference is whether you reuse an existing `DEFAULT_*` constant or add a new one.
|
||||
|
||||
### Step 1 — Declare the Type
|
||||
|
||||
**Pattern A** — add a required field to the sub-interface that owns this concern:
|
||||
|
||||
```ts
|
||||
// src/types/book.ts
|
||||
export interface ViewConfig {
|
||||
// ...existing fields
|
||||
myNewToggle: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern B** — define a new interface and extend `ViewSettings`:
|
||||
|
||||
```ts
|
||||
// src/types/book.ts
|
||||
export interface ViewSettingsConfig {
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
export interface ViewSettings
|
||||
extends
|
||||
BookLayout,
|
||||
BookStyle,
|
||||
// ...other bundles
|
||||
ViewSettingsConfig {}
|
||||
```
|
||||
|
||||
Fields should be **required**, not optional. Optional fields make downstream code defensive. Provide a sensible default in Step 2 instead.
|
||||
|
||||
### Step 2 — Provide a Default
|
||||
|
||||
Every field in `ViewSettings` must have a default, otherwise `getDefaultViewSettings()` produces an incomplete object.
|
||||
|
||||
**Pattern A** — add the value to the existing `DEFAULT_*` constant:
|
||||
|
||||
```ts
|
||||
// src/services/constants.ts
|
||||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
// ...existing defaults
|
||||
myNewToggle: false,
|
||||
};
|
||||
```
|
||||
|
||||
**Pattern B** — add a `DEFAULT_*_CONFIG` constant for your new bundle, then register it in `getDefaultViewSettings`:
|
||||
|
||||
```ts
|
||||
// src/services/constants.ts
|
||||
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
|
||||
isGlobal: true,
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/services/settingsService.ts
|
||||
export function getDefaultViewSettings(ctx: Context): ViewSettings {
|
||||
return {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
// ...other bundles
|
||||
...DEFAULT_VIEW_SETTINGS_CONFIG,
|
||||
// platform overrides go last so they win
|
||||
...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}),
|
||||
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Platform Overrides
|
||||
|
||||
To tweak the default on mobile, e-ink, or CJK locales, add the field to the matching `Partial<ViewSettings>` constant (`DEFAULT_MOBILE_VIEW_SETTINGS`, `DEFAULT_EINK_VIEW_SETTINGS`, `DEFAULT_CJK_VIEW_SETTINGS`). These are spread after the base defaults in `getDefaultViewSettings`, so they override them.
|
||||
|
||||
#### Migration
|
||||
|
||||
Old `settings.json` files on disk won't have your new field. `loadSettings` merges the stored blob over fresh defaults:
|
||||
|
||||
```ts
|
||||
settings.globalViewSettings = {
|
||||
...getDefaultViewSettings(ctx),
|
||||
...settings.globalViewSettings,
|
||||
};
|
||||
```
|
||||
|
||||
So existing users pick up your default automatically — no explicit migration is needed for adding a field. Only bump `SYSTEM_SETTINGS_VERSION` if you are reshaping existing data.
|
||||
|
||||
### Step 3 — Read and Write from Components
|
||||
|
||||
Read the current value by preferring the per-book settings, falling back to the global:
|
||||
|
||||
```tsx
|
||||
const { settings } = useSettingsStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
|
||||
```
|
||||
|
||||
Write via `saveViewSettings` — never mutate the store directly. The helper handles the global-vs-per-book routing, persists to disk, and re-applies styles when needed.
|
||||
|
||||
```tsx
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
|
||||
const [myNewToggle, setMyNewToggle] = useState(viewSettings.myNewToggle);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'myNewToggle', myNewToggle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [myNewToggle]);
|
||||
```
|
||||
|
||||
The `useEffect`-on-local-state pattern is the established convention in `LayoutPanel`, `ControlPanel`, `ColorPanel`, etc. It keeps the UI responsive and batches store updates until the user stops interacting.
|
||||
|
||||
#### Signature
|
||||
|
||||
```ts
|
||||
saveViewSettings<K extends keyof ViewSettings>(
|
||||
envConfig,
|
||||
bookKey,
|
||||
key: K,
|
||||
value: ViewSettings[K],
|
||||
skipGlobal = false, // true → only update this book's settings
|
||||
applyStyles = true, // false → don't re-run style recomputation
|
||||
)
|
||||
```
|
||||
|
||||
**Global vs. per-book routing.** `saveViewSettings` inspects `viewSettings.isGlobal` on the target book. When `true` (the default), it writes to `globalViewSettings`, loops through every open book, and saves to disk. When `false`, it writes only to the one book's config.
|
||||
|
||||
**Skip global.** Pass `skipGlobal=true` when the setting is meta — i.e. it describes the settings system itself, not book content. The canonical case is toggling `isGlobal` from `DialogMenu`: you want the scope flag to live on the specific book without propagating it to every other book.
|
||||
|
||||
```tsx
|
||||
saveViewSettings(envConfig, bookKey, 'isGlobal', !isSettingsGlobal, true, false);
|
||||
```
|
||||
|
||||
**Skip styles.** Pass `applyStyles=false` for options that don't affect CSS rendering (toggles, flags, metadata). This avoids an unnecessary `renderer.setStyles` call.
|
||||
|
||||
### Step 4 — Support Reset
|
||||
|
||||
If your field should be resettable from the panel menu, register a setter in the panel's `handleReset` via `useResetViewSettings`:
|
||||
|
||||
```tsx
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
myNewToggle: setMyNewToggle,
|
||||
// ...other setters
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
The hook resolves the default by reading from `getDefaultViewSettings(ctx)` and calls each provided setter with that value, which then fires your `useEffect` and persists the change.
|
||||
|
||||
### Step 5 — Register in the Command Palette
|
||||
|
||||
If your setting has a visible row in a panel, register it in the matching `*PanelItems` array in `src/services/commandRegistry.ts`. This wires it into the command-palette fuzzy search so users can jump straight to it.
|
||||
|
||||
```ts
|
||||
// src/services/commandRegistry.ts
|
||||
const layoutPanelItems = [
|
||||
// ...existing entries
|
||||
{
|
||||
id: 'settings.layout.myNewToggle',
|
||||
labelKey: _('My New Toggle'),
|
||||
keywords: ['search', 'terms', 'for', 'discoverability'],
|
||||
section: 'Paragraph',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
- `id` must match the `data-setting-id` attribute on the panel row. The palette uses it to scroll/highlight the target control.
|
||||
- `labelKey` uses `stubTranslation` (imported as `_`) so the extractor picks it up — the same string that appears in the panel.
|
||||
- `keywords` broadens fuzzy-search hits beyond the label; include synonyms, related jargon, and the panel section name.
|
||||
- `section` groups the entry in the palette results (matches the panel's sub-header: `Layout`, `Paragraph`, `Page`, `Header & Footer`, etc.).
|
||||
|
||||
Skip this step only for settings that don't surface as a user-visible row (hidden toggles, flags used by other settings).
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't make the field optional** just to skip providing a default. Add a default in Step 2 instead.
|
||||
- **Don't mutate `settings.globalViewSettings` directly** in a component — `saveViewSettings` already handles global propagation when `isGlobal` is true.
|
||||
- **Don't bump `SYSTEM_SETTINGS_VERSION`** for a plain additive field. The load-time merge handles it.
|
||||
|
||||
### Minimal Checklist
|
||||
|
||||
- [ ] Field or new interface added in `src/types/book.ts`
|
||||
- [ ] Default value in `src/services/constants.ts`
|
||||
- [ ] New `DEFAULT_*_CONFIG` spread into `getDefaultViewSettings` (Pattern B only)
|
||||
- [ ] Optional mobile/eink/CJK override in the matching `Partial<ViewSettings>` constant
|
||||
- [ ] Read via `getViewSettings(bookKey) || settings.globalViewSettings`
|
||||
- [ ] Write via `saveViewSettings(envConfig, bookKey, 'key', value)`
|
||||
- [ ] Reset setter wired into `useResetViewSettings` if the panel has a reset menu
|
||||
- [ ] Command-palette entry added to the matching `*PanelItems` array in `src/services/commandRegistry.ts`, with an `id` that matches the panel row's `data-setting-id`
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.10.4",
|
||||
"version": "0.10.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,9 +16,9 @@
|
||||
"build-tauri": "dotenv -e .env.tauri -- next build",
|
||||
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
|
||||
"lint": "tsgo --noEmit && biome check .",
|
||||
"test": "dotenv -e .env -e .env.test.local vitest",
|
||||
"test": "dotenv -e .env -e .env.test.local -- vitest",
|
||||
"test:coverage": "dotenv -e .env -e .env.test.local -- vitest run --coverage",
|
||||
"test:browser": "vitest --config vitest.browser.config.mts --watch=false",
|
||||
"test:browser": "dotenv -e .env -e .env.test.local -- vitest run --config vitest.browser.config.mts",
|
||||
"test:tauri": "bash scripts/test-tauri.sh",
|
||||
"test:pr:web": "pnpm test -- --watch=false && pnpm test:browser",
|
||||
"test:pr:tauri": "bash scripts/test-tauri.sh",
|
||||
@@ -66,7 +66,9 @@
|
||||
"check:translations": "count=$(grep -rno '__STRING_NOT_TRANSLATED__' public/locales/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Untranslated strings found!'; exit 1; else echo '✅ All strings translated.'; fi",
|
||||
"check:lookbehind-regex": "count=$(grep -rnoE '\\(\\?<[!=]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Lookbehind regex found in output!'; exit 1; else echo '✅ No lookbehind regex found.'; fi",
|
||||
"check:all": "pnpm check:translations && pnpm check:lookbehind-regex",
|
||||
"build-check": "pnpm build && pnpm build-web && pnpm check:all"
|
||||
"build-check": "pnpm build && pnpm build-web && pnpm check:all",
|
||||
"worktree:new": "pnpm exec tsx scripts/worktree-new.ts",
|
||||
"worktree:rm": "pnpm exec tsx scripts/worktree-rm.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.49",
|
||||
@@ -80,7 +82,7 @@
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@opennextjs/cloudflare": "^1.17.3",
|
||||
"@opennextjs/cloudflare": "^1.19.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -146,15 +148,15 @@
|
||||
"lunr": "^2.3.9",
|
||||
"marked": "^15.0.12",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.2.2",
|
||||
"next": "16.2.3",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nunjucks": "^3.2.4",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"posthog-js": "^1.246.0",
|
||||
"react": "19.2.4",
|
||||
"react": "19.2.5",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dom": "19.2.5",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
@@ -196,7 +198,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260312.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-rsc": "^0.5.21",
|
||||
"@vitejs/plugin-rsc": "^0.5.23",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/browser-webdriverio": "^4.0.18",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
@@ -220,15 +222,15 @@
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react-server-dom-webpack": "^19.2.4",
|
||||
"react-server-dom-webpack": "^19.2.5",
|
||||
"serwist": "^9.3.0",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.7.2",
|
||||
"vinext": "^0.0.21",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.18",
|
||||
"wrangler": "^4.77.0"
|
||||
"wrangler": "^4.81.1"
|
||||
},
|
||||
"browserslist": [
|
||||
"chrome 92",
|
||||
|
||||
@@ -1235,5 +1235,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "فشلت مزامنة تقدم Hardcover: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "الإبقاء على معرف المصدر الحالي",
|
||||
"Toggle Toolbar": "تبديل شريط الأدوات"
|
||||
"Toggle Toolbar": "تبديل شريط الأدوات",
|
||||
"Split Hyphens": "تقسيم الواصلات",
|
||||
"Apply Theme Colors to PDF": "تطبيق ألوان السمة على PDF",
|
||||
"Name": "الاسم",
|
||||
"Unavailable": "غير متاح",
|
||||
"Remove": "إزالة",
|
||||
"Edit OPDS Catalog": "تعديل كتالوج OPDS",
|
||||
"Save Changes": "حفظ التغييرات",
|
||||
"Use Book Layout": "استخدام تخطيط الكتاب"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover অগ্রগতি সিঙ্ক ব্যর্থ: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "বিদ্যমান উৎস শনাক্তকারী রাখুন",
|
||||
"Toggle Toolbar": "টুলবার টগল করুন"
|
||||
"Toggle Toolbar": "টুলবার টগল করুন",
|
||||
"Split Hyphens": "হাইফেন বিভক্ত করুন",
|
||||
"Apply Theme Colors to PDF": "PDF-এ থিম রং প্রয়োগ করুন",
|
||||
"Name": "নাম",
|
||||
"Unavailable": "অনুপলব্ধ",
|
||||
"Remove": "সরান",
|
||||
"Edit OPDS Catalog": "OPDS ক্যাটালগ সম্পাদনা",
|
||||
"Save Changes": "পরিবর্তন সংরক্ষণ",
|
||||
"Use Book Layout": "বইয়ের লেআউট ব্যবহার করুন"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover ཀློག་འགྲོས་ཟླ་སྒྲིག་མི་ཚོགས: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "ཡོད་བཞིན་པའི་འབྱུང་ཁུངས་ཐོབ་ཐང་གཞག",
|
||||
"Toggle Toolbar": "ལག་ཆའི་ཚན་བར་བརྗེ་བ།"
|
||||
"Toggle Toolbar": "ལག་ཆའི་ཚན་བར་བརྗེ་བ།",
|
||||
"Split Hyphens": "སྦྲེལ་རྟགས་གཅད་པ",
|
||||
"Apply Theme Colors to PDF": "PDF ལ་བརྗོད་དོན་ཚོས་གཞི་སྤྱོད་པ",
|
||||
"Name": "མིང",
|
||||
"Unavailable": "རག་མི་ཐུབ",
|
||||
"Remove": "བསུབ།",
|
||||
"Edit OPDS Catalog": "OPDS དཀར་ཆག་རྩོམ་སྒྲིག",
|
||||
"Save Changes": "བསྒྱུར་བཅོས་ཉར་ཚགས།",
|
||||
"Use Book Layout": "དཔེ་ཆའི་བཀོད་སྒྲིག་སྤྱོད་པ"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover-Fortschrittssynchronisierung fehlgeschlagen: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Vorhandene Quellkennung beibehalten",
|
||||
"Toggle Toolbar": "Symbolleiste umschalten"
|
||||
"Toggle Toolbar": "Symbolleiste umschalten",
|
||||
"Split Hyphens": "Bindestriche trennen",
|
||||
"Apply Theme Colors to PDF": "Designfarben auf PDF anwenden",
|
||||
"Name": "Name",
|
||||
"Unavailable": "Nicht verfügbar",
|
||||
"Remove": "Entfernen",
|
||||
"Edit OPDS Catalog": "OPDS-Katalog bearbeiten",
|
||||
"Save Changes": "Änderungen speichern",
|
||||
"Use Book Layout": "Buchlayout verwenden"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Αποτυχία συγχρονισμού προόδου Hardcover: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Διατήρηση υπάρχοντος αναγνωριστικού πηγής",
|
||||
"Toggle Toolbar": "Εναλλαγή γραμμής εργαλείων"
|
||||
"Toggle Toolbar": "Εναλλαγή γραμμής εργαλείων",
|
||||
"Split Hyphens": "Διαχωρισμός παύλων",
|
||||
"Apply Theme Colors to PDF": "Εφαρμογή χρωμάτων θέματος σε PDF",
|
||||
"Name": "Όνομα",
|
||||
"Unavailable": "Μη διαθέσιμο",
|
||||
"Remove": "Αφαίρεση",
|
||||
"Edit OPDS Catalog": "Επεξεργασία καταλόγου OPDS",
|
||||
"Save Changes": "Αποθήκευση αλλαγών",
|
||||
"Use Book Layout": "Χρήση διάταξης βιβλίου"
|
||||
}
|
||||
|
||||
@@ -1196,5 +1196,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Error al sincronizar progreso de Hardcover: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Mantener el identificador de origen existente",
|
||||
"Toggle Toolbar": "Alternar barra de herramientas"
|
||||
"Toggle Toolbar": "Alternar barra de herramientas",
|
||||
"Split Hyphens": "Dividir guiones",
|
||||
"Apply Theme Colors to PDF": "Aplicar colores del tema al PDF",
|
||||
"Name": "Nombre",
|
||||
"Unavailable": "No disponible",
|
||||
"Remove": "Eliminar",
|
||||
"Edit OPDS Catalog": "Editar catálogo OPDS",
|
||||
"Save Changes": "Guardar cambios",
|
||||
"Use Book Layout": "Usar el diseño del libro"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "همگامسازی پیشرفت Hardcover ناموفق: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "حفظ شناسه منبع موجود",
|
||||
"Toggle Toolbar": "تغییر نوار ابزار"
|
||||
"Toggle Toolbar": "تغییر نوار ابزار",
|
||||
"Split Hyphens": "تقسیم خطتیرهها",
|
||||
"Apply Theme Colors to PDF": "اعمال رنگهای پوسته روی PDF",
|
||||
"Name": "نام",
|
||||
"Unavailable": "ناموجود",
|
||||
"Remove": "حذف",
|
||||
"Edit OPDS Catalog": "ویرایش کاتالوگ OPDS",
|
||||
"Save Changes": "ذخیره تغییرات",
|
||||
"Use Book Layout": "استفاده از چیدمان کتاب"
|
||||
}
|
||||
|
||||
@@ -1196,5 +1196,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Échec de la synchronisation de la progression Hardcover : {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Conserver l'identifiant source existant",
|
||||
"Toggle Toolbar": "Basculer la barre d'outils"
|
||||
"Toggle Toolbar": "Basculer la barre d'outils",
|
||||
"Split Hyphens": "Séparer les traits d'union",
|
||||
"Apply Theme Colors to PDF": "Appliquer les couleurs du thème au PDF",
|
||||
"Name": "Nom",
|
||||
"Unavailable": "Indisponible",
|
||||
"Remove": "Supprimer",
|
||||
"Edit OPDS Catalog": "Modifier le catalogue OPDS",
|
||||
"Save Changes": "Enregistrer les modifications",
|
||||
"Use Book Layout": "Utiliser la mise en page du livre"
|
||||
}
|
||||
|
||||
@@ -1196,5 +1196,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "סנכרון התקדמות Hardcover נכשל: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "שמור על מזהה המקור הקיים",
|
||||
"Toggle Toolbar": "הצג/הסתר סרגל כלים"
|
||||
"Toggle Toolbar": "הצג/הסתר סרגל כלים",
|
||||
"Split Hyphens": "פיצול מקפים",
|
||||
"Apply Theme Colors to PDF": "החל צבעי ערכת נושא על PDF",
|
||||
"Name": "שם",
|
||||
"Unavailable": "לא זמין",
|
||||
"Remove": "הסר",
|
||||
"Edit OPDS Catalog": "עריכת קטלוג OPDS",
|
||||
"Save Changes": "שמור שינויים",
|
||||
"Use Book Layout": "השתמש בפריסת הספר"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover प्रगति सिंक विफल: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "मौजूदा स्रोत पहचानकर्ता रखें",
|
||||
"Toggle Toolbar": "टूलबार टॉगल करें"
|
||||
"Toggle Toolbar": "टूलबार टॉगल करें",
|
||||
"Split Hyphens": "हाइफ़न विभाजित करें",
|
||||
"Apply Theme Colors to PDF": "PDF पर थीम रंग लागू करें",
|
||||
"Name": "नाम",
|
||||
"Unavailable": "अनुपलब्ध",
|
||||
"Remove": "हटाएँ",
|
||||
"Edit OPDS Catalog": "OPDS कैटलॉग संपादित करें",
|
||||
"Save Changes": "परिवर्तन सहेजें",
|
||||
"Use Book Layout": "पुस्तक का लेआउट उपयोग करें"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Sunset": "Naplemente",
|
||||
"Reveal in Finder": "Megjelenítés a Finderben",
|
||||
"Reveal in File Explorer": "Megjelenítés a Fájlkezelőben",
|
||||
"Reveal in Folder": "Megjelenítés a mappában"
|
||||
"Reveal in Folder": "Megjelenítés a mappában",
|
||||
"Split Hyphens": "Kötőjelek felosztása",
|
||||
"Apply Theme Colors to PDF": "Téma színeinek alkalmazása PDF-re",
|
||||
"Name": "Név",
|
||||
"Unavailable": "Nem elérhető",
|
||||
"Remove": "Eltávolítás",
|
||||
"Edit OPDS Catalog": "OPDS katalógus szerkesztése",
|
||||
"Save Changes": "Módosítások mentése",
|
||||
"Use Book Layout": "Könyv elrendezésének használata"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Sinkronisasi progres Hardcover gagal: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Pertahankan pengidentifikasi sumber yang ada",
|
||||
"Toggle Toolbar": "Alihkan Bilah Alat"
|
||||
"Toggle Toolbar": "Alihkan Bilah Alat",
|
||||
"Split Hyphens": "Pisahkan Tanda Hubung",
|
||||
"Apply Theme Colors to PDF": "Terapkan Warna Tema ke PDF",
|
||||
"Name": "Nama",
|
||||
"Unavailable": "Tidak Tersedia",
|
||||
"Remove": "Hapus",
|
||||
"Edit OPDS Catalog": "Edit Katalog OPDS",
|
||||
"Save Changes": "Simpan Perubahan",
|
||||
"Use Book Layout": "Gunakan tata letak buku"
|
||||
}
|
||||
|
||||
@@ -1196,5 +1196,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Sincronizzazione progressi Hardcover non riuscita: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Mantieni l'identificatore sorgente esistente",
|
||||
"Toggle Toolbar": "Mostra/nascondi barra degli strumenti"
|
||||
"Toggle Toolbar": "Mostra/nascondi barra degli strumenti",
|
||||
"Split Hyphens": "Dividi trattini",
|
||||
"Apply Theme Colors to PDF": "Applica colori tema al PDF",
|
||||
"Name": "Nome",
|
||||
"Unavailable": "Non disponibile",
|
||||
"Remove": "Rimuovi",
|
||||
"Edit OPDS Catalog": "Modifica catalogo OPDS",
|
||||
"Save Changes": "Salva modifiche",
|
||||
"Use Book Layout": "Usa layout del libro"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover進捗の同期に失敗: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "既存のソース識別子を保持",
|
||||
"Toggle Toolbar": "ツールバーの表示切替"
|
||||
"Toggle Toolbar": "ツールバーの表示切替",
|
||||
"Split Hyphens": "ハイフンで分割",
|
||||
"Apply Theme Colors to PDF": "テーマカラーをPDFに適用",
|
||||
"Name": "名前",
|
||||
"Unavailable": "利用不可",
|
||||
"Remove": "削除",
|
||||
"Edit OPDS Catalog": "OPDSカタログを編集",
|
||||
"Save Changes": "変更を保存",
|
||||
"Use Book Layout": "書籍のレイアウトを使用"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover 진행 상황 동기화 실패: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "기존 소스 식별자 유지",
|
||||
"Toggle Toolbar": "도구 모음 전환"
|
||||
"Toggle Toolbar": "도구 모음 전환",
|
||||
"Split Hyphens": "하이픈 분리",
|
||||
"Apply Theme Colors to PDF": "PDF에 테마 색상 적용",
|
||||
"Name": "이름",
|
||||
"Unavailable": "사용 불가",
|
||||
"Remove": "삭제",
|
||||
"Edit OPDS Catalog": "OPDS 카탈로그 편집",
|
||||
"Save Changes": "변경 사항 저장",
|
||||
"Use Book Layout": "책 레이아웃 사용"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Penyegerakan kemajuan Hardcover gagal: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Kekalkan pengecam sumber sedia ada",
|
||||
"Toggle Toolbar": "Togol Bar Alat"
|
||||
"Toggle Toolbar": "Togol Bar Alat",
|
||||
"Split Hyphens": "Pisahkan Sempang",
|
||||
"Apply Theme Colors to PDF": "Gunakan Warna Tema pada PDF",
|
||||
"Name": "Nama",
|
||||
"Unavailable": "Tidak Tersedia",
|
||||
"Remove": "Alih keluar",
|
||||
"Edit OPDS Catalog": "Edit Katalog OPDS",
|
||||
"Save Changes": "Simpan Perubahan",
|
||||
"Use Book Layout": "Guna susun atur buku"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Synchronisatie van Hardcover-voortgang mislukt: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Bestaande bronidentificatie behouden",
|
||||
"Toggle Toolbar": "Werkbalk in-/uitschakelen"
|
||||
"Toggle Toolbar": "Werkbalk in-/uitschakelen",
|
||||
"Split Hyphens": "Afbreekstreepjes splitsen",
|
||||
"Apply Theme Colors to PDF": "Themakleuren toepassen op PDF",
|
||||
"Name": "Naam",
|
||||
"Unavailable": "Niet beschikbaar",
|
||||
"Remove": "Verwijderen",
|
||||
"Edit OPDS Catalog": "OPDS-catalogus bewerken",
|
||||
"Save Changes": "Wijzigingen opslaan",
|
||||
"Use Book Layout": "Boekindeling gebruiken"
|
||||
}
|
||||
|
||||
@@ -1209,5 +1209,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Synchronizacja postępu Hardcover nie powiodła się: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Zachowaj istniejący identyfikator źródła",
|
||||
"Toggle Toolbar": "Przełącz pasek narzędzi"
|
||||
"Toggle Toolbar": "Przełącz pasek narzędzi",
|
||||
"Split Hyphens": "Dziel łączniki",
|
||||
"Apply Theme Colors to PDF": "Zastosuj kolory motywu do PDF",
|
||||
"Name": "Nazwa",
|
||||
"Unavailable": "Niedostępne",
|
||||
"Remove": "Usuń",
|
||||
"Edit OPDS Catalog": "Edytuj katalog OPDS",
|
||||
"Save Changes": "Zapisz zmiany",
|
||||
"Use Book Layout": "Użyj układu książki"
|
||||
}
|
||||
|
||||
@@ -1196,5 +1196,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Falha na sincronização de progresso do Hardcover: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Manter o identificador de origem existente",
|
||||
"Toggle Toolbar": "Alternar barra de ferramentas"
|
||||
"Toggle Toolbar": "Alternar barra de ferramentas",
|
||||
"Split Hyphens": "Dividir hífens",
|
||||
"Apply Theme Colors to PDF": "Aplicar cores do tema ao PDF",
|
||||
"Name": "Nome",
|
||||
"Unavailable": "Indisponível",
|
||||
"Remove": "Remover",
|
||||
"Edit OPDS Catalog": "Editar catálogo OPDS",
|
||||
"Save Changes": "Salvar alterações",
|
||||
"Use Book Layout": "Usar layout do livro"
|
||||
}
|
||||
|
||||
@@ -1196,5 +1196,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Sincronizarea progresului Hardcover a eșuat: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Păstrați identificatorul sursă existent",
|
||||
"Toggle Toolbar": "Comutare bară de instrumente"
|
||||
"Toggle Toolbar": "Comutare bară de instrumente",
|
||||
"Split Hyphens": "Desparte cratimele",
|
||||
"Apply Theme Colors to PDF": "Aplică culorile temei la PDF",
|
||||
"Name": "Nume",
|
||||
"Unavailable": "Indisponibil",
|
||||
"Remove": "Elimină",
|
||||
"Edit OPDS Catalog": "Editare catalog OPDS",
|
||||
"Save Changes": "Salvare modificări",
|
||||
"Use Book Layout": "Folosește aspectul cărții"
|
||||
}
|
||||
|
||||
@@ -1209,5 +1209,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Ошибка синхронизации прогресса Hardcover: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Сохранить существующий идентификатор источника",
|
||||
"Toggle Toolbar": "Переключить панель инструментов"
|
||||
"Toggle Toolbar": "Переключить панель инструментов",
|
||||
"Split Hyphens": "Разделить дефисы",
|
||||
"Apply Theme Colors to PDF": "Применить цвета темы к PDF",
|
||||
"Name": "Имя",
|
||||
"Unavailable": "Недоступно",
|
||||
"Remove": "Удалить",
|
||||
"Edit OPDS Catalog": "Редактировать каталог OPDS",
|
||||
"Save Changes": "Сохранить изменения",
|
||||
"Use Book Layout": "Использовать макет книги"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover ප්රගති සමමුහුර්තකරණය අසාර්ථකයි: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "පවතින මූලාශ්ර හඳුනාගැනීම තබාගන්න",
|
||||
"Toggle Toolbar": "මෙවලම් තීරුව ටොගල් කරන්න"
|
||||
"Toggle Toolbar": "මෙවලම් තීරුව ටොගල් කරන්න",
|
||||
"Split Hyphens": "හයිෆන් බෙදන්න",
|
||||
"Apply Theme Colors to PDF": "PDF වෙත තේමා වර්ණ යොදන්න",
|
||||
"Name": "නම",
|
||||
"Unavailable": "ලබාගත නොහැක",
|
||||
"Remove": "ඉවත් කරන්න",
|
||||
"Edit OPDS Catalog": "OPDS නාමාවලිය සංස්කරණය",
|
||||
"Save Changes": "වෙනස්කම් සුරකින්න",
|
||||
"Use Book Layout": "පොතේ සැකැස්ම භාවිත කරන්න"
|
||||
}
|
||||
|
||||
@@ -1209,5 +1209,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Sinhronizacija napredka Hardcover ni uspela: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Ohrani obstoječi identifikator vira",
|
||||
"Toggle Toolbar": "Preklopi orodno vrstico"
|
||||
"Toggle Toolbar": "Preklopi orodno vrstico",
|
||||
"Split Hyphens": "Razdeli vezaje",
|
||||
"Apply Theme Colors to PDF": "Uporabi barve teme za PDF",
|
||||
"Name": "Ime",
|
||||
"Unavailable": "Nedosegljivo",
|
||||
"Remove": "Odstrani",
|
||||
"Edit OPDS Catalog": "Uredi katalog OPDS",
|
||||
"Save Changes": "Shrani spremembe",
|
||||
"Use Book Layout": "Uporabi postavitev knjige"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Synkronisering av Hardcover-framsteg misslyckades: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Behåll befintlig källidentifierare",
|
||||
"Toggle Toolbar": "Visa/dölj verktygsfältet"
|
||||
"Toggle Toolbar": "Visa/dölj verktygsfältet",
|
||||
"Split Hyphens": "Dela bindestreck",
|
||||
"Apply Theme Colors to PDF": "Tillämpa temafärger på PDF",
|
||||
"Name": "Namn",
|
||||
"Unavailable": "Inte tillgängligt",
|
||||
"Remove": "Ta bort",
|
||||
"Edit OPDS Catalog": "Redigera OPDS-katalog",
|
||||
"Save Changes": "Spara ändringar",
|
||||
"Use Book Layout": "Använd bokens layout"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover முன்னேற்ற ஒத்திசைவு தோல்வி: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "ஏற்கனவே உள்ள மூல அடையாளங்காட்டியை வைத்திருக்கவும்",
|
||||
"Toggle Toolbar": "கருவிப்பட்டியை நிலைமாற்று"
|
||||
"Toggle Toolbar": "கருவிப்பட்டியை நிலைமாற்று",
|
||||
"Split Hyphens": "ஹைபன்களைப் பிரி",
|
||||
"Apply Theme Colors to PDF": "PDF-க்கு தீம் நிறங்களைப் பயன்படுத்து",
|
||||
"Name": "பெயர்",
|
||||
"Unavailable": "கிடைக்கவில்லை",
|
||||
"Remove": "நீக்கு",
|
||||
"Edit OPDS Catalog": "OPDS பட்டியலைத் திருத்து",
|
||||
"Save Changes": "மாற்றங்களைச் சேமி",
|
||||
"Use Book Layout": "புத்தகத்தின் அமைப்பைப் பயன்படுத்து"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "การซิงค์ความคืบหน้า Hardcover ล้มเหลว: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "เก็บตัวระบุแหล่งที่มาที่มีอยู่",
|
||||
"Toggle Toolbar": "สลับแถบเครื่องมือ"
|
||||
"Toggle Toolbar": "สลับแถบเครื่องมือ",
|
||||
"Split Hyphens": "แยกยัติภังค์",
|
||||
"Apply Theme Colors to PDF": "ใช้สีธีมกับ PDF",
|
||||
"Name": "ชื่อ",
|
||||
"Unavailable": "ไม่พร้อมใช้งาน",
|
||||
"Remove": "ลบ",
|
||||
"Edit OPDS Catalog": "แก้ไขแคตตาล็อก OPDS",
|
||||
"Save Changes": "บันทึกการเปลี่ยนแปลง",
|
||||
"Use Book Layout": "ใช้เลย์เอาต์ของหนังสือ"
|
||||
}
|
||||
|
||||
@@ -1183,5 +1183,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover ilerleme senkronizasyonu başarısız: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Mevcut kaynak tanımlayıcısını koru",
|
||||
"Toggle Toolbar": "Araç Çubuğunu Aç/Kapat"
|
||||
"Toggle Toolbar": "Araç Çubuğunu Aç/Kapat",
|
||||
"Split Hyphens": "Tireleri Böl",
|
||||
"Apply Theme Colors to PDF": "Tema Renklerini PDF'ye Uygula",
|
||||
"Name": "Ad",
|
||||
"Unavailable": "Kullanılamaz",
|
||||
"Remove": "Kaldır",
|
||||
"Edit OPDS Catalog": "OPDS Kataloğunu Düzenle",
|
||||
"Save Changes": "Değişiklikleri Kaydet",
|
||||
"Use Book Layout": "Kitap düzenini kullan"
|
||||
}
|
||||
|
||||
@@ -1209,5 +1209,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Помилка синхронізації прогресу Hardcover: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Зберегти наявний ідентифікатор джерела",
|
||||
"Toggle Toolbar": "Перемкнути панель інструментів"
|
||||
"Toggle Toolbar": "Перемкнути панель інструментів",
|
||||
"Split Hyphens": "Розділити дефіси",
|
||||
"Apply Theme Colors to PDF": "Застосувати кольори теми до PDF",
|
||||
"Name": "Ім'я",
|
||||
"Unavailable": "Недоступно",
|
||||
"Remove": "Видалити",
|
||||
"Edit OPDS Catalog": "Редагувати каталог OPDS",
|
||||
"Save Changes": "Зберегти зміни",
|
||||
"Use Book Layout": "Використовувати макет книги"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Đồng bộ tiến trình Hardcover thất bại: {{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "Giữ mã định danh nguồn hiện tại",
|
||||
"Toggle Toolbar": "Bật/tắt thanh công cụ"
|
||||
"Toggle Toolbar": "Bật/tắt thanh công cụ",
|
||||
"Split Hyphens": "Tách gạch nối",
|
||||
"Apply Theme Colors to PDF": "Áp dụng màu chủ đề cho PDF",
|
||||
"Name": "Tên",
|
||||
"Unavailable": "Không khả dụng",
|
||||
"Remove": "Xóa",
|
||||
"Edit OPDS Catalog": "Chỉnh sửa danh mục OPDS",
|
||||
"Save Changes": "Lưu thay đổi",
|
||||
"Use Book Layout": "Sử dụng bố cục sách"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover 进度同步失败:{{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "保留现有来源标识符",
|
||||
"Toggle Toolbar": "切换工具栏"
|
||||
"Toggle Toolbar": "切换工具栏",
|
||||
"Split Hyphens": "拆分连字符",
|
||||
"Apply Theme Colors to PDF": "将主题颜色应用于 PDF",
|
||||
"Name": "名称",
|
||||
"Unavailable": "不可用",
|
||||
"Remove": "移除",
|
||||
"Edit OPDS Catalog": "编辑 OPDS 目录",
|
||||
"Save Changes": "保存更改",
|
||||
"Use Book Layout": "使用书籍排版"
|
||||
}
|
||||
|
||||
@@ -1170,5 +1170,13 @@
|
||||
"Hardcover progress sync failed: {{error}}": "Hardcover 進度同步失敗:{{error}}",
|
||||
"ISBN": "ISBN",
|
||||
"Keep existing source identifier": "保留現有來源識別碼",
|
||||
"Toggle Toolbar": "切換工具列"
|
||||
"Toggle Toolbar": "切換工具列",
|
||||
"Split Hyphens": "拆分連字號",
|
||||
"Apply Theme Colors to PDF": "將主題顏色套用至 PDF",
|
||||
"Name": "名稱",
|
||||
"Unavailable": "無法使用",
|
||||
"Remove": "移除",
|
||||
"Edit OPDS Catalog": "編輯 OPDS 目錄",
|
||||
"Save Changes": "儲存變更",
|
||||
"Use Book Layout": "使用書籍排版"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.10.6": {
|
||||
"date": "2026-04-13",
|
||||
"notes": [
|
||||
"Library: Much faster browsing for large collections",
|
||||
"Reading: Added an option to keep the book's original paragraph layout",
|
||||
"Reading: Fixed page navigation for Arabic books",
|
||||
"PDF: Fixed TTS reading interruptions at line breaks in PDFs",
|
||||
"PDF: You can now apply your chosen theme colors to PDFs",
|
||||
"Sync: Added HTTP Basic auth support for CWA",
|
||||
"Sync: Improved progress sync with Hardcover.app",
|
||||
"OPDS: You can now edit your registered catalogs, with better handling of download filenames",
|
||||
"Speed Reading: Your reading position now syncs across devices",
|
||||
"Platform: Fixed window buttons on macOS, reduced crashes on iOS"
|
||||
]
|
||||
},
|
||||
"0.10.4": {
|
||||
"date": "2026-04-06",
|
||||
"notes": [
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { execSync, type StdioOptions } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Submodules skipped during worktree setup (shared via symlinks or pre-built)
|
||||
const SKIPPED_SUBMODULES = [
|
||||
'apps/readest-app/.claude/skills/gstack', // shared via .claude symlink
|
||||
'packages/simplecc-wasm', // built assets already in public/vendor
|
||||
];
|
||||
|
||||
const arg = process.argv[2];
|
||||
if (!arg) {
|
||||
console.error('Usage: pnpm worktree:new <branch-name|pr-number>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
||||
|
||||
// Git output goes to stderr so stdout carries only the path (enables: cd $(pnpm worktree:new <arg>))
|
||||
const gitStdio: StdioOptions = ['inherit', process.stderr, process.stderr];
|
||||
|
||||
// Fetch origin so origin/main is up to date
|
||||
console.error('--- Fetching origin ---');
|
||||
execSync('git fetch origin', { stdio: gitStdio, cwd: repoRoot });
|
||||
|
||||
let localBranch: string;
|
||||
let worktreePath: string;
|
||||
|
||||
if (/^\d+$/.test(arg)) {
|
||||
// PR number -- fetch and set up remote tracking so `git push` works (even for forks)
|
||||
localBranch = `pr-${arg}`;
|
||||
worktreePath = path.join(path.dirname(repoRoot), `readest-${localBranch}`);
|
||||
|
||||
// Get PR metadata to determine the source repo and branch
|
||||
const prJson = execSync(
|
||||
`gh pr view ${arg} --json headRefName,headRepositoryOwner,headRepository`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
},
|
||||
);
|
||||
const pr = JSON.parse(prJson) as {
|
||||
headRefName: string;
|
||||
headRepositoryOwner: { login: string };
|
||||
headRepository: { name: string };
|
||||
};
|
||||
const forkOwner = pr.headRepositoryOwner.login;
|
||||
const forkRepo = pr.headRepository.name;
|
||||
const remoteBranch = pr.headRefName;
|
||||
|
||||
// Use "origin" if the PR is from the same repo, otherwise add the fork as a remote
|
||||
const originUrl = execSync('git remote get-url origin', {
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
}).trim();
|
||||
const isFromOrigin = originUrl.includes(`/${forkOwner}/${forkRepo}`);
|
||||
const remoteName = isFromOrigin ? 'origin' : forkOwner;
|
||||
|
||||
if (!isFromOrigin) {
|
||||
try {
|
||||
execSync(`git remote get-url "${remoteName}"`, { encoding: 'utf8', cwd: repoRoot });
|
||||
} catch {
|
||||
execSync(`git remote add "${remoteName}" "https://github.com/${forkOwner}/${forkRepo}.git"`, {
|
||||
stdio: gitStdio,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
execSync(`git fetch "${remoteName}" "${remoteBranch}:${localBranch}"`, {
|
||||
stdio: gitStdio,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
execSync(`git worktree add "${worktreePath}" "${localBranch}"`, {
|
||||
stdio: gitStdio,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
|
||||
// Set upstream so `git push` targets the correct fork and branch.
|
||||
// Use git-config directly instead of `branch --set-upstream-to` because
|
||||
// the targeted fetch above doesn't create a remote-tracking ref.
|
||||
execSync(`git -C "${worktreePath}" config "branch.${localBranch}.remote" "${remoteName}"`);
|
||||
execSync(
|
||||
`git -C "${worktreePath}" config "branch.${localBranch}.merge" "refs/heads/${remoteBranch}"`,
|
||||
);
|
||||
} else {
|
||||
// Branch name -- slashes replaced with dashes for the directory name
|
||||
localBranch = arg;
|
||||
worktreePath = path.join(path.dirname(repoRoot), `readest-${arg.replace(/\//g, '-')}`);
|
||||
|
||||
if (fs.existsSync(worktreePath)) {
|
||||
console.error(`Worktree path already exists: ${worktreePath}`);
|
||||
console.error('Removing existing worktree...');
|
||||
// Deinit only submodules we manage — skipped ones were never initialized
|
||||
const initedSubs = execSync(
|
||||
'git config --file .gitmodules --get-regexp "submodule\\..*\\.path"',
|
||||
{ encoding: 'utf8', cwd: worktreePath },
|
||||
)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.split(/\s+/)[1]!)
|
||||
.filter((p) => !SKIPPED_SUBMODULES.includes(p));
|
||||
for (const sub of initedSubs) {
|
||||
execSync(`git -C "${worktreePath}" submodule deinit --force -- "${sub}"`, {
|
||||
stdio: gitStdio,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
}
|
||||
execSync(`git worktree remove --force "${worktreePath}"`, { stdio: gitStdio, cwd: repoRoot });
|
||||
}
|
||||
|
||||
// Check if the branch already exists
|
||||
const branchExists = execSync('git branch --list --format="%(refname:short)"', {
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
})
|
||||
.split('\n')
|
||||
.includes(localBranch);
|
||||
|
||||
if (branchExists) {
|
||||
execSync(`git worktree add "${worktreePath}" "${localBranch}"`, {
|
||||
stdio: gitStdio,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
} else {
|
||||
execSync(`git worktree add -b "${localBranch}" "${worktreePath}" origin/main`, {
|
||||
stdio: gitStdio,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rebase onto origin/main so the worktree starts from the latest upstream
|
||||
console.error('\n--- Rebasing onto origin/main ---');
|
||||
execSync('git rebase origin/main', { stdio: gitStdio, cwd: worktreePath });
|
||||
|
||||
// Symlink shared directories into the new worktree, pointing at the bare repo's
|
||||
// git common dir so all worktrees share the same settings without duplication.
|
||||
const gitCommonDir = execSync('git rev-parse --git-common-dir', {
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
}).trim();
|
||||
for (const dir of ['.claude', '.local-settings']) {
|
||||
const sharedDir = path.resolve(repoRoot, gitCommonDir, dir);
|
||||
const newDir = path.join(worktreePath, dir);
|
||||
fs.mkdirSync(sharedDir, { recursive: true });
|
||||
if (!fs.existsSync(newDir)) {
|
||||
// 'junction' works without elevated privileges on Windows; ignored on Unix
|
||||
fs.symlinkSync(sharedDir, newDir, 'junction');
|
||||
}
|
||||
}
|
||||
|
||||
// Repoint submodule URLs to local .git/modules/ clones to avoid remote fetches.
|
||||
// Submodules without a local cache fall back to the remote URL.
|
||||
console.error('\n--- Initializing submodules (using local objects) ---');
|
||||
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8', cwd: repoRoot }).trim();
|
||||
const absGitDir = path.resolve(repoRoot, gitDir);
|
||||
const submoduleNames = execSync(
|
||||
'git config --file .gitmodules --get-regexp "submodule\\..*\\.path"',
|
||||
{ encoding: 'utf8', cwd: worktreePath },
|
||||
)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
// line: submodule.<name>.path <path>
|
||||
const match = line.match(/^submodule\.(.+)\.path\s+(.+)$/);
|
||||
return { name: match![1]!, subPath: match![2]! };
|
||||
})
|
||||
.filter(({ subPath }) => !SKIPPED_SUBMODULES.includes(subPath));
|
||||
|
||||
for (const { name, subPath } of submoduleNames) {
|
||||
const localModuleDir = path.join(absGitDir, 'modules', subPath);
|
||||
// Also check if the submodule has a full .git/ directory (cloned outside of git's modules cache)
|
||||
const subGitDir = path.join(repoRoot, subPath, '.git');
|
||||
let localDir: string | undefined;
|
||||
if (fs.existsSync(localModuleDir)) {
|
||||
localDir = localModuleDir;
|
||||
console.error(` ${subPath} -> local (.git/modules)`);
|
||||
} else if (fs.existsSync(subGitDir)) {
|
||||
localDir = fs.statSync(subGitDir).isDirectory()
|
||||
? subGitDir
|
||||
: path.resolve(
|
||||
path.join(repoRoot, subPath),
|
||||
fs.readFileSync(subGitDir, 'utf8').replace('gitdir: ', '').trim(),
|
||||
);
|
||||
console.error(` ${subPath} -> local (standalone .git)`);
|
||||
} else {
|
||||
console.error(` ${subPath} -> remote (no local cache)`);
|
||||
}
|
||||
if (localDir) {
|
||||
// Allow fetching any commit (not just branch tips) from the local source
|
||||
execSync(`git -C "${localDir}" config uploadpack.allowAnySHA1InWant true`);
|
||||
execSync(`git -C "${worktreePath}" config "submodule.${name}.url" "${localDir}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const { subPath } of submoduleNames) {
|
||||
execSync(
|
||||
`git -c protocol.file.allow=always submodule update --init --recursive -- "${subPath}"`,
|
||||
{ stdio: gitStdio, cwd: worktreePath },
|
||||
);
|
||||
}
|
||||
|
||||
// Restore original remote URLs so `git push` in submodules works correctly
|
||||
for (const { name } of submoduleNames) {
|
||||
const origUrl = execSync(`git config --file .gitmodules "submodule.${name}.url"`, {
|
||||
encoding: 'utf8',
|
||||
cwd: worktreePath,
|
||||
}).trim();
|
||||
execSync(`git -C "${worktreePath}" config "submodule.${name}.url" "${origUrl}"`);
|
||||
}
|
||||
|
||||
// Install dependencies
|
||||
console.error('\n--- Installing dependencies ---');
|
||||
execSync('pnpm install', { stdio: gitStdio, cwd: worktreePath });
|
||||
|
||||
// Copy .env* files from the app directory to the new worktree's app directory
|
||||
const appRelPath = 'apps/readest-app';
|
||||
const srcAppDir = path.join(repoRoot, appRelPath);
|
||||
const dstAppDir = path.join(worktreePath, appRelPath);
|
||||
const envFiles = fs.readdirSync(srcAppDir).filter((f) => f.startsWith('.env'));
|
||||
if (envFiles.length > 0) {
|
||||
console.error(`\n--- Copying ${envFiles.length} .env* files ---`);
|
||||
for (const envFile of envFiles) {
|
||||
const src = path.join(srcAppDir, envFile);
|
||||
const dst = path.join(dstAppDir, envFile);
|
||||
if (!fs.existsSync(dst)) {
|
||||
fs.copyFileSync(src, dst);
|
||||
console.error(` ${envFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Symlink target so the worktree shares the Rust build cache
|
||||
const srcTarget = path.join(repoRoot, 'target');
|
||||
const dstTarget = path.join(worktreePath, 'target');
|
||||
if (fs.existsSync(srcTarget) && !fs.existsSync(dstTarget)) {
|
||||
console.error('\n--- Symlinking src-tauri/target ---');
|
||||
fs.symlinkSync(srcTarget, dstTarget, 'junction');
|
||||
}
|
||||
|
||||
// Initialize Tauri Android gen directory (needs platform-specific paths regenerated)
|
||||
const genDir = path.join(dstAppDir, 'src-tauri', 'gen');
|
||||
const androidGenDir = path.join(genDir, 'android');
|
||||
if (fs.existsSync(androidGenDir)) {
|
||||
console.error('\n--- Initializing Tauri Android ---');
|
||||
fs.rmSync(androidGenDir, { recursive: true });
|
||||
execSync('pnpm tauri android init', { stdio: gitStdio, cwd: dstAppDir });
|
||||
execSync('pnpm tauri icon ../../data/icons/readest-book.png', {
|
||||
stdio: gitStdio,
|
||||
cwd: dstAppDir,
|
||||
});
|
||||
execSync(`git checkout ${appRelPath}/src-tauri/gen/android`, {
|
||||
stdio: gitStdio,
|
||||
cwd: worktreePath,
|
||||
});
|
||||
}
|
||||
|
||||
// Symlink Tauri gen/apple and gen/schemas from the main worktree
|
||||
for (const sub of ['apple', 'schemas', 'android/keystore.properties']) {
|
||||
const src = path.join(srcAppDir, 'src-tauri', 'gen', sub);
|
||||
const dst = path.join(genDir, sub);
|
||||
if (fs.existsSync(src) && !fs.existsSync(dst)) {
|
||||
console.error(` Symlinking src-tauri/gen/${sub}`);
|
||||
fs.symlinkSync(src, dst, 'junction');
|
||||
}
|
||||
}
|
||||
|
||||
// Copy public/vendor to the new worktree (built assets not in git)
|
||||
const srcVendor = path.join(srcAppDir, 'public', 'vendor');
|
||||
const dstVendor = path.join(dstAppDir, 'public', 'vendor');
|
||||
if (fs.existsSync(srcVendor) && !fs.existsSync(dstVendor)) {
|
||||
console.error('\n--- Copying public/vendor ---');
|
||||
fs.cpSync(srcVendor, dstVendor, { recursive: true });
|
||||
}
|
||||
|
||||
// Print path to stdout -- allows: cd $(pnpm worktree:new <arg>)
|
||||
process.stdout.write(worktreePath + '\n');
|
||||
@@ -0,0 +1,51 @@
|
||||
import { execSync, type StdioOptions } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIPPED_SUBMODULES = ['apps/readest-app/.claude/skills/gstack', 'packages/simplecc-wasm'];
|
||||
|
||||
const arg = process.argv[2];
|
||||
if (!arg) {
|
||||
console.error('Usage: pnpm worktree:rm <branch-name|pr-number>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
||||
const gitStdio: StdioOptions = ['inherit', process.stderr, process.stderr];
|
||||
|
||||
// Resolve worktree path from the argument
|
||||
let dirName: string;
|
||||
if (/^\d+$/.test(arg)) {
|
||||
dirName = `readest-pr-${arg}`;
|
||||
} else {
|
||||
dirName = `readest-${arg.replace(/\//g, '-')}`;
|
||||
}
|
||||
const worktreePath = path.join(path.dirname(repoRoot), dirName);
|
||||
|
||||
// Check the worktree exists
|
||||
const worktrees = execSync('git worktree list --porcelain', { encoding: 'utf8', cwd: repoRoot });
|
||||
const found = worktrees.split('\n').some((line) => line === `worktree ${worktreePath}`);
|
||||
if (!found) {
|
||||
console.error(`error: no worktree found at ${worktreePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(`Removing worktree: ${worktreePath}`);
|
||||
|
||||
// Deinit only submodules we manage — skipped ones were never initialized
|
||||
const initedSubs = execSync('git config --file .gitmodules --get-regexp "submodule\\..*\\.path"', {
|
||||
encoding: 'utf8',
|
||||
cwd: worktreePath,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.split(/\s+/)[1]!)
|
||||
.filter((p) => !SKIPPED_SUBMODULES.includes(p));
|
||||
for (const sub of initedSubs) {
|
||||
execSync(`git -C "${worktreePath}" submodule deinit --force -- "${sub}"`, {
|
||||
stdio: gitStdio,
|
||||
cwd: repoRoot,
|
||||
});
|
||||
}
|
||||
execSync(`git worktree remove --force "${worktreePath}"`, { stdio: gitStdio, cwd: repoRoot });
|
||||
|
||||
console.error('Done.');
|
||||
@@ -78,3 +78,6 @@ tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
discord-rich-presence = "1.0.0"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
rsproperties = "0.3"
|
||||
|
||||
-1
@@ -442,7 +442,6 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
|
||||
if (windowInsets != null) {
|
||||
val insets = windowInsets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or
|
||||
WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
val density = activity.resources.displayMetrics.density
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Known e-ink device manufacturers and brands (case-insensitive matching)
|
||||
const EINK_MANUFACTURERS: &[&str] = &[
|
||||
@@ -45,28 +45,21 @@ const EINK_MODELS: &[&str] = &[
|
||||
"max lumi",
|
||||
];
|
||||
|
||||
/// Get Android system property using getprop command
|
||||
fn get_system_property(prop: &str) -> Option<String> {
|
||||
Command::new("getprop")
|
||||
.arg(prop)
|
||||
.output()
|
||||
rsproperties::get::<String>(prop)
|
||||
.ok()
|
||||
.and_then(|output| {
|
||||
if output.status.success() {
|
||||
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Check if the current Android device is an e-ink device
|
||||
/// Check if the current Android device is an e-ink device.
|
||||
///
|
||||
/// The result is cached on first call so subsequent calls are free.
|
||||
pub fn is_eink_device() -> bool {
|
||||
static IS_EINK: OnceLock<bool> = OnceLock::new();
|
||||
*IS_EINK.get_or_init(detect_eink_device)
|
||||
}
|
||||
|
||||
fn detect_eink_device() -> bool {
|
||||
// Get device manufacturer and model
|
||||
let manufacturer = get_system_property("ro.product.manufacturer")
|
||||
.or_else(|| get_system_property("ro.product.brand"))
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { TOCItem } from '@/libs/document';
|
||||
|
||||
/**
|
||||
* Regression test for TOC sidebar blank on initial book load.
|
||||
*
|
||||
* When a book opens at a position with no TOC entry (e.g., cover page),
|
||||
* progress.sectionHref is undefined and expandParents is never called.
|
||||
* For books with a nested TOC structure (chapters nested under a root
|
||||
* container), the sidebar appears blank because expandedItems is empty
|
||||
* and only the root item is shown.
|
||||
*
|
||||
* Fix: Initialize expandedItems with top-level TOC items that have subitems
|
||||
* so chapters are visible immediately on sidebar open.
|
||||
*/
|
||||
|
||||
// Mirrors the logic in TOCView.tsx's getItemIdentifier
|
||||
const getItemIdentifier = (item: TOCItem) => {
|
||||
const href = item.href || '';
|
||||
return `toc-item-${item.id}-${href}`;
|
||||
};
|
||||
|
||||
// Mirrors the initialization logic to be added to TOCView.tsx
|
||||
const getInitialExpandedItems = (toc: TOCItem[]): Set<string> => {
|
||||
const topLevelWithSubitems = toc
|
||||
.filter((item) => item.subitems?.length)
|
||||
.map((item) => getItemIdentifier(item));
|
||||
return topLevelWithSubitems.length > 0 ? new Set(topLevelWithSubitems) : new Set();
|
||||
};
|
||||
|
||||
// Mirrors useFlattenedTOC logic in TOCView.tsx
|
||||
const flattenTOC = (items: TOCItem[], expandedItems: Set<string>, depth = 0): TOCItem[] => {
|
||||
const result: TOCItem[] = [];
|
||||
items.forEach((item) => {
|
||||
const isExpanded = expandedItems.has(getItemIdentifier(item));
|
||||
result.push(item);
|
||||
if (item.subitems && isExpanded) {
|
||||
result.push(...flattenTOC(item.subitems, expandedItems, depth + 1));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('TOC sidebar initialization', () => {
|
||||
const nestedTOC: TOCItem[] = [
|
||||
{
|
||||
id: 0,
|
||||
label: 'Book',
|
||||
href: undefined,
|
||||
subitems: [
|
||||
{ id: 1, label: 'Chapter 1', href: 'ch1.html' },
|
||||
{ id: 2, label: 'Chapter 2', href: 'ch2.html' },
|
||||
{ id: 3, label: 'Chapter 3', href: 'ch3.html' },
|
||||
],
|
||||
} as unknown as TOCItem,
|
||||
];
|
||||
|
||||
const flatTOC: TOCItem[] = [
|
||||
{ id: 1, label: 'Chapter 1', href: 'ch1.html' } as unknown as TOCItem,
|
||||
{ id: 2, label: 'Chapter 2', href: 'ch2.html' } as unknown as TOCItem,
|
||||
{ id: 3, label: 'Chapter 3', href: 'ch3.html' } as unknown as TOCItem,
|
||||
];
|
||||
|
||||
describe('before fix (demonstrates the bug)', () => {
|
||||
it('nested TOC with empty expandedItems only shows root item, not chapters', () => {
|
||||
const expandedItems = new Set<string>(); // Empty initial state (the bug)
|
||||
const flatItems = flattenTOC(nestedTOC, expandedItems);
|
||||
// Only root "Book" shows — chapters are hidden
|
||||
expect(flatItems).toHaveLength(1);
|
||||
expect(flatItems[0]!.label).toBe('Book');
|
||||
});
|
||||
});
|
||||
|
||||
describe('after fix (initialization effect behavior)', () => {
|
||||
it('nested TOC: getInitialExpandedItems expands the root container', () => {
|
||||
const expandedItems = getInitialExpandedItems(nestedTOC);
|
||||
expect(expandedItems.size).toBe(1);
|
||||
expect(expandedItems.has(getItemIdentifier(nestedTOC[0]!))).toBe(true);
|
||||
});
|
||||
|
||||
it('nested TOC: with initialized expandedItems, all chapters are visible', () => {
|
||||
const expandedItems = getInitialExpandedItems(nestedTOC);
|
||||
const flatItems = flattenTOC(nestedTOC, expandedItems);
|
||||
// Root + 3 chapters = 4 items
|
||||
expect(flatItems).toHaveLength(4);
|
||||
expect(flatItems[1]!.label).toBe('Chapter 1');
|
||||
expect(flatItems[2]!.label).toBe('Chapter 2');
|
||||
expect(flatItems[3]!.label).toBe('Chapter 3');
|
||||
});
|
||||
|
||||
it('flat TOC: getInitialExpandedItems returns empty set (no change)', () => {
|
||||
const expandedItems = getInitialExpandedItems(flatTOC);
|
||||
expect(expandedItems.size).toBe(0);
|
||||
});
|
||||
|
||||
it('flat TOC: all chapters visible regardless of expandedItems', () => {
|
||||
const expandedItems = getInitialExpandedItems(flatTOC);
|
||||
const flatItems = flattenTOC(flatTOC, expandedItems);
|
||||
expect(flatItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('non-empty expandedItems is preserved (no re-initialization)', () => {
|
||||
const existingItems = new Set(['toc-item-1-ch1.html']);
|
||||
// The effect uses: if (prev.size > 0) return prev
|
||||
const result = existingItems.size > 0 ? existingItems : getInitialExpandedItems(nestedTOC);
|
||||
expect(result).toBe(existingItems); // Same reference - not re-initialized
|
||||
});
|
||||
|
||||
it('empty TOC produces empty expandedItems', () => {
|
||||
const expandedItems = getInitialExpandedItems([]);
|
||||
expect(expandedItems.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
// Mock zustand stores before importing the component
|
||||
vi.mock('@/store/atmosphereStore', () => ({
|
||||
useAtmosphereStore: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/store/themeStore', () => ({
|
||||
useThemeStore: vi.fn(),
|
||||
}));
|
||||
|
||||
import AtmosphereOverlay from '@/components/AtmosphereOverlay';
|
||||
import { useAtmosphereStore } from '@/store/atmosphereStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const setupStores = (active: boolean) => {
|
||||
vi.mocked(useAtmosphereStore).mockImplementation((selector: unknown) =>
|
||||
(selector as (s: { active: boolean }) => unknown)({ active }),
|
||||
);
|
||||
vi.mocked(useThemeStore).mockImplementation((selector: unknown) =>
|
||||
(selector as (s: { isDarkMode: boolean }) => unknown)({ isDarkMode: false }),
|
||||
);
|
||||
};
|
||||
|
||||
describe('AtmosphereOverlay', () => {
|
||||
it('does not render <video> element when inactive', () => {
|
||||
setupStores(false);
|
||||
const { container } = render(<AtmosphereOverlay />);
|
||||
const video = container.querySelector('video');
|
||||
expect(video).toBeNull();
|
||||
});
|
||||
|
||||
it('renders <video> element when active', () => {
|
||||
setupStores(true);
|
||||
const { container } = render(<AtmosphereOverlay />);
|
||||
const video = container.querySelector('video');
|
||||
expect(video).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
import BookCover from '@/components/BookCover';
|
||||
import { Book } from '@/types/book';
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => {
|
||||
// biome-ignore lint/a11y/useAltText: test mock; alt comes from spread props
|
||||
return <img {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const makeBook = (overrides?: Partial<Book>): Book =>
|
||||
({
|
||||
hash: 'abc123',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
format: 'epub',
|
||||
coverImageUrl: 'https://example.com/cover.jpg',
|
||||
...overrides,
|
||||
}) as Book;
|
||||
|
||||
describe('BookCover', () => {
|
||||
it('passes loading="lazy" to crop-mode Image', () => {
|
||||
const { container } = render(<BookCover book={makeBook()} coverFit='crop' />);
|
||||
const img = container.querySelector('img.cover-image');
|
||||
expect(img).toBeTruthy();
|
||||
expect(img?.getAttribute('loading')).toBe('lazy');
|
||||
});
|
||||
|
||||
it('passes loading="lazy" to fit-mode Image', () => {
|
||||
const { container } = render(<BookCover book={makeBook()} coverFit='fit' />);
|
||||
const img = container.querySelector('img.cover-image');
|
||||
expect(img).toBeTruthy();
|
||||
expect(img?.getAttribute('loading')).toBe('lazy');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
|
||||
import HighlightColorsEditor from '@/components/settings/color/HighlightColorsEditor';
|
||||
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
|
||||
import type { DefaultHighlightColor, HighlightColor, UserHighlightColor } from '@/types/book';
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
// Mocked SketchPicker that tracks mount/unmount via a shared module-level
|
||||
// registry, so we can detect whether the picker was remounted across a hex
|
||||
// change. Each mount gets a unique id; a mount is "alive" while its cleanup
|
||||
// hasn't run.
|
||||
const mountLog: Array<{ id: number; alive: boolean }> = [];
|
||||
let nextMountId = 0;
|
||||
|
||||
vi.mock('react-color', () => ({
|
||||
SketchPicker: ({
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
color: string;
|
||||
onChange: (c: { hex: string }) => void;
|
||||
}) => {
|
||||
const [mountId] = useState(() => {
|
||||
const id = nextMountId++;
|
||||
mountLog.push({ id, alive: true });
|
||||
return id;
|
||||
});
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
const entry = mountLog.find((m) => m.id === mountId);
|
||||
if (entry) entry.alive = false;
|
||||
};
|
||||
}, [mountId]);
|
||||
return (
|
||||
<div
|
||||
data-testid='mock-sketch-picker'
|
||||
data-mount-id={mountId}
|
||||
data-color={color}
|
||||
// Expose a way to trigger onChange as if from a drag.
|
||||
onClick={() => onChange({ hex: '#112233' })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
ColorResult: undefined,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mountLog.length = 0;
|
||||
nextMountId = 0;
|
||||
});
|
||||
|
||||
const Harness: React.FC<{ initialUserColors: UserHighlightColor[] }> = ({ initialUserColors }) => {
|
||||
const [userColors, setUserColors] = useState<UserHighlightColor[]>(initialUserColors);
|
||||
const [customColors, setCustomColors] =
|
||||
useState<Record<HighlightColor, string>>(HIGHLIGHT_COLOR_HEX);
|
||||
const [labels, setLabels] = useState<Partial<Record<DefaultHighlightColor, string>>>({});
|
||||
|
||||
return (
|
||||
<HighlightColorsEditor
|
||||
customHighlightColors={customColors}
|
||||
userHighlightColors={userColors}
|
||||
defaultHighlightLabels={labels}
|
||||
highlightOpacity={0.3}
|
||||
isEink={false}
|
||||
onCustomHighlightColorsChange={setCustomColors}
|
||||
onUserHighlightColorsChange={setUserColors}
|
||||
onDefaultHighlightLabelsChange={setLabels}
|
||||
onOpacityChange={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('HighlightColorsEditor — user color SketchPicker stability', () => {
|
||||
it('keeps the SketchPicker mounted when the user-color hex updates (so drag is not interrupted)', () => {
|
||||
render(<Harness initialUserColors={[{ hex: '#aabbcc' }]} />);
|
||||
|
||||
// The user color row's ColorInput renders a text input with the hex.
|
||||
// Find it (the predefined palette also renders inputs; the user row's
|
||||
// input is last in the document because it's rendered after the defaults).
|
||||
const hexInputs = screen.getAllByDisplayValue(/^#/);
|
||||
const userHexInput = hexInputs[hexInputs.length - 1]!;
|
||||
expect(userHexInput).toHaveProperty('value', '#aabbcc');
|
||||
|
||||
// Open the SketchPicker for this user color.
|
||||
fireEvent.click(userHexInput);
|
||||
|
||||
const picker = screen.getByTestId('mock-sketch-picker');
|
||||
const initialMountId = picker.getAttribute('data-mount-id');
|
||||
expect(initialMountId).not.toBeNull();
|
||||
|
||||
// Simulate an onChange coming from the SketchPicker during drag.
|
||||
// (In the real picker this fires continuously as the user drags.)
|
||||
fireEvent.click(picker);
|
||||
|
||||
// After the hex update, the picker should STILL be mounted (same id).
|
||||
// If the parent row unmounted due to `key={hex}` changing, the original
|
||||
// mount would be gone and React would have mounted a fresh one — the
|
||||
// drag's window-level mouse listeners would be torn down with it.
|
||||
const sameMount = mountLog.find((m) => String(m.id) === initialMountId);
|
||||
expect(sameMount, 'SketchPicker mount with initial id should still exist').toBeDefined();
|
||||
expect(sameMount!.alive, 'SketchPicker should not be unmounted on hex change').toBe(true);
|
||||
|
||||
// And a picker for the new color should still be visible in the DOM.
|
||||
const pickerAfter = screen.queryByTestId('mock-sketch-picker');
|
||||
expect(pickerAfter).not.toBeNull();
|
||||
expect(pickerAfter!.getAttribute('data-color')).toBe('#112233');
|
||||
expect(pickerAfter!.getAttribute('data-mount-id')).toBe(initialMountId);
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Visual regression test for the AnnotationPopup component.
|
||||
*
|
||||
* Renders the *real* AnnotationPopup + HighlightOptions with actual
|
||||
* annotationToolButtons, DEFAULT_HIGHLIGHT_COLORS, and optional user
|
||||
* colors. Tailwind CSS is loaded so the screenshot matches the live app.
|
||||
*
|
||||
* Guards against the layout regression from PR #3741 (missing
|
||||
* `justify-between`, unwanted `flex-1` on the color strip).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
import { page } from 'vitest/browser';
|
||||
import type { UserHighlightColor } from '@/types/book';
|
||||
|
||||
// ── Tailwind / DaisyUI styles ───────────────────────────────────────────
|
||||
import '@/styles/globals.css';
|
||||
|
||||
// ── Per-test state read by mocks ────────────────────────────────────────
|
||||
let mockUserColors: UserHighlightColor[] = [];
|
||||
|
||||
// ── Mocks (must be before component imports) ────────────────────────────
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ envConfig: {}, appService: null }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/themeStore', () => ({
|
||||
useThemeStore: () => ({ isDarkMode: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
vi.mock('@/store/settingsStore', () => ({
|
||||
useSettingsStore: () => ({
|
||||
settings: {
|
||||
globalReadSettings: {
|
||||
highlightStyle: 'highlight' as const,
|
||||
highlightStyles: {
|
||||
highlight: 'yellow',
|
||||
underline: 'red',
|
||||
squiggly: 'blue',
|
||||
},
|
||||
customHighlightColors: {} as Record<string, string>,
|
||||
get userHighlightColors() {
|
||||
return mockUserColors;
|
||||
},
|
||||
defaultHighlightLabels: {},
|
||||
},
|
||||
globalViewSettings: {
|
||||
isEink: false,
|
||||
isColorEink: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useResponsiveSize', () => ({
|
||||
useResponsiveSize: (n: number) => n,
|
||||
useDefaultIconSize: () => 20,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useKeyDownActions', () => ({
|
||||
useKeyDownActions: () => {},
|
||||
}));
|
||||
|
||||
vi.mock('@/helpers/settings', () => ({
|
||||
saveSysSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/reader/utils/annotatorUtil', () => ({
|
||||
getHighlightColorLabel: () => undefined,
|
||||
}));
|
||||
|
||||
// ── Real component imports ──────────────────────────────────────────────
|
||||
|
||||
import AnnotationPopup from '@/app/reader/components/annotator/AnnotationPopup';
|
||||
import { annotationToolButtons } from '@/app/reader/components/annotator/AnnotationTools';
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────
|
||||
|
||||
const POPUP_W = 300;
|
||||
const POPUP_H = 44;
|
||||
|
||||
// Highlight options float above the popup by (28 + 16) = 44px
|
||||
const OPTIONS_OFFSET = 28 + 16;
|
||||
|
||||
// Position the popup so both it and the floating options are visible:
|
||||
// y=0..OPTIONS_OFFSET: highlight-options row
|
||||
// y=OPTIONS_OFFSET..OPTIONS_OFFSET+POPUP_H: toolbar
|
||||
const POPUP_Y = OPTIONS_OFFSET;
|
||||
const POPUP_X = 0;
|
||||
const WRAPPER_H = POPUP_Y + POPUP_H + 14; // +14 for triangle below
|
||||
|
||||
const toolButtons = annotationToolButtons.map(({ label, Icon }) => ({
|
||||
tooltipText: label,
|
||||
Icon,
|
||||
onClick: vi.fn(),
|
||||
}));
|
||||
|
||||
// Browser-mode matcher types are unavailable to tsgo; cast once here.
|
||||
const expectElement = (locator: unknown) =>
|
||||
// @ts-expect-error -- expect.element() exists in vitest browser mode
|
||||
expect.element(locator) as { toMatchScreenshot: (name: string) => Promise<void> };
|
||||
|
||||
/**
|
||||
* Fixed-size wrapper that contains both the popup and the absolutely
|
||||
* positioned highlight-options row above it, matching the real app
|
||||
* where the triangle points up and highlight options float above.
|
||||
*/
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div
|
||||
data-theme='dark'
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: POPUP_W,
|
||||
height: WRAPPER_H,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPopup = (userColors: UserHighlightColor[] = []) => {
|
||||
mockUserColors = userColors;
|
||||
return render(
|
||||
<Wrapper>
|
||||
<AnnotationPopup
|
||||
bookKey='test'
|
||||
dir='ltr'
|
||||
isVertical={false}
|
||||
buttons={toolButtons}
|
||||
notes={[]}
|
||||
position={{ dir: 'up', point: { x: POPUP_X, y: POPUP_Y } }}
|
||||
trianglePosition={{ dir: 'up', point: { x: POPUP_X + POPUP_W / 2, y: POPUP_Y + POPUP_H } }}
|
||||
highlightOptionsVisible
|
||||
selectedStyle='highlight'
|
||||
selectedColor='yellow'
|
||||
popupWidth={POPUP_W}
|
||||
popupHeight={POPUP_H}
|
||||
onHighlight={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
};
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(async () => {
|
||||
await page.viewport(800, 600);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserColors = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AnnotationPopup layout screenshot', () => {
|
||||
it('default 5 colors — compact color strip, large gap', async () => {
|
||||
const { container } = renderPopup();
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
await expectElement(page.elementLocator(wrapper)).toMatchScreenshot(
|
||||
'annotation-popup-5-colors',
|
||||
);
|
||||
});
|
||||
|
||||
it('5+5 user colors — color strip grows, gap shrinks', async () => {
|
||||
const { container } = renderPopup([
|
||||
{ hex: '#f97316' },
|
||||
{ hex: '#06b6d4' },
|
||||
{ hex: '#ec4899' },
|
||||
{ hex: '#14b8a6' },
|
||||
{ hex: '#f43f5e' },
|
||||
]);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
await expectElement(page.elementLocator(wrapper)).toMatchScreenshot(
|
||||
'annotation-popup-10-colors',
|
||||
);
|
||||
});
|
||||
|
||||
it('5+10 user colors — color strip at max, overflow scrolls', async () => {
|
||||
const { container } = renderPopup([
|
||||
{ hex: '#f97316' },
|
||||
{ hex: '#06b6d4' },
|
||||
{ hex: '#ec4899' },
|
||||
{ hex: '#14b8a6' },
|
||||
{ hex: '#f43f5e' },
|
||||
{ hex: '#a855f7' },
|
||||
{ hex: '#84cc16' },
|
||||
{ hex: '#0ea5e9' },
|
||||
{ hex: '#e11d48' },
|
||||
{ hex: '#6366f1' },
|
||||
]);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
await expectElement(page.elementLocator(wrapper)).toMatchScreenshot(
|
||||
'annotation-popup-15-colors',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, act } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/utils/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
onAuthStateChange: vi.fn(() => ({
|
||||
data: { subscription: { unsubscribe: vi.fn() } },
|
||||
})),
|
||||
refreshSession: vi.fn().mockResolvedValue(undefined),
|
||||
signOut: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: { identify: vi.fn() },
|
||||
}));
|
||||
|
||||
import { AuthProvider, useAuth } from '@/context/AuthContext';
|
||||
|
||||
describe('AuthContext memoization', () => {
|
||||
beforeEach(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('returns the same context value reference when parent re-renders without state change', () => {
|
||||
const captured: ReturnType<typeof useAuth>[] = [];
|
||||
|
||||
function Probe() {
|
||||
const value = useAuth();
|
||||
captured.push(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Wrapper({ tick }: { tick: number }) {
|
||||
// The tick prop forces a parent re-render but does not change AuthProvider state
|
||||
return (
|
||||
<AuthProvider>
|
||||
<span data-tick={tick} />
|
||||
<Probe />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { rerender } = render(<Wrapper tick={0} />);
|
||||
act(() => {
|
||||
rerender(<Wrapper tick={1} />);
|
||||
});
|
||||
act(() => {
|
||||
rerender(<Wrapper tick={2} />);
|
||||
});
|
||||
|
||||
// Probe captures one value per render. We expect at least 3 captures.
|
||||
expect(captured.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// The first capture happens during initial mount (state may settle async),
|
||||
// but subsequent captures from parent-only re-renders should reuse the same
|
||||
// memoized context value reference. If login/logout/refresh are not stable
|
||||
// (no useCallback), useMemo's deps change every render and produce a fresh
|
||||
// object each time — this assertion catches that regression.
|
||||
const firstStable = captured[captured.length - 2]!;
|
||||
const secondStable = captured[captured.length - 1]!;
|
||||
expect(secondStable).toBe(firstStable);
|
||||
});
|
||||
|
||||
test('login/logout/refresh callbacks are stable across re-renders', () => {
|
||||
const captured: ReturnType<typeof useAuth>[] = [];
|
||||
|
||||
function Probe() {
|
||||
const value = useAuth();
|
||||
captured.push(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Wrapper({ tick }: { tick: number }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<span data-tick={tick} />
|
||||
<Probe />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { rerender } = render(<Wrapper tick={0} />);
|
||||
act(() => {
|
||||
rerender(<Wrapper tick={1} />);
|
||||
});
|
||||
|
||||
const last = captured[captured.length - 1]!;
|
||||
const prev = captured[captured.length - 2]!;
|
||||
expect(last.login).toBe(prev.login);
|
||||
expect(last.logout).toBe(prev.logout);
|
||||
expect(last.refresh).toBe(prev.refresh);
|
||||
});
|
||||
});
|
||||
@@ -278,6 +278,40 @@ describe('Paginator multi-view architecture', () => {
|
||||
// With tolerance, this should detect index 3 (not 2)
|
||||
expect(detectPrimary(visibleStart)).toBe(3);
|
||||
});
|
||||
|
||||
it('should not inflate page count due to fractional DPR rounding (pages getter)', () => {
|
||||
// On fractional DPR devices, getBoundingClientRect() on the view element
|
||||
// returns a width that is a near-integer multiple of the container size,
|
||||
// but with tiny floating-point drift. Math.ceil inflates the count by 1.
|
||||
// Real scenario: containerSize=785.45458984375, viewSize should be
|
||||
// exactly 4*containerSize but getBoundingClientRect() returns a value
|
||||
// with sub-pixel error.
|
||||
const containerSize = 785.45458984375;
|
||||
const viewSize = containerSize * 4 + 0.0001; // tiny FP drift
|
||||
|
||||
// Bug: Math.ceil gives 5 instead of 4
|
||||
const buggyPages = Math.ceil(viewSize / containerSize);
|
||||
expect(buggyPages).toBe(5); // confirms the bug exists
|
||||
|
||||
// Fix: Math.round absorbs sub-pixel drift
|
||||
const fixedPages = Math.round(viewSize / containerSize);
|
||||
expect(fixedPages).toBe(4);
|
||||
});
|
||||
|
||||
it('should not inflate rendered page count due to fractional DPR rounding', () => {
|
||||
// Same issue for #renderedPages which sums multiple view sizes
|
||||
const containerSize = 785.45458984375;
|
||||
const viewSizes = [containerSize * 3 + 0.00005, containerSize * 2 + 0.00008];
|
||||
const totalViewSize = viewSizes.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Bug: Math.ceil over-counts
|
||||
const buggyPages = Math.ceil(totalViewSize / containerSize);
|
||||
expect(buggyPages).toBe(6); // should be 5 but ceil rounds up
|
||||
|
||||
// Fix: Math.round
|
||||
const fixedPages = Math.round(totalViewSize / containerSize);
|
||||
expect(fixedPages).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adjacent index with fromIndex parameter', () => {
|
||||
|
||||
@@ -19,10 +19,16 @@ const highlight = vi.fn();
|
||||
/**
|
||||
* Build a document that mimics a rendered PDF page with text layer,
|
||||
* matching the structure that pdf.js produces in the iframe.
|
||||
* When withLineBreaks is true, inserts <br> between spans (matching real PDF.js output).
|
||||
*/
|
||||
const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Document => {
|
||||
const createPDFTextLayerDoc = (
|
||||
textSpans: string[],
|
||||
annotationText?: string,
|
||||
withLineBreaks?: boolean,
|
||||
): Document => {
|
||||
const parser = new DOMParser();
|
||||
const spans = textSpans.map((t) => `<span>${t}</span>`).join('');
|
||||
const separator = withLineBreaks ? '<br>' : '';
|
||||
const spans = textSpans.map((t) => `<span>${t}</span>`).join(separator);
|
||||
const annotation = annotationText
|
||||
? `<div class="annotationLayer"><a href="#">${annotationText}</a></div>`
|
||||
: '<div class="annotationLayer"></div>';
|
||||
@@ -38,7 +44,7 @@ const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Do
|
||||
|
||||
/** Node filter matching what TTSController uses for PDFs */
|
||||
const pdfNodeFilter = createRejectFilter({
|
||||
tags: ['rt', 'canvas'],
|
||||
tags: ['rt', 'canvas', 'br'],
|
||||
classes: ['annotationLayer'],
|
||||
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
|
||||
});
|
||||
@@ -101,6 +107,183 @@ describe('PDF TTS', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTS with PDF line breaks (br elements)', () => {
|
||||
it('should not produce SSML break elements for br tags in PDF text layer', () => {
|
||||
const doc = createPDFTextLayerDoc(
|
||||
['Alice was beginning to get very ', 'tired of sitting by her sister '],
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
// The SSML should NOT contain <break> elements for PDF line breaks
|
||||
expect(ssml).not.toMatch(/<break\s*\/?\s*>/);
|
||||
// But the text should still be continuous
|
||||
const text = stripTags(ssml!);
|
||||
expect(text).toContain('Alice');
|
||||
expect(text).toContain('tired');
|
||||
});
|
||||
|
||||
it('should read through PDF line breaks without interruption', () => {
|
||||
const doc = createPDFTextLayerDoc(
|
||||
[
|
||||
'This is the first line of a paragraph ',
|
||||
'and this continues on the second line ',
|
||||
'ending on the third line.',
|
||||
],
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
const ssml = tts.start();
|
||||
|
||||
expect(ssml).toBeTruthy();
|
||||
const text = stripTags(ssml!);
|
||||
// All text should be in a single block without breaks
|
||||
expect(text).toContain('first line');
|
||||
expect(text).toContain('second line');
|
||||
expect(text).toContain('third line');
|
||||
// No SSML break elements
|
||||
expect(ssml).not.toMatch(/<break\s*\/?\s*>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDF sentence-level block splitting', () => {
|
||||
it('should split multiple sentences into separate TTS blocks', () => {
|
||||
const doc = createPDFTextLayerDoc([
|
||||
'Alice was beginning to get very tired. ',
|
||||
'She had nothing to do. ',
|
||||
'The day was warm and sunny.',
|
||||
]);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
|
||||
const blocks: string[] = [];
|
||||
let ssml = tts.start();
|
||||
while (ssml) {
|
||||
blocks.push(stripTags(ssml));
|
||||
ssml = tts.next();
|
||||
}
|
||||
|
||||
// Each sentence should be its own block
|
||||
expect(blocks.length).toBe(3);
|
||||
expect(blocks[0]).toContain('Alice was beginning');
|
||||
expect(blocks[1]).toContain('She had nothing');
|
||||
expect(blocks[2]).toContain('The day was warm');
|
||||
});
|
||||
|
||||
it('should handle sentences that span across multiple spans', () => {
|
||||
const doc = createPDFTextLayerDoc([
|
||||
'Alice was beginning to get very ',
|
||||
'tired of sitting by her sister. She had ',
|
||||
'nothing to do.',
|
||||
]);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
|
||||
const blocks: string[] = [];
|
||||
let ssml = tts.start();
|
||||
while (ssml) {
|
||||
blocks.push(stripTags(ssml));
|
||||
ssml = tts.next();
|
||||
}
|
||||
|
||||
expect(blocks.length).toBe(2);
|
||||
expect(blocks[0]).toContain('tired of sitting');
|
||||
expect(blocks[1]).toContain('nothing to do');
|
||||
});
|
||||
|
||||
it('should handle a single sentence as one block', () => {
|
||||
const doc = createPDFTextLayerDoc(['Just one sentence here.']);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
|
||||
const blocks: string[] = [];
|
||||
let ssml = tts.start();
|
||||
while (ssml) {
|
||||
blocks.push(stripTags(ssml));
|
||||
ssml = tts.next();
|
||||
}
|
||||
|
||||
expect(blocks.length).toBe(1);
|
||||
expect(blocks[0]).toContain('Just one sentence here');
|
||||
});
|
||||
|
||||
it('should handle sentences with br elements between lines', () => {
|
||||
const doc = createPDFTextLayerDoc(
|
||||
[
|
||||
'First sentence on line one. ',
|
||||
'Second sentence starts here ',
|
||||
'and continues on line three.',
|
||||
],
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
|
||||
const blocks: string[] = [];
|
||||
let ssml = tts.start();
|
||||
while (ssml) {
|
||||
const text = stripTags(ssml);
|
||||
blocks.push(text);
|
||||
// No break elements in any block
|
||||
expect(ssml).not.toMatch(/<break\s*\/?\s*>/);
|
||||
ssml = tts.next();
|
||||
}
|
||||
|
||||
expect(blocks.length).toBe(2);
|
||||
expect(blocks[0]).toContain('First sentence');
|
||||
expect(blocks[1]).toContain('Second sentence');
|
||||
expect(blocks[1]).toContain('line three');
|
||||
});
|
||||
|
||||
it('should produce word marks within each sentence block', () => {
|
||||
const doc = createPDFTextLayerDoc(['Hello world. Goodbye world.']);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
|
||||
const ssml = tts.start();
|
||||
expect(ssml).toBeTruthy();
|
||||
expect(ssml).toContain('<mark');
|
||||
expect(stripTags(ssml!)).toContain('Hello');
|
||||
expect(stripTags(ssml!)).not.toContain('Goodbye');
|
||||
|
||||
const ssml2 = tts.next();
|
||||
expect(ssml2).toBeTruthy();
|
||||
expect(ssml2).toContain('<mark');
|
||||
expect(stripTags(ssml2!)).toContain('Goodbye');
|
||||
});
|
||||
|
||||
it('should align marks with sentence text when sentence spans multiple spans', () => {
|
||||
// Sentence boundary falls in the middle of span 2:
|
||||
// "Alice was beginning to get " + "very tired. She had nothing " + "to do."
|
||||
// Sentence 1: "Alice was beginning to get very tired. "
|
||||
// Sentence 2: "She had nothing to do."
|
||||
const doc = createPDFTextLayerDoc([
|
||||
'Alice was beginning to get ',
|
||||
'very tired. She had nothing ',
|
||||
'to do.',
|
||||
]);
|
||||
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
|
||||
|
||||
const ssml1 = tts.start();
|
||||
expect(ssml1).toBeTruthy();
|
||||
const text1 = stripTags(ssml1!);
|
||||
// First block must contain ONLY sentence 1 words
|
||||
expect(text1).toContain('Alice');
|
||||
expect(text1).toContain('tired');
|
||||
expect(text1).not.toContain('She');
|
||||
expect(text1).not.toContain('nothing');
|
||||
|
||||
const ssml2 = tts.next();
|
||||
expect(ssml2).toBeTruthy();
|
||||
const text2 = stripTags(ssml2!);
|
||||
// Second block must contain ONLY sentence 2 words
|
||||
expect(text2).toContain('She');
|
||||
expect(text2).toContain('nothing');
|
||||
expect(text2).not.toContain('Alice');
|
||||
expect(text2).not.toContain('tired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDF node filter', () => {
|
||||
it('should reject canvas elements', () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, act } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/store/readerStore', () => {
|
||||
return {
|
||||
useReaderStore: () => ({ hoveredBookKey: null }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/store/bookDataStore', () => {
|
||||
return {
|
||||
useBookDataStore: () => ({ getBookData: () => null }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/utils/event', () => ({
|
||||
eventDispatcher: { dispatch: vi.fn() },
|
||||
}));
|
||||
|
||||
import { useMouseEvent } from '@/app/reader/hooks/useIframeEvents';
|
||||
|
||||
function dispatchWheelMessage(bookKey: string) {
|
||||
// useMouseEvent listens on `message`, not `window.postMessage` directly,
|
||||
// so we dispatch a MessageEvent manually for synchronous delivery.
|
||||
const event = new MessageEvent('message', {
|
||||
data: { bookKey, type: 'iframe-wheel', deltaY: 100, ctrlKey: false },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
describe('useMouseEvent debounce ref', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('debounced wheel handler dispatches to the latest handlePageFlip after re-render', () => {
|
||||
const fn1 = vi.fn();
|
||||
const fn2 = vi.fn();
|
||||
|
||||
function Wrapper({ handler }: { handler: (msg: MessageEvent) => void }) {
|
||||
// useMouseEvent has the 2nd parameter typed as a union including
|
||||
// React.MouseEvent — we cast through unknown to satisfy the typecheck
|
||||
// for this focused unit test.
|
||||
useMouseEvent('book-1', handler as unknown as Parameters<typeof useMouseEvent>[1]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { rerender } = render(<Wrapper handler={fn1} />);
|
||||
// Re-render with a new handler reference. The debounced wheel wrapper
|
||||
// should pick up the latest one rather than holding onto fn1 forever.
|
||||
rerender(<Wrapper handler={fn2} />);
|
||||
|
||||
dispatchWheelMessage('book-1');
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150); // exceed the 100ms debounce
|
||||
});
|
||||
|
||||
expect(fn1).not.toHaveBeenCalled();
|
||||
expect(fn2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock isomorphic-ws so that if the legacy (non-fetch) path is hit on
|
||||
// Cloudflare Workers, the test fails loudly instead of attempting a real
|
||||
// WebSocket connection.
|
||||
vi.mock('isomorphic-ws', () => ({
|
||||
default: class {
|
||||
constructor() {
|
||||
throw new Error('isomorphic-ws should not be used on Cloudflare Workers');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub the Supabase client so importing edgeTTS.ts (transitively via
|
||||
// @/utils/fetch -> @/utils/access) does not instantiate a real GoTrueClient.
|
||||
// Each `vi.resetModules()` would otherwise create another client and Supabase
|
||||
// logs "Multiple GoTrueClient instances detected" to stderr.
|
||||
vi.mock('@/utils/supabase', () => ({
|
||||
supabase: { auth: { getSession: async () => ({ data: { session: null } }) } },
|
||||
createSupabaseClient: () => ({}),
|
||||
createSupabaseAdminClient: () => ({}),
|
||||
}));
|
||||
|
||||
type GlobalWithWsPair = typeof globalThis & { WebSocketPair?: unknown };
|
||||
|
||||
describe('EdgeSpeechTTS on Cloudflare Workers', () => {
|
||||
let originalWebSocketPair: unknown;
|
||||
let originalFetch: typeof fetch | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Simulate Cloudflare Workers by defining WebSocketPair on globalThis.
|
||||
originalWebSocketPair = (globalThis as GlobalWithWsPair).WebSocketPair;
|
||||
(globalThis as GlobalWithWsPair).WebSocketPair = function WebSocketPair() {};
|
||||
originalFetch = globalThis.fetch;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWebSocketPair === undefined) {
|
||||
delete (globalThis as GlobalWithWsPair).WebSocketPair;
|
||||
} else {
|
||||
(globalThis as GlobalWithWsPair).WebSocketPair = originalWebSocketPair;
|
||||
}
|
||||
if (originalFetch) {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('uses fetch-based WebSocket upgrade and returns audio Response', async () => {
|
||||
// Build a mock WebSocket that records listeners and emits a frame
|
||||
// containing valid audio after both speech.config and ssml are sent.
|
||||
const listeners: Record<string, Array<(event: unknown) => void>> = {};
|
||||
const mockSocket = {
|
||||
accept: vi.fn(),
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
addEventListener: vi.fn((type: string, cb: (event: unknown) => void) => {
|
||||
(listeners[type] ??= []).push(cb);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
// Simulate server responses once both config + ssml messages are sent.
|
||||
let sendCount = 0;
|
||||
mockSocket.send.mockImplementation(() => {
|
||||
sendCount++;
|
||||
if (sendCount === 2) {
|
||||
// Binary audio frame: [2-byte big-endian header length][header text][audio body]
|
||||
const headerText = 'X-RequestId:1\r\nContent-Type:audio/mpeg\r\nPath:audio\r\n';
|
||||
const headerBytes = new TextEncoder().encode(headerText);
|
||||
const audioBody = new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]);
|
||||
const frame = new Uint8Array(2 + headerBytes.byteLength + audioBody.byteLength);
|
||||
new DataView(frame.buffer).setInt16(0, headerBytes.byteLength);
|
||||
frame.set(headerBytes, 2);
|
||||
frame.set(audioBody, 2 + headerBytes.byteLength);
|
||||
|
||||
// Dispatch on a microtask so the send() call returns first.
|
||||
queueMicrotask(() => {
|
||||
for (const cb of listeners['message'] ?? []) {
|
||||
cb({ data: frame.buffer });
|
||||
}
|
||||
for (const cb of listeners['message'] ?? []) {
|
||||
cb({ data: 'X-RequestId:1\r\nPath: turn.end\r\n\r\n' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
status: 101,
|
||||
webSocket: mockSocket,
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
// Import AFTER the mocks and globals are set up.
|
||||
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
|
||||
const tts = new EdgeSpeechTTS('wss');
|
||||
const response = await tts.create({
|
||||
lang: 'en-US',
|
||||
text: 'hello',
|
||||
voice: 'en-US-AriaNeural',
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
const buffer = await response.arrayBuffer();
|
||||
expect(new Uint8Array(buffer)).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]));
|
||||
|
||||
// fetch should be called once with an https URL and an Upgrade header.
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const call = fetchSpy.mock.calls[0]!;
|
||||
const calledUrl = call[0] as string | URL;
|
||||
const calledInit = call[1] as RequestInit;
|
||||
expect(String(calledUrl)).toContain('https://speech.platform.bing.com/');
|
||||
expect(String(calledUrl)).not.toContain('wss://');
|
||||
const headers = calledInit.headers as Record<string, string>;
|
||||
expect(headers['Upgrade']).toBe('websocket');
|
||||
|
||||
// The WebSocket returned by fetch must be accepted before use, and both
|
||||
// the speech.config and ssml messages must be sent.
|
||||
expect(mockSocket.accept).toHaveBeenCalledOnce();
|
||||
expect(mockSocket.send).toHaveBeenCalledTimes(2);
|
||||
// Socket is closed once turn.end is received.
|
||||
expect(mockSocket.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('decodes Blob binary frames (Cloudflare Workers shape)', async () => {
|
||||
// On real Cloudflare Workers, WebSocket binary frames arrive as Blob
|
||||
// instances rather than ArrayBuffer. This test guards that code path
|
||||
// by having the mock socket emit Blob messages.
|
||||
const listeners: Record<string, Array<(event: unknown) => void>> = {};
|
||||
const mockSocket = {
|
||||
accept: vi.fn(),
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
addEventListener: vi.fn((type: string, cb: (event: unknown) => void) => {
|
||||
(listeners[type] ??= []).push(cb);
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
|
||||
const buildFrame = (body: Uint8Array) => {
|
||||
const headerText = 'X-RequestId:1\r\nContent-Type:audio/mpeg\r\nPath:audio\r\n';
|
||||
const headerBytes = new TextEncoder().encode(headerText);
|
||||
const frame = new Uint8Array(2 + headerBytes.byteLength + body.byteLength);
|
||||
new DataView(frame.buffer).setInt16(0, headerBytes.byteLength);
|
||||
frame.set(headerBytes, 2);
|
||||
frame.set(body, 2 + headerBytes.byteLength);
|
||||
return new Blob([frame]);
|
||||
};
|
||||
|
||||
let sendCount = 0;
|
||||
mockSocket.send.mockImplementation(() => {
|
||||
sendCount++;
|
||||
if (sendCount === 2) {
|
||||
queueMicrotask(() => {
|
||||
// Two binary Blob frames...
|
||||
for (const cb of listeners['message'] ?? []) {
|
||||
cb({ data: buildFrame(new Uint8Array([0x01, 0x02, 0x03])) });
|
||||
}
|
||||
for (const cb of listeners['message'] ?? []) {
|
||||
cb({ data: buildFrame(new Uint8Array([0x04, 0x05])) });
|
||||
}
|
||||
// ...then turn.end text message (fires before blob.arrayBuffer() resolves).
|
||||
for (const cb of listeners['message'] ?? []) {
|
||||
cb({ data: 'X-RequestId:1\r\nPath:turn.end\r\n\r\n' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
status: 101,
|
||||
webSocket: mockSocket,
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
|
||||
const tts = new EdgeSpeechTTS('wss');
|
||||
const response = await tts.create({
|
||||
lang: 'en-US',
|
||||
text: 'hello',
|
||||
voice: 'en-US-AriaNeural',
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
});
|
||||
|
||||
// Both Blob frames should be decoded in receive order before the
|
||||
// turn.end message finalizes the audio payload.
|
||||
const buffer = await response.arrayBuffer();
|
||||
expect(new Uint8Array(buffer)).toEqual(new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]));
|
||||
expect(mockSocket.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects when fetch upgrade returns non-101 status', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
status: 403,
|
||||
webSocket: undefined,
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
|
||||
const tts = new EdgeSpeechTTS('wss');
|
||||
await expect(
|
||||
tts.create({
|
||||
lang: 'en-US',
|
||||
text: 'hello',
|
||||
voice: 'en-US-AriaNeural',
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
parseRedirect,
|
||||
parsePinyin,
|
||||
cleanWikiMarkup,
|
||||
parseDefinitions,
|
||||
fetchChineseDefinition,
|
||||
} from '@/services/dictionaries/chineseDict';
|
||||
|
||||
// Sample wikitext for 書 (traditional, full entry)
|
||||
const WIKITEXT_SHU_TRADITIONAL = `
|
||||
==Chinese==
|
||||
|
||||
===Glyph origin===
|
||||
Some origin text.
|
||||
|
||||
===Pronunciation===
|
||||
{{zh-pron
|
||||
|m=shū
|
||||
|c=syu1
|
||||
|cat=n,v,pn
|
||||
}}
|
||||
|
||||
===Definitions===
|
||||
{{head|zh|hanzi}}
|
||||
|
||||
# [[book]]; [[codex]]
|
||||
# [[letter]]; [[document]]
|
||||
# form of a written or printed [[Chinese character]]; [[style]]
|
||||
# {{lb|zh|literary}} [[Chinese character]]; [[writing]]; [[script]]
|
||||
# {{lb|zh|historical}} ancient government [[post]]
|
||||
# [[storytelling]]
|
||||
# to [[write]]
|
||||
# {{surname|zh}}
|
||||
|
||||
===Compounds===
|
||||
* {{zh-l|書法}}
|
||||
`;
|
||||
|
||||
// Sample wikitext for 书 (simplified, redirect)
|
||||
const WIKITEXT_SHU_SIMPLIFIED = `
|
||||
{{also|書}}
|
||||
{{character info}}
|
||||
==Translingual==
|
||||
|
||||
===Han character===
|
||||
{{Han char|rn=5|rad=乙|as=3|sn=4}}
|
||||
|
||||
==Chinese==
|
||||
|
||||
===Glyph origin===
|
||||
{{Han simp|書}}
|
||||
|
||||
===Definitions===
|
||||
{{zh-see|書}}
|
||||
`;
|
||||
|
||||
// Sample wikitext for 你好 (multi-character word)
|
||||
const WIKITEXT_NI_HAO = `
|
||||
==Chinese==
|
||||
|
||||
===Pronunciation===
|
||||
{{zh-pron
|
||||
|m=nǐ hǎo
|
||||
|c=nei5 hou2
|
||||
|cat=intj
|
||||
}}
|
||||
|
||||
===Definitions===
|
||||
{{head|zh|interjection}}
|
||||
|
||||
# [[hello]]; [[hi]]
|
||||
|
||||
====Usage notes====
|
||||
Often used as a polite greeting.
|
||||
|
||||
===See also===
|
||||
* {{zh-l|您好}}
|
||||
`;
|
||||
|
||||
describe('parseRedirect', () => {
|
||||
it('detects zh-see redirect', () => {
|
||||
expect(parseRedirect(WIKITEXT_SHU_SIMPLIFIED)).toBe('書');
|
||||
});
|
||||
|
||||
it('returns null when no redirect', () => {
|
||||
expect(parseRedirect(WIKITEXT_SHU_TRADITIONAL)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseRedirect('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePinyin', () => {
|
||||
it('extracts pinyin from zh-pron template', () => {
|
||||
expect(parsePinyin(WIKITEXT_SHU_TRADITIONAL)).toBe('shū');
|
||||
});
|
||||
|
||||
it('extracts multi-syllable pinyin', () => {
|
||||
expect(parsePinyin(WIKITEXT_NI_HAO)).toBe('nǐ hǎo');
|
||||
});
|
||||
|
||||
it('returns null when no zh-pron template', () => {
|
||||
expect(parsePinyin('no pronunciation here')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when zh-pron has no mandarin entry', () => {
|
||||
const wikitext = '{{zh-pron\n|c=syu1\n}}';
|
||||
expect(parsePinyin(wikitext)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanWikiMarkup', () => {
|
||||
it('cleans simple wiki links', () => {
|
||||
expect(cleanWikiMarkup('[[book]]')).toBe('book');
|
||||
});
|
||||
|
||||
it('cleans piped wiki links', () => {
|
||||
expect(cleanWikiMarkup('[[Chinese character|character]]')).toBe('character');
|
||||
});
|
||||
|
||||
it('cleans language label templates', () => {
|
||||
expect(cleanWikiMarkup('{{lb|zh|literary}} text')).toBe('(literary) text');
|
||||
});
|
||||
|
||||
it('cleans multiple labels', () => {
|
||||
expect(cleanWikiMarkup('{{lb|zh|historical|archaic}} text')).toBe('(historical, archaic) text');
|
||||
});
|
||||
|
||||
it('cleans surname template', () => {
|
||||
expect(cleanWikiMarkup('{{surname|zh}}')).toBe('A surname');
|
||||
});
|
||||
|
||||
it('cleans zh-abbrev template', () => {
|
||||
expect(cleanWikiMarkup('{{zh-abbrev|书经}}')).toBe('abbreviation of 书经');
|
||||
});
|
||||
|
||||
it('cleans l template', () => {
|
||||
expect(cleanWikiMarkup('{{l|zh|書}}')).toBe('書');
|
||||
});
|
||||
|
||||
it('removes unknown templates', () => {
|
||||
expect(cleanWikiMarkup('{{unknown|template}}')).toBe('');
|
||||
});
|
||||
|
||||
it('handles mixed content', () => {
|
||||
const input = '{{lb|zh|literary}} [[Chinese character]]; [[writing]]';
|
||||
expect(cleanWikiMarkup(input)).toBe('(literary) Chinese character; writing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDefinitions', () => {
|
||||
it('parses definitions from traditional character entry', () => {
|
||||
const result = parseDefinitions(WIKITEXT_SHU_TRADITIONAL);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.meanings).toContain('book; codex');
|
||||
expect(result[0]!.meanings).toContain('letter; document');
|
||||
expect(result[0]!.meanings).toContain('to write');
|
||||
expect(result[0]!.meanings).toContain('A surname');
|
||||
});
|
||||
|
||||
it('parses definitions from multi-character word', () => {
|
||||
const result = parseDefinitions(WIKITEXT_NI_HAO);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.meanings).toContain('hello; hi');
|
||||
});
|
||||
|
||||
it('returns empty for redirect entries', () => {
|
||||
const result = parseDefinitions(WIKITEXT_SHU_SIMPLIFIED);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty for text without definitions', () => {
|
||||
expect(parseDefinitions('no definitions here')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips sub-definition lines (## lines)', () => {
|
||||
const wikitext = `===Definitions===
|
||||
{{head|zh|hanzi}}
|
||||
|
||||
# main definition
|
||||
## sub definition
|
||||
# another main
|
||||
|
||||
===Other===`;
|
||||
const result = parseDefinitions(wikitext);
|
||||
expect(result[0]!.meanings).toHaveLength(2);
|
||||
expect(result[0]!.meanings).toContain('main definition');
|
||||
expect(result[0]!.meanings).toContain('another main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChineseDefinition', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fetches and parses a traditional character', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
parse: { wikitext: { '*': WIKITEXT_SHU_TRADITIONAL } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchChineseDefinition('書');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.pinyin).toBe('shū');
|
||||
expect(result!.word).toBe('書');
|
||||
expect(result!.definitions[0]!.meanings).toContain('book; codex');
|
||||
});
|
||||
|
||||
it('follows simplified → traditional redirect', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
// First call returns simplified (redirect)
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
parse: { wikitext: { '*': WIKITEXT_SHU_SIMPLIFIED } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Second call returns traditional (full entry)
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
parse: { wikitext: { '*': WIKITEXT_SHU_TRADITIONAL } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchChineseDefinition('书');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.word).toBe('書');
|
||||
expect(result!.pinyin).toBe('shū');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns null on network error', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 404 }));
|
||||
|
||||
const result = await fetchChineseDefinition('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when wikitext has no useful data', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
parse: { wikitext: { '*': '==Translingual==\nSome unrelated content.' } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchChineseDefinition('test');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ type HardcoverClientTestApi = {
|
||||
token: string;
|
||||
extractISBN: (book: Book) => string | null;
|
||||
request: <TVariables, TData>(query: string, variables: TVariables) => Promise<TData>;
|
||||
fetchBookContext: (book: Book) => Promise<TestBookContext | null>;
|
||||
buildJournalPayload: (
|
||||
note: BookNote,
|
||||
config: BookConfig,
|
||||
@@ -254,6 +255,74 @@ describe('HardcoverClient', () => {
|
||||
expect(variables?.started_at).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
test('should prefer the active user read edition when resolving Hardcover context', async () => {
|
||||
const book = {
|
||||
metadata: {
|
||||
isbn: '9780679783268',
|
||||
},
|
||||
} as unknown as Book;
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: { me: { id: 1 } } }),
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
editions: [
|
||||
{
|
||||
id: 101,
|
||||
pages: 320,
|
||||
reading_format_id: 1,
|
||||
book: {
|
||||
id: 202,
|
||||
pages: 500,
|
||||
user_books: [
|
||||
{
|
||||
id: 303,
|
||||
status_id: 2,
|
||||
edition: {
|
||||
id: 404,
|
||||
pages: 410,
|
||||
reading_format_id: 1,
|
||||
},
|
||||
user_book_reads: [
|
||||
{
|
||||
id: 505,
|
||||
started_at: '2026-03-29',
|
||||
edition: {
|
||||
id: 606,
|
||||
pages: 400,
|
||||
reading_format_id: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const context = await clientApi.ensureBookInLibrary(book, true);
|
||||
|
||||
expect(context).toMatchObject({
|
||||
editionId: 606,
|
||||
pages: 400,
|
||||
bookId: 202,
|
||||
bookPages: 500,
|
||||
userBook: {
|
||||
id: 303,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should format multiline quote text with a short divider before the note', () => {
|
||||
const note = {
|
||||
id: '1',
|
||||
@@ -327,6 +396,145 @@ describe('HardcoverClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should reuse the active read returned when promoting a book to currently reading', async () => {
|
||||
const book = {
|
||||
createdAt: 1711737600000,
|
||||
metadata: { isbn: '1234567890' },
|
||||
} as Book;
|
||||
const config = { progress: [25, 100] } as BookConfig;
|
||||
|
||||
vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({
|
||||
editionId: 101,
|
||||
pages: 100,
|
||||
bookId: 202,
|
||||
bookPages: 100,
|
||||
userBook: {
|
||||
id: 303,
|
||||
status_id: 1,
|
||||
user_book_reads: [],
|
||||
},
|
||||
});
|
||||
|
||||
const requestSpy = vi.spyOn(clientApi, 'request').mockImplementation(async (query) => {
|
||||
if (String(query).includes('mutation UpdateUserBook')) {
|
||||
return {
|
||||
update_user_book: {
|
||||
user_book: {
|
||||
user_book_reads: [{ id: 404, started_at: '2024-03-29' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
await client.pushProgress(book, config);
|
||||
|
||||
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
|
||||
const updateReadCall = requestCalls.find((call) =>
|
||||
String(call[0]).includes('mutation UpdateRead'),
|
||||
);
|
||||
const insertReadCall = requestCalls.find((call) =>
|
||||
String(call[0]).includes('mutation InsertRead'),
|
||||
);
|
||||
|
||||
expect(updateReadCall).toBeDefined();
|
||||
expect(insertReadCall).toBeUndefined();
|
||||
expect(updateReadCall?.[1]).toMatchObject({
|
||||
id: 404,
|
||||
progress_pages: 25,
|
||||
edition_id: 101,
|
||||
started_at: '2024-03-29',
|
||||
});
|
||||
});
|
||||
|
||||
test('should reuse the active read returned when adding a book through sync', async () => {
|
||||
const book = {
|
||||
createdAt: 1711737600000,
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
} as Book;
|
||||
const config = { progress: [25, 100] } as BookConfig;
|
||||
|
||||
vi.spyOn(clientApi, 'fetchBookContext').mockResolvedValue({
|
||||
editionId: 101,
|
||||
pages: 100,
|
||||
bookId: 202,
|
||||
bookPages: 100,
|
||||
userBook: null,
|
||||
} as TestBookContext);
|
||||
|
||||
const requestSpy = vi.spyOn(clientApi, 'request').mockImplementation(async (query) => {
|
||||
if (String(query).includes('mutation InsertUserBook')) {
|
||||
return {
|
||||
insert_user_book: {
|
||||
user_book: {
|
||||
id: 303,
|
||||
user_book_reads: [{ id: 404, started_at: '2024-03-29' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
await client.pushProgress(book, config);
|
||||
|
||||
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
|
||||
const updateReadCall = requestCalls.find((call) =>
|
||||
String(call[0]).includes('mutation UpdateRead'),
|
||||
);
|
||||
const insertReadCall = requestCalls.find((call) =>
|
||||
String(call[0]).includes('mutation InsertRead'),
|
||||
);
|
||||
|
||||
expect(updateReadCall).toBeDefined();
|
||||
expect(insertReadCall).toBeUndefined();
|
||||
expect(updateReadCall?.[1]).toMatchObject({
|
||||
id: 404,
|
||||
progress_pages: 25,
|
||||
edition_id: 101,
|
||||
started_at: '2024-03-29',
|
||||
});
|
||||
});
|
||||
|
||||
test('should scale progress pages from local percentage to Hardcover edition pages', async () => {
|
||||
const book = {
|
||||
createdAt: 1711737600000,
|
||||
metadata: { isbn: '1234567890' },
|
||||
} as Book;
|
||||
const config = { progress: [25, 100] } as BookConfig;
|
||||
|
||||
vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({
|
||||
editionId: 101,
|
||||
pages: 400,
|
||||
bookId: 202,
|
||||
bookPages: 400,
|
||||
userBook: {
|
||||
id: 303,
|
||||
status_id: 2,
|
||||
user_book_reads: [],
|
||||
},
|
||||
});
|
||||
const requestSpy = vi.spyOn(clientApi, 'request').mockResolvedValue({});
|
||||
|
||||
await client.pushProgress(book, config);
|
||||
|
||||
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
|
||||
const insertReadCall = requestCalls.find((call) =>
|
||||
String(call[0]).includes('mutation InsertRead'),
|
||||
);
|
||||
expect(insertReadCall).toBeDefined();
|
||||
expect(insertReadCall?.[1]).toMatchObject({
|
||||
user_book_id: 303,
|
||||
edition_id: 101,
|
||||
progress_pages: 100,
|
||||
started_at: '2024-03-29',
|
||||
});
|
||||
});
|
||||
|
||||
test('should not promote an existing user book when syncing notes only', async () => {
|
||||
const book = {
|
||||
hash: 'book-hash',
|
||||
@@ -390,4 +598,118 @@ describe('HardcoverClient', () => {
|
||||
calls.some((call: { query: string }) => call.query.includes('mutation UpdateUserBook')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should throw when insert_user_book returns a null user_book', async () => {
|
||||
const book = { title: 'Test Book', author: 'Test Author' } as Book;
|
||||
const config = { progress: [25, 100] } as BookConfig;
|
||||
|
||||
vi.spyOn(clientApi, 'fetchBookContext').mockResolvedValue({
|
||||
editionId: 101,
|
||||
pages: 100,
|
||||
bookId: 202,
|
||||
bookPages: 100,
|
||||
userBook: null,
|
||||
} as TestBookContext);
|
||||
|
||||
vi.spyOn(clientApi, 'request').mockImplementation(async (query) => {
|
||||
if (String(query).includes('mutation InsertUserBook')) {
|
||||
return { insert_user_book: { error: 'conflict', user_book: null } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await expect(client.pushProgress(book, config)).rejects.toThrow('insert_user_book failed');
|
||||
});
|
||||
|
||||
test('should skip progress push when Hardcover edition page count is unknown', async () => {
|
||||
const book = { createdAt: 1711737600000, metadata: { isbn: '1234567890' } } as Book;
|
||||
const config = { progress: [25, 100] } as BookConfig;
|
||||
|
||||
vi.spyOn(clientApi, 'ensureBookInLibrary').mockResolvedValue({
|
||||
editionId: 101,
|
||||
pages: null,
|
||||
bookId: 202,
|
||||
bookPages: null,
|
||||
userBook: { id: 303, status_id: 2, user_book_reads: [] },
|
||||
} as TestBookContext);
|
||||
const requestSpy = vi.spyOn(clientApi, 'request').mockResolvedValue({});
|
||||
|
||||
await client.pushProgress(book, config);
|
||||
|
||||
const requestCalls = requestSpy.mock.calls as RequestSpyCall[];
|
||||
const insertReadCall = requestCalls.find((call) =>
|
||||
String(call[0]).includes('mutation InsertRead'),
|
||||
);
|
||||
const updateReadCall = requestCalls.find((call) =>
|
||||
String(call[0]).includes('mutation UpdateRead'),
|
||||
);
|
||||
expect(insertReadCall).toBeUndefined();
|
||||
expect(updateReadCall).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should apply edition preference when resolving context via title search', async () => {
|
||||
const book = { title: 'Test Book', author: 'Test Author' } as Book;
|
||||
|
||||
// authenticate
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: { me: { id: 1 } } }),
|
||||
});
|
||||
// QUERY_SEARCH_BOOK
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
search: {
|
||||
results: [{ id: 202, pages: 300, featured_edition_id: 101 }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
// QUERY_GET_BOOK_USER_DATA
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
editions: [
|
||||
{
|
||||
book: {
|
||||
id: 202,
|
||||
pages: 300,
|
||||
user_books: [
|
||||
{
|
||||
id: 303,
|
||||
status_id: 2,
|
||||
edition: { id: 404, pages: 310, reading_format_id: 1 },
|
||||
user_book_reads: [
|
||||
{
|
||||
id: 505,
|
||||
started_at: '2026-03-29',
|
||||
edition: { id: 606, pages: 400, reading_format_id: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const context = await clientApi.fetchBookContext(book);
|
||||
|
||||
expect(context).toMatchObject({
|
||||
editionId: 606,
|
||||
pages: 400,
|
||||
bookId: 202,
|
||||
bookPages: 300,
|
||||
userBook: { id: 303 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('@/libs/storage', () => ({
|
||||
}));
|
||||
|
||||
import { BaseAppService } from '@/services/appService';
|
||||
import { buildBookLookupIndex } from '@/services/bookService';
|
||||
|
||||
// Concrete test subclass of BaseAppService with mocked fs
|
||||
class TestAppService extends BaseAppService {
|
||||
@@ -240,7 +241,7 @@ describe('importBook metaHash deduplication', () => {
|
||||
fs.openFile.mockResolvedValue(new File(['content'], 'test.epub'));
|
||||
|
||||
// Transient import requires string file path
|
||||
const result = await service.importBook('/path/to/test.epub', books, true, true, false, true);
|
||||
const result = await service.importBook('/path/to/test.epub', books, { transient: true });
|
||||
|
||||
// Should create a new entry, not override existing
|
||||
expect(result).not.toBe(existingBook);
|
||||
@@ -593,3 +594,58 @@ describe('importBook metaHash aggregation', () => {
|
||||
expect(writtenConfig.booknotes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importBook with BookLookupIndex', () => {
|
||||
let service: TestAppService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new TestAppService();
|
||||
const fs = service.getFs();
|
||||
fs.exists.mockResolvedValue(false);
|
||||
fs.createDir.mockResolvedValue(undefined);
|
||||
fs.writeFile.mockResolvedValue(undefined);
|
||||
fs.removeDir.mockResolvedValue(undefined);
|
||||
fs.readFile.mockResolvedValue('{}');
|
||||
});
|
||||
|
||||
it('updates the lookup index after a successful new-book import', async () => {
|
||||
const books: Book[] = [];
|
||||
const lookupIndex = buildBookLookupIndex(books);
|
||||
|
||||
mockPartialMD5.mockResolvedValue('imported-hash');
|
||||
setupMockBookDoc();
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
const result = await service.importBook(mockFile, books, { lookupIndex });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hash).toBe('imported-hash');
|
||||
// The lookup index must contain the freshly imported book
|
||||
expect(lookupIndex.byHash.get('imported-hash')).toBe(result);
|
||||
if (result?.metaHash) {
|
||||
const key = `${result.metaHash}:${result.format}`;
|
||||
expect(lookupIndex.byMetaKey.get(key)).toContain(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds existing book via lookup index without scanning books array', async () => {
|
||||
const metaHash = getMetadataHash(TEST_METADATA);
|
||||
const existingBook = makeBook({ hash: 'existing', metaHash });
|
||||
// Pass an EMPTY books array but a lookup index that already contains the book.
|
||||
// If the implementation falls back to books.find(), it will fail to find the
|
||||
// existing book and create a new one. If it consults the lookup index, it
|
||||
// will discover the existing book and update it.
|
||||
const books: Book[] = [];
|
||||
const lookupIndex = buildBookLookupIndex([existingBook]);
|
||||
|
||||
mockPartialMD5.mockResolvedValue('existing'); // same hash as existing
|
||||
setupMockBookDoc();
|
||||
|
||||
const mockFile = new File(['content'], 'test.epub', { type: 'application/epub+zip' });
|
||||
const result = await service.importBook(mockFile, books, { lookupIndex });
|
||||
|
||||
// Should reuse the existing book object via lookup index
|
||||
expect(result).toBe(existingBook);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,4 +84,63 @@ describe('RSVPController', () => {
|
||||
expect(controller.currentState.words[0]!.text).toBe('Foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentDisplayWord', () => {
|
||||
test('returns full word when splitHyphens is false', () => {
|
||||
const doc = makeDoc('well-known');
|
||||
const view = createMockView(0, [doc]);
|
||||
const controller = new RSVPController(view, 'test-book-abc123');
|
||||
controller.setSplitHyphens(false);
|
||||
controller.start();
|
||||
|
||||
expect(controller.currentDisplayWord?.text).toBe('well-known');
|
||||
});
|
||||
|
||||
test('returns first part only when splitHyphens is true', () => {
|
||||
const doc = makeDoc('well-known');
|
||||
const view = createMockView(0, [doc]);
|
||||
const controller = new RSVPController(view, 'test-book-abc123');
|
||||
controller.setSplitHyphens(true);
|
||||
controller.start();
|
||||
|
||||
expect(controller.currentDisplayWord?.text).toBe('well-');
|
||||
});
|
||||
|
||||
test('returns unsplit word when splitHyphens is true but no hyphen pattern', () => {
|
||||
const doc = makeDoc('hello');
|
||||
const view = createMockView(0, [doc]);
|
||||
const controller = new RSVPController(view, 'test-book-abc123');
|
||||
controller.setSplitHyphens(true);
|
||||
controller.start();
|
||||
|
||||
expect(controller.currentDisplayWord?.text).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate word blank insertion', () => {
|
||||
test('inserts blank between two consecutive identical words', () => {
|
||||
const doc = makeDoc('the the cat');
|
||||
const view = createMockView(0, [doc]);
|
||||
const controller = new RSVPController(view, 'test-book-abc123');
|
||||
controller.start();
|
||||
|
||||
const words = controller.currentState.words;
|
||||
expect(words[0]!.text).toBe('the');
|
||||
expect(words[1]!.text).toBe(' ');
|
||||
expect(words[2]!.text).toBe('the');
|
||||
expect(words[3]!.text).toBe('cat');
|
||||
});
|
||||
|
||||
test('does not insert blank between different words', () => {
|
||||
const doc = makeDoc('the cat');
|
||||
const view = createMockView(0, [doc]);
|
||||
const controller = new RSVPController(view, 'test-book-abc123');
|
||||
controller.start();
|
||||
|
||||
const words = controller.currentState.words;
|
||||
expect(words.length).toBe(2);
|
||||
expect(words[0]!.text).toBe('the');
|
||||
expect(words[1]!.text).toBe('cat');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,5 +274,17 @@ describe('rsvp/utils', () => {
|
||||
test('returns trailing-hyphen word unchanged', () => {
|
||||
expect(getHyphenParts('word-')).toEqual(['word-']);
|
||||
});
|
||||
|
||||
test('splits on ellipsis between letters with trailing ellipsis on non-last parts', () => {
|
||||
expect(getHyphenParts('a...b')).toEqual(['a...', 'b']);
|
||||
});
|
||||
|
||||
test('splits mixed hyphens and ellipses preserving each delimiter', () => {
|
||||
expect(getHyphenParts('foo-bar...baz')).toEqual(['foo-', 'bar...', 'baz']);
|
||||
});
|
||||
|
||||
test('returns ellipsis-only unchanged', () => {
|
||||
expect(getHyphenParts('...')).toEqual(['...']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export function bookTests(
|
||||
const first = await service.importBook(file1, books);
|
||||
|
||||
const file2 = await getBookFile('sample-alice.epub');
|
||||
const second = await service.importBook(file2, books, true, true, true);
|
||||
const second = await service.importBook(file2, books, { overwrite: true });
|
||||
|
||||
expect(books).toHaveLength(1);
|
||||
expect(second!.hash).toBe(first!.hash);
|
||||
|
||||
@@ -10,7 +10,19 @@ vi.mock('@/utils/md5', () => ({
|
||||
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import type { BookData } from '@/store/bookDataStore';
|
||||
import type { BookConfig, BookNote } from '@/types/book';
|
||||
import type { BookConfig, BookNote, Book } from '@/types/book';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import type { EnvConfigType } from '@/services/environment';
|
||||
import type { AppService } from '@/types/system';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
|
||||
function makeEnvConfig(appService: Partial<AppService>): EnvConfigType {
|
||||
return {
|
||||
getAppService: vi.fn().mockResolvedValue(appService as AppService),
|
||||
};
|
||||
}
|
||||
|
||||
const FAKE_SETTINGS = {} as unknown as SystemSettings;
|
||||
|
||||
function makeBookData(id: string, config?: Partial<BookConfig>): BookData {
|
||||
return {
|
||||
@@ -273,4 +285,120 @@ describe('bookDataStore', () => {
|
||||
expect(config!.booknotes).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfig', () => {
|
||||
function makeLibraryBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
hash: 'h1',
|
||||
format: 'EPUB',
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a new library array reference (Zustand change-detection)', async () => {
|
||||
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
|
||||
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
|
||||
|
||||
const book = makeLibraryBook({ hash: 'h1' });
|
||||
useLibraryStore.getState().setLibrary([book]);
|
||||
const before = useLibraryStore.getState().library;
|
||||
|
||||
const data = makeBookData('h1', { progress: [10, 100] });
|
||||
useBookDataStore.setState({ booksData: { h1: data } });
|
||||
|
||||
await useBookDataStore.getState().saveConfig(envConfig, 'h1', data.config!, FAKE_SETTINGS);
|
||||
|
||||
const after = useLibraryStore.getState().library;
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
test('moves the saved book to the front of the library', async () => {
|
||||
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
|
||||
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
|
||||
|
||||
useLibraryStore
|
||||
.getState()
|
||||
.setLibrary([
|
||||
makeLibraryBook({ hash: 'a' }),
|
||||
makeLibraryBook({ hash: 'b' }),
|
||||
makeLibraryBook({ hash: 'c' }),
|
||||
]);
|
||||
|
||||
const data = makeBookData('c', { progress: [5, 100] });
|
||||
useBookDataStore.setState({ booksData: { c: data } });
|
||||
|
||||
await useBookDataStore.getState().saveConfig(envConfig, 'c', data.config!, FAKE_SETTINGS);
|
||||
|
||||
const library = useLibraryStore.getState().library;
|
||||
expect(library.map((b) => b.hash)).toEqual(['c', 'a', 'b']);
|
||||
// hashIndex should be rebuilt to match the new order
|
||||
expect(useLibraryStore.getState().hashIndex.get('c')).toBe(0);
|
||||
expect(useLibraryStore.getState().hashIndex.get('a')).toBe(1);
|
||||
expect(useLibraryStore.getState().hashIndex.get('b')).toBe(2);
|
||||
});
|
||||
|
||||
test('updates visibleLibrary to match the new library order', async () => {
|
||||
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
|
||||
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
|
||||
|
||||
useLibraryStore
|
||||
.getState()
|
||||
.setLibrary([
|
||||
makeLibraryBook({ hash: 'a' }),
|
||||
makeLibraryBook({ hash: 'b', deletedAt: 999 }),
|
||||
makeLibraryBook({ hash: 'c' }),
|
||||
]);
|
||||
|
||||
const data = makeBookData('c', { progress: [5, 100] });
|
||||
useBookDataStore.setState({ booksData: { c: data } });
|
||||
|
||||
await useBookDataStore.getState().saveConfig(envConfig, 'c', data.config!, FAKE_SETTINGS);
|
||||
|
||||
const visible = useLibraryStore.getState().getVisibleLibrary();
|
||||
expect(visible.map((b) => b.hash)).toEqual(['c', 'a']);
|
||||
});
|
||||
|
||||
test('persists progress and writes the library', async () => {
|
||||
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
|
||||
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
|
||||
|
||||
useLibraryStore.getState().setLibrary([makeLibraryBook({ hash: 'h1' })]);
|
||||
|
||||
const data = makeBookData('h1', { progress: [42, 100] });
|
||||
useBookDataStore.setState({ booksData: { h1: data } });
|
||||
|
||||
await useBookDataStore.getState().saveConfig(envConfig, 'h1', data.config!, FAKE_SETTINGS);
|
||||
|
||||
const stored = useLibraryStore.getState().getBookByHash('h1');
|
||||
expect(stored?.progress).toEqual([42, 100]);
|
||||
expect(saveBookConfig).toHaveBeenCalledOnce();
|
||||
expect(saveLibraryBooks).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('does nothing for unknown book hash', async () => {
|
||||
const saveBookConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
|
||||
const envConfig = makeEnvConfig({ saveBookConfig, saveLibraryBooks });
|
||||
|
||||
useLibraryStore.getState().setLibrary([makeLibraryBook({ hash: 'h1' })]);
|
||||
|
||||
const data = makeBookData('nonexistent', { progress: [1, 100] });
|
||||
useBookDataStore.setState({ booksData: { nonexistent: data } });
|
||||
|
||||
await useBookDataStore
|
||||
.getState()
|
||||
.saveConfig(envConfig, 'nonexistent', data.config!, FAKE_SETTINGS);
|
||||
|
||||
expect(saveBookConfig).not.toHaveBeenCalled();
|
||||
expect(saveLibraryBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,14 @@ vi.mock('@/utils/md5', () => ({
|
||||
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import type { Book, BooksGroup } from '@/types/book';
|
||||
import type { EnvConfigType } from '@/services/environment';
|
||||
import type { AppService } from '@/types/system';
|
||||
|
||||
function makeEnvConfig(appService: Partial<AppService>): EnvConfigType {
|
||||
return {
|
||||
getAppService: vi.fn().mockResolvedValue(appService as AppService),
|
||||
};
|
||||
}
|
||||
|
||||
function makeBook(overrides: Partial<Book> = {}): Book {
|
||||
return {
|
||||
@@ -33,6 +41,8 @@ describe('libraryStore', () => {
|
||||
currentBookshelf: [],
|
||||
selectedBooks: new Set(),
|
||||
groups: {},
|
||||
hashIndex: new Map(),
|
||||
visibleLibrary: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +56,16 @@ describe('libraryStore', () => {
|
||||
expect(state.libraryLoaded).toBe(true);
|
||||
});
|
||||
|
||||
test('builds hash index on setLibrary', () => {
|
||||
const books = [makeBook({ hash: 'a' }), makeBook({ hash: 'b' })];
|
||||
useLibraryStore.getState().setLibrary(books);
|
||||
|
||||
const state = useLibraryStore.getState();
|
||||
expect(state.hashIndex.get('a')).toBe(0);
|
||||
expect(state.hashIndex.get('b')).toBe(1);
|
||||
expect(state.hashIndex.size).toBe(2);
|
||||
});
|
||||
|
||||
test('calls refreshGroups after setting library', () => {
|
||||
const book = makeBook({ hash: 'a', groupName: 'Fiction' });
|
||||
useLibraryStore.getState().setLibrary([book]);
|
||||
@@ -56,14 +76,153 @@ describe('libraryStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleLibrary', () => {
|
||||
test('filters out books with deletedAt set', () => {
|
||||
describe('getBookByHash', () => {
|
||||
test('returns the book for a known hash', () => {
|
||||
const books = [makeBook({ hash: 'a', title: 'Book A' }), makeBook({ hash: 'b' })];
|
||||
useLibraryStore.getState().setLibrary(books);
|
||||
|
||||
expect(useLibraryStore.getState().getBookByHash('a')?.title).toBe('Book A');
|
||||
});
|
||||
|
||||
test('returns undefined for unknown hash', () => {
|
||||
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a' })]);
|
||||
expect(useLibraryStore.getState().getBookByHash('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBookProgress', () => {
|
||||
test('updates progress and explicitly clears readingStatus when undefined is passed', () => {
|
||||
const books = [makeBook({ hash: 'a', progress: [1, 100], readingStatus: 'unread' })];
|
||||
useLibraryStore.getState().setLibrary(books);
|
||||
|
||||
useLibraryStore.getState().updateBookProgress('a', [50, 100], undefined);
|
||||
|
||||
const book = useLibraryStore.getState().getBookByHash('a');
|
||||
expect(book?.progress).toEqual([50, 100]);
|
||||
expect(book?.readingStatus).toBeUndefined();
|
||||
expect(book?.updatedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('does nothing for unknown hash', () => {
|
||||
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a' })]);
|
||||
useLibraryStore.getState().updateBookProgress('nonexistent', [1, 1], undefined);
|
||||
expect(useLibraryStore.getState().library).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('marks book as finished at 100%', () => {
|
||||
const books = [makeBook({ hash: 'a' })];
|
||||
useLibraryStore.getState().setLibrary(books);
|
||||
|
||||
useLibraryStore.getState().updateBookProgress('a', [100, 100], 'finished');
|
||||
|
||||
expect(useLibraryStore.getState().getBookByHash('a')?.readingStatus).toBe('finished');
|
||||
});
|
||||
|
||||
test('creates a new library array reference (Zustand change-detection)', () => {
|
||||
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a' })]);
|
||||
const before = useLibraryStore.getState().library;
|
||||
|
||||
useLibraryStore.getState().updateBookProgress('a', [50, 100], undefined);
|
||||
|
||||
const after = useLibraryStore.getState().library;
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
test('replaces the book entry with a new object (no in-place mutation)', () => {
|
||||
const original = makeBook({ hash: 'a', progress: [1, 100] });
|
||||
useLibraryStore.getState().setLibrary([original]);
|
||||
|
||||
useLibraryStore.getState().updateBookProgress('a', [50, 100], undefined);
|
||||
|
||||
// Original reference must NOT be mutated
|
||||
expect(original.progress).toEqual([1, 100]);
|
||||
// Store should have a new book object
|
||||
expect(useLibraryStore.getState().getBookByHash('a')).not.toBe(original);
|
||||
});
|
||||
|
||||
test('updates visibleLibrary cache so callers see fresh progress', () => {
|
||||
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a', progress: [1, 100] })]);
|
||||
|
||||
useLibraryStore.getState().updateBookProgress('a', [42, 100], undefined);
|
||||
|
||||
const visible = useLibraryStore.getState().getVisibleLibrary();
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0]?.progress).toEqual([42, 100]);
|
||||
});
|
||||
|
||||
test('does not include deleted books in visibleLibrary after update', () => {
|
||||
const books = [
|
||||
makeBook({ hash: 'a', deletedAt: null }),
|
||||
makeBook({ hash: 'a' }),
|
||||
makeBook({ hash: 'b', deletedAt: 12345 }),
|
||||
makeBook({ hash: 'c' }),
|
||||
];
|
||||
useLibraryStore.setState({ library: books });
|
||||
useLibraryStore.getState().setLibrary(books);
|
||||
|
||||
useLibraryStore.getState().updateBookProgress('a', [10, 100], undefined);
|
||||
|
||||
const visible = useLibraryStore.getState().getVisibleLibrary();
|
||||
expect(visible.map((b) => b.hash)).toEqual(['a', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBooks', () => {
|
||||
test('persists by default', async () => {
|
||||
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
|
||||
const envConfig = makeEnvConfig({ saveLibraryBooks });
|
||||
|
||||
await useLibraryStore.getState().updateBooks(envConfig, [makeBook({ hash: 'a' })]);
|
||||
|
||||
expect(saveLibraryBooks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('skips persistence when skipSave: true', async () => {
|
||||
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
|
||||
const envConfig = makeEnvConfig({ saveLibraryBooks });
|
||||
|
||||
await useLibraryStore
|
||||
.getState()
|
||||
.updateBooks(envConfig, [makeBook({ hash: 'a' })], { skipSave: true });
|
||||
|
||||
expect(saveLibraryBooks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('still updates store state when skipSave: true', async () => {
|
||||
const saveLibraryBooks = vi.fn().mockResolvedValue(undefined);
|
||||
const envConfig = makeEnvConfig({ saveLibraryBooks });
|
||||
|
||||
await useLibraryStore
|
||||
.getState()
|
||||
.updateBooks(envConfig, [makeBook({ hash: 'a' })], { skipSave: true });
|
||||
|
||||
expect(useLibraryStore.getState().library).toHaveLength(1);
|
||||
expect(useLibraryStore.getState().getBookByHash('a')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebuildHashIndex', () => {
|
||||
test('rebuilds index after manual array mutation', () => {
|
||||
const books = [makeBook({ hash: 'a' }), makeBook({ hash: 'b' })];
|
||||
useLibraryStore.getState().setLibrary(books);
|
||||
|
||||
// Simulate manual splice + unshift (like saveConfig does)
|
||||
const { library } = useLibraryStore.getState();
|
||||
const [book] = library.splice(1, 1);
|
||||
library.unshift(book!);
|
||||
useLibraryStore.getState().rebuildHashIndex();
|
||||
|
||||
const state = useLibraryStore.getState();
|
||||
expect(state.hashIndex.get('b')).toBe(0);
|
||||
expect(state.hashIndex.get('a')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleLibrary', () => {
|
||||
test('filters out books with deletedAt set', () => {
|
||||
const bookA = makeBook({ hash: 'a', deletedAt: null });
|
||||
const bookB = makeBook({ hash: 'b', deletedAt: 12345 });
|
||||
const bookC = makeBook({ hash: 'c' });
|
||||
const books = [bookA, bookB, bookC];
|
||||
useLibraryStore.setState({ library: books, visibleLibrary: [bookA, bookC] });
|
||||
|
||||
const visible = useLibraryStore.getState().getVisibleLibrary();
|
||||
expect(visible).toHaveLength(2);
|
||||
@@ -72,7 +231,7 @@ describe('libraryStore', () => {
|
||||
|
||||
test('returns all books when none are deleted', () => {
|
||||
const books = [makeBook({ hash: 'a' }), makeBook({ hash: 'b' })];
|
||||
useLibraryStore.setState({ library: books });
|
||||
useLibraryStore.setState({ library: books, visibleLibrary: books });
|
||||
|
||||
const visible = useLibraryStore.getState().getVisibleLibrary();
|
||||
expect(visible).toHaveLength(2);
|
||||
|
||||
@@ -26,7 +26,11 @@ vi.mock('@/store/libraryStore', () => {
|
||||
return {
|
||||
useLibraryStore: create(() => ({
|
||||
library: [],
|
||||
hashIndex: new Map(),
|
||||
setLibrary: vi.fn(),
|
||||
getBookByHash: vi.fn(),
|
||||
updateBookProgress: vi.fn(),
|
||||
rebuildHashIndex: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,7 +66,6 @@ describe('settingsStore', () => {
|
||||
settings: {} as SystemSettings,
|
||||
settingsDialogBookKey: '',
|
||||
isSettingsDialogOpen: false,
|
||||
isSettingsGlobal: true,
|
||||
fontPanelView: 'main-fonts',
|
||||
activeSettingsItemId: null,
|
||||
});
|
||||
@@ -118,19 +117,6 @@ describe('settingsStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSettingsGlobal', () => {
|
||||
test('sets global mode to true', () => {
|
||||
useSettingsStore.getState().setSettingsGlobal(false);
|
||||
useSettingsStore.getState().setSettingsGlobal(true);
|
||||
expect(useSettingsStore.getState().isSettingsGlobal).toBe(true);
|
||||
});
|
||||
|
||||
test('sets global mode to false', () => {
|
||||
useSettingsStore.getState().setSettingsGlobal(false);
|
||||
expect(useSettingsStore.getState().isSettingsGlobal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFontPanelView', () => {
|
||||
test('sets to main-fonts', () => {
|
||||
useSettingsStore.getState().setFontPanelView('main-fonts');
|
||||
|
||||
@@ -149,6 +149,43 @@ describe('themeStore', () => {
|
||||
expect(state.systemIsDarkMode).toBe(true);
|
||||
expect(state.isDarkMode).toBe(false);
|
||||
});
|
||||
|
||||
test('updates themeCode when system theme changes to dark', async () => {
|
||||
const styleModule = await import('@/utils/style');
|
||||
const mockGetThemeCode = vi.mocked(styleModule.getThemeCode);
|
||||
const darkThemeCode = {
|
||||
bg: '#1a1a1a',
|
||||
fg: '#ffffff',
|
||||
primary: '#4d9fff',
|
||||
palette: {
|
||||
'base-100': '#1a1a1a',
|
||||
'base-200': '#2a2a2a',
|
||||
'base-300': '#3a3a3a',
|
||||
'base-content': '#ffffff',
|
||||
neutral: '#333333',
|
||||
'neutral-content': '#ffffff',
|
||||
primary: '#4d9fff',
|
||||
secondary: '#6c757d',
|
||||
accent: '#4d9fff',
|
||||
},
|
||||
isDarkMode: true,
|
||||
};
|
||||
mockGetThemeCode.mockReturnValueOnce(darkThemeCode);
|
||||
|
||||
useThemeStore.setState({ themeMode: 'auto' });
|
||||
useThemeStore.getState().handleSystemThemeChange(true);
|
||||
|
||||
expect(useThemeStore.getState().themeCode).toEqual(darkThemeCode);
|
||||
});
|
||||
|
||||
test('updates data-theme attribute when system theme changes', () => {
|
||||
useThemeStore.setState({ themeMode: 'auto', themeColor: 'default' });
|
||||
useThemeStore.getState().handleSystemThemeChange(true);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('default-dark');
|
||||
|
||||
useThemeStore.getState().handleSystemThemeChange(false);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('default-light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showSystemUI / dismissSystemUI', () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isLanAddress } from '@/utils/network';
|
||||
|
||||
/**
|
||||
* Tests for SSRF protection in the kosync proxy.
|
||||
* The proxy must reject requests to private/internal addresses.
|
||||
* See: https://github.com/readest/readest/security/code-scanning/14
|
||||
*/
|
||||
describe('isLanAddress – SSRF edge cases for proxy', () => {
|
||||
// 0.0.0.0 routes to localhost on many systems
|
||||
it('returns true for 0.0.0.0', () => {
|
||||
expect(isLanAddress('http://0.0.0.0')).toBe(true);
|
||||
expect(isLanAddress('http://0.0.0.0:8080')).toBe(true);
|
||||
});
|
||||
|
||||
// Cloud metadata endpoint (169.254.x.x is already covered, but test the exact AWS one)
|
||||
it('returns true for cloud metadata IP 169.254.169.254', () => {
|
||||
expect(isLanAddress('http://169.254.169.254')).toBe(true);
|
||||
expect(isLanAddress('http://169.254.169.254/latest/meta-data/')).toBe(true);
|
||||
});
|
||||
|
||||
// IPv6 loopback with brackets (URL standard format)
|
||||
it('returns true for bracket-wrapped IPv6 loopback [::1]', () => {
|
||||
expect(isLanAddress('http://[::1]')).toBe(true);
|
||||
expect(isLanAddress('http://[::1]:8080')).toBe(true);
|
||||
});
|
||||
|
||||
// IPv6 link-local with brackets
|
||||
it('returns true for bracket-wrapped IPv6 link-local [fe80::1]', () => {
|
||||
expect(isLanAddress('http://[fe80::1]')).toBe(true);
|
||||
});
|
||||
|
||||
// IPv6 unique local with brackets
|
||||
it('returns true for bracket-wrapped IPv6 unique local [fc00::1] and [fd00::1]', () => {
|
||||
expect(isLanAddress('http://[fc00::1]')).toBe(true);
|
||||
expect(isLanAddress('http://[fd00::abc]')).toBe(true);
|
||||
});
|
||||
|
||||
// Public addresses should still return false
|
||||
it('returns false for public addresses', () => {
|
||||
expect(isLanAddress('https://sync.koreader.rocks')).toBe(false);
|
||||
expect(isLanAddress('https://8.8.8.8')).toBe(false);
|
||||
expect(isLanAddress('http://[2001:db8::1]')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -76,15 +76,15 @@ describe('isLanAddress', () => {
|
||||
// IPv6 private addresses
|
||||
// -----------------------------------------------------------------------
|
||||
describe('IPv6 private addresses', () => {
|
||||
// Note: URL.hostname wraps IPv6 addresses in brackets (e.g., '[::1]'),
|
||||
// so the current implementation's startsWith checks won't match bracket-
|
||||
// wrapped addresses. These tests document the actual behavior.
|
||||
it('returns false for bracket-wrapped IPv6 (URL standard hostname format)', () => {
|
||||
// URL('http://[::1]').hostname === '[::1]' which doesn't match startsWith('::1')
|
||||
expect(isLanAddress('http://[::1]')).toBe(false);
|
||||
expect(isLanAddress('http://[fe80::1]')).toBe(false);
|
||||
expect(isLanAddress('http://[fc00::1]')).toBe(false);
|
||||
expect(isLanAddress('http://[fd00::abc]')).toBe(false);
|
||||
it('returns true for bracket-wrapped IPv6 loopback', () => {
|
||||
expect(isLanAddress('http://[::1]')).toBe(true);
|
||||
expect(isLanAddress('http://[::1]:8080')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for bracket-wrapped IPv6 link-local and unique local', () => {
|
||||
expect(isLanAddress('http://[fe80::1]')).toBe(true);
|
||||
expect(isLanAddress('http://[fc00::1]')).toBe(true);
|
||||
expect(isLanAddress('http://[fd00::abc]')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -368,6 +368,58 @@ describe('getLayoutStyles branches (via getStyles)', () => {
|
||||
// getStyles passes 1.0 as zoomLevel to getLayoutStyles
|
||||
expect(css).toContain('zoom: 1');
|
||||
});
|
||||
|
||||
it('omits only paragraph-related layout rules when useBookLayout is true', () => {
|
||||
const vs = makeViewSettings({
|
||||
useBookLayout: true,
|
||||
lineHeight: 1.7,
|
||||
wordSpacing: 3,
|
||||
letterSpacing: 2,
|
||||
textIndent: 2,
|
||||
paragraphMargin: 1.25,
|
||||
fullJustification: true,
|
||||
hyphenation: true,
|
||||
marginTopPx: 50,
|
||||
});
|
||||
const css = getStyles(vs, theme);
|
||||
// paragraph-specific tokens driven by the Paragraph section must be absent
|
||||
expect(css).not.toContain('--default-text-align:');
|
||||
expect(css).not.toContain('line-height: 1.7');
|
||||
expect(css).not.toContain('word-spacing: 3px');
|
||||
expect(css).not.toContain('letter-spacing: 2px');
|
||||
expect(css).not.toContain('text-indent: 2em');
|
||||
expect(css).not.toContain('hyphens: auto');
|
||||
expect(css).not.toContain('-webkit-hyphens: auto');
|
||||
// non-paragraph layout rules must still be emitted
|
||||
expect(css).toContain('@namespace epub');
|
||||
expect(css).toContain('--margin-top: 50px');
|
||||
expect(css).toContain('--margin-right:');
|
||||
expect(css).toContain('--margin-bottom:');
|
||||
expect(css).toContain('--margin-left:');
|
||||
// font/color/translation sections must still be present
|
||||
expect(css).toContain('--serif:');
|
||||
expect(css).toContain('--theme-bg-color');
|
||||
expect(css).toContain('.translation-source');
|
||||
});
|
||||
|
||||
it('includes paragraph layout rules when useBookLayout is false', () => {
|
||||
const vs = makeViewSettings({
|
||||
useBookLayout: false,
|
||||
lineHeight: 1.7,
|
||||
wordSpacing: 3,
|
||||
letterSpacing: 2,
|
||||
textIndent: 2,
|
||||
fullJustification: true,
|
||||
hyphenation: true,
|
||||
});
|
||||
const css = getStyles(vs, theme);
|
||||
expect(css).toContain('--default-text-align: justify');
|
||||
expect(css).toContain('line-height: 1.7');
|
||||
expect(css).toContain('word-spacing: 3px');
|
||||
expect(css).toContain('letter-spacing: 2px');
|
||||
expect(css).toContain('text-indent: 2em');
|
||||
expect(css).toContain('hyphens: auto');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -245,19 +245,17 @@ describe('transformStylesheet', () => {
|
||||
it('adds width when both left and right bleed', () => {
|
||||
const css = '.bleed { duokan-bleed: left right; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).toContain(
|
||||
'width: calc(var(--_max-width) + var(--page-margin-left) + var(--page-margin-right)) !important',
|
||||
);
|
||||
expect(result).toContain('max-width: calc(var(--full-width) * 1px) !important');
|
||||
expect(result).toContain('width: calc(var(--available-width) * 1px) !important');
|
||||
expect(result).toContain('min-width: calc(var(--available-width) * 1px) !important');
|
||||
expect(result).toContain('max-width: calc(var(--available-width) * 1px) !important');
|
||||
});
|
||||
|
||||
it('adds height when both top and bottom bleed', () => {
|
||||
const css = '.bleed { duokan-bleed: top bottom; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).toContain(
|
||||
'height: calc(100% + var(--page-margin-top) + var(--page-margin-bottom)) !important',
|
||||
);
|
||||
expect(result).toContain('max-height: calc(var(--full-height) * 1px) !important');
|
||||
expect(result).toContain('height: calc(var(--available-height) * 1px) !important');
|
||||
expect(result).toContain('min-height: calc(var(--available-height) * 1px) !important');
|
||||
expect(result).toContain('max-height: calc(var(--available-height) * 1px) !important');
|
||||
});
|
||||
|
||||
it('does not add bleed styles when vertical is true', () => {
|
||||
@@ -268,6 +266,46 @@ describe('transformStylesheet', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hardcoded pixel width clamping', () => {
|
||||
it('adds max-width and border-box when width exceeds viewport', () => {
|
||||
const css = '.calibre8 { display: block; width: 1200px; padding: 2em 0 0 1em; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).toContain('max-width: calc(var(--available-width) * 1px)');
|
||||
expect(result).toContain('box-sizing: border-box');
|
||||
});
|
||||
|
||||
it('does not clamp when width is smaller than viewport', () => {
|
||||
const css = '.box { width: 450px; padding: 2em; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).not.toContain('max-width: calc(var(--available-width)');
|
||||
});
|
||||
|
||||
it('does not add max-width when one already exists', () => {
|
||||
const css = '.box { width: 1200px; max-width: 100%; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
const matches = result.match(/max-width/g);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not affect max-width or min-width properties', () => {
|
||||
const css = '.box { max-width: 1200px; min-width: 200px; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).not.toContain('max-width: calc(var(--available-width)');
|
||||
});
|
||||
|
||||
it('does not add max-width for non-pixel width values', () => {
|
||||
const css = '.box { width: 50%; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).not.toContain('max-width: calc(var(--available-width)');
|
||||
});
|
||||
|
||||
it('does not add max-width for em width values', () => {
|
||||
const css = '.box { width: 20em; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).not.toContain('max-width: calc(var(--available-width)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preserves unrelated CSS', () => {
|
||||
it('passes through CSS without any matching patterns unchanged', () => {
|
||||
const css = '.custom { display: flex; padding: 10px; margin: 5px; }';
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transformBookNoteToDB, transformBookNoteFromDB } from '@/utils/transform';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { DBBookNote } from '@/types/records';
|
||||
import {
|
||||
transformBookNoteToDB,
|
||||
transformBookNoteFromDB,
|
||||
transformBookConfigToDB,
|
||||
transformBookConfigFromDB,
|
||||
} from '@/utils/transform';
|
||||
import { BookConfig, BookNote } from '@/types/book';
|
||||
import { DBBookConfig, DBBookNote } from '@/types/records';
|
||||
|
||||
describe('transformBookNoteToDB with xpointer fields', () => {
|
||||
it('passes through xpointer0 and xpointer1', () => {
|
||||
@@ -112,3 +117,59 @@ describe('transformBookNoteFromDB with xpointer fields', () => {
|
||||
expect(note.xpointer1).toBe('/body/DocFragment[1]/body/p[1]/text().20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformBookConfigToDB / transformBookConfigFromDB rsvpPosition', () => {
|
||||
const baseConfig: BookConfig = {
|
||||
bookHash: 'hash1',
|
||||
updatedAt: 1700000000000,
|
||||
};
|
||||
|
||||
it('serializes rsvpPosition to JSON string in DB record', () => {
|
||||
const config: BookConfig = {
|
||||
...baseConfig,
|
||||
rsvpPosition: { cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' },
|
||||
};
|
||||
const db = transformBookConfigToDB(config, 'user1');
|
||||
expect(db.rsvp_position).toBe(
|
||||
JSON.stringify({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits rsvp_position when rsvpPosition is undefined', () => {
|
||||
const db = transformBookConfigToDB(baseConfig, 'user1');
|
||||
expect(db.rsvp_position).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deserializes rsvp_position from DB record', () => {
|
||||
const dbConfig: DBBookConfig = {
|
||||
user_id: 'user1',
|
||||
book_hash: 'hash1',
|
||||
rsvp_position: JSON.stringify({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' }),
|
||||
updated_at: '2023-11-14T22:13:20.000Z',
|
||||
};
|
||||
const config = transformBookConfigFromDB(dbConfig);
|
||||
expect(config.rsvpPosition).toEqual({ cfi: 'epubcfi(/6/4!/4/2/1:0)', wordText: 'hello' });
|
||||
});
|
||||
|
||||
it('leaves rsvpPosition undefined when rsvp_position is absent from DB', () => {
|
||||
const dbConfig: DBBookConfig = {
|
||||
user_id: 'user1',
|
||||
book_hash: 'hash1',
|
||||
updated_at: '2023-11-14T22:13:20.000Z',
|
||||
};
|
||||
const config = transformBookConfigFromDB(dbConfig);
|
||||
expect(config.rsvpPosition).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips rsvpPosition through DB transform', () => {
|
||||
const config: BookConfig = {
|
||||
...baseConfig,
|
||||
rsvpPosition: { cfi: 'epubcfi(/6/8!/4/2/3:5)', wordText: 'world' },
|
||||
};
|
||||
const db = transformBookConfigToDB(config, 'user1');
|
||||
// Simulate what DB returns (updated_at as ISO string)
|
||||
const dbRecord: DBBookConfig = { ...db, updated_at: new Date(config.updatedAt).toISOString() };
|
||||
const restored = transformBookConfigFromDB(dbRecord);
|
||||
expect(restored.rsvpPosition).toEqual(config.rsvpPosition);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,8 +63,8 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'bookitem-main relative flex aspect-[28/41] justify-center rounded',
|
||||
coverFit === 'crop' && 'overflow-hidden shadow-md',
|
||||
'bookitem-main relative flex aspect-[28/41] justify-center overflow-hidden rounded',
|
||||
coverFit === 'crop' && 'shadow-md',
|
||||
mode === 'grid' && 'items-end',
|
||||
mode === 'list' && 'min-w-20 items-center',
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,16 @@ import * as React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import {
|
||||
Virtuoso,
|
||||
VirtuosoGrid,
|
||||
type Components,
|
||||
type GridComponents,
|
||||
type GridListProps,
|
||||
type ListProps,
|
||||
} from 'react-virtuoso';
|
||||
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
|
||||
import {
|
||||
LibraryCoverFitType,
|
||||
@@ -46,6 +56,7 @@ interface BookshelfProps {
|
||||
isSelectMode: boolean;
|
||||
isSelectAll: boolean;
|
||||
isSelectNone: boolean;
|
||||
onScrollerRef: (el: HTMLDivElement | null) => void;
|
||||
handleImportBooks: () => void;
|
||||
handleBookDownload: (
|
||||
book: Book,
|
||||
@@ -60,11 +71,67 @@ interface BookshelfProps {
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to the custom Virtuoso `List` components so they can render
|
||||
* grid styles that depend on runtime settings without being re-created on
|
||||
* every Bookshelf render (which would break Virtuoso's component identity).
|
||||
*/
|
||||
type BookshelfListContext = {
|
||||
autoColumns: boolean;
|
||||
fixedColumns: number;
|
||||
};
|
||||
|
||||
const BOOKSHELF_GRID_CLASSES =
|
||||
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2 ' +
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12';
|
||||
|
||||
const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col';
|
||||
|
||||
const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
GridListProps & { context?: BookshelfListContext }
|
||||
>(({ children, className, style, context, 'data-testid': testId }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid={testId}
|
||||
className={clsx(BOOKSHELF_GRID_CLASSES, className)}
|
||||
style={{
|
||||
...style,
|
||||
gridTemplateColumns:
|
||||
context && !context.autoColumns
|
||||
? `repeat(${context.fixedColumns}, minmax(0, 1fr))`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
BookshelfGridList.displayName = 'BookshelfGridList';
|
||||
|
||||
const BookshelfLinearList: Components['List'] = React.forwardRef<HTMLDivElement, ListProps>(
|
||||
({ children, style, 'data-testid': testId }, ref) => (
|
||||
<div ref={ref} data-testid={testId} className={BOOKSHELF_LIST_CLASSES} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
BookshelfLinearList.displayName = 'BookshelfLinearList';
|
||||
|
||||
const GRID_VIRTUOSO_COMPONENTS: GridComponents<BookshelfListContext> = {
|
||||
List: BookshelfGridList,
|
||||
Footer: () => <div style={{ height: 34 }} />,
|
||||
};
|
||||
const LIST_VIRTUOSO_COMPONENTS: Components = {
|
||||
List: BookshelfLinearList,
|
||||
Footer: () => <div style={{ height: 34 }} />,
|
||||
};
|
||||
|
||||
const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
libraryBooks,
|
||||
isSelectMode,
|
||||
isSelectAll,
|
||||
isSelectNone,
|
||||
onScrollerRef,
|
||||
handleImportBooks,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
@@ -395,62 +462,65 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedBooks = getSelectedBooks();
|
||||
// OverlayScrollbars + Virtuoso integration: Virtuoso manages its own
|
||||
// scroller; OverlayScrollbars wraps it for overlay scrollbar rendering.
|
||||
const osRootRef = useRef<HTMLDivElement>(null);
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
options: { scrollbars: { autoHide: 'scroll' } },
|
||||
events: {
|
||||
initialized(instance) {
|
||||
const { viewport } = instance.elements();
|
||||
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
|
||||
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='bookshelf'>
|
||||
<div
|
||||
ref={autofocusRef}
|
||||
tabIndex={-1}
|
||||
className={clsx(
|
||||
'bookshelf-items transform-wrapper focus:outline-none',
|
||||
viewMode === 'grid' && 'grid flex-1 grid-cols-3 gap-x-4 px-4 sm:gap-x-0 sm:px-2',
|
||||
viewMode === 'grid' && 'sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
|
||||
viewMode === 'list' && 'flex flex-col',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
viewMode === 'grid' && !settings.libraryAutoColumns
|
||||
? `repeat(${settings.libraryColumns}, minmax(0, 1fr))`
|
||||
: undefined,
|
||||
}}
|
||||
role='main'
|
||||
aria-label={_('Bookshelf')}
|
||||
>
|
||||
{sortedBookshelfItems.map((item) => (
|
||||
<BookshelfItem
|
||||
key={`library-item-${'hash' in item ? item.hash : item.id}`}
|
||||
item={item}
|
||||
mode={viewMode as LibraryViewModeType}
|
||||
coverFit={coverFit as LibraryCoverFitType}
|
||||
isSelectMode={isSelectMode}
|
||||
itemSelected={
|
||||
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id)
|
||||
}
|
||||
setLoading={setLoading}
|
||||
toggleSelection={toggleSelection}
|
||||
handleGroupBooks={groupSelectedBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{viewMode === 'grid' && currentBookshelfItems.length > 0 && (
|
||||
useEffect(() => {
|
||||
const root = osRootRef.current;
|
||||
if (scroller && root) {
|
||||
initialize({ target: root, elements: { viewport: scroller } });
|
||||
}
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
// Expose the Virtuoso scroller to the parent for pull-to-refresh & scroll save.
|
||||
const handleScrollerRef = useCallback(
|
||||
(el: HTMLElement | Window | null) => {
|
||||
const div = el instanceof HTMLElement ? el : null;
|
||||
setScroller(div);
|
||||
onScrollerRef(div as HTMLDivElement | null);
|
||||
},
|
||||
[onScrollerRef],
|
||||
);
|
||||
|
||||
const selectedBooks = getSelectedBooks();
|
||||
const isGridMode = viewMode === 'grid';
|
||||
const hasItems = sortedBookshelfItems.length > 0;
|
||||
// In grid mode the Import-Books "+" tile is rendered as an extra grid cell
|
||||
// after all books. We represent it to Virtuoso as an extra index past the
|
||||
// last book; list mode doesn't have an import tile.
|
||||
const gridTotalCount = hasItems ? sortedBookshelfItems.length + 1 : 0;
|
||||
|
||||
const listContext = useMemo<BookshelfListContext>(
|
||||
() => ({
|
||||
autoColumns: settings.libraryAutoColumns,
|
||||
fixedColumns: settings.libraryColumns,
|
||||
}),
|
||||
[settings.libraryAutoColumns, settings.libraryColumns],
|
||||
);
|
||||
|
||||
const renderBookshelfItem = useCallback(
|
||||
(index: number) => {
|
||||
if (isGridMode && index === sortedBookshelfItems.length) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('bookshelf-import-item mx-0 my-2 sm:mx-4 sm:my-4')}
|
||||
style={
|
||||
coverFit === 'fit' && viewMode === 'grid'
|
||||
? {
|
||||
display: 'flex',
|
||||
paddingBottom: `${iconSize15 + 24}px`,
|
||||
}
|
||||
coverFit === 'fit'
|
||||
? { display: 'flex', paddingBottom: `${iconSize15 + 24}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
@@ -468,6 +538,98 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const item = sortedBookshelfItems[index];
|
||||
if (!item) return null;
|
||||
const itemSelected =
|
||||
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id);
|
||||
return (
|
||||
<BookshelfItem
|
||||
item={item}
|
||||
mode={viewMode as LibraryViewModeType}
|
||||
coverFit={coverFit as LibraryCoverFitType}
|
||||
isSelectMode={isSelectMode}
|
||||
itemSelected={itemSelected}
|
||||
setLoading={setLoading}
|
||||
toggleSelection={toggleSelection}
|
||||
handleGroupBooks={groupSelectedBooks}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
handleUpdateReadingStatus={handleUpdateReadingStatus}
|
||||
transferProgress={
|
||||
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
sortedBookshelfItems,
|
||||
selectedBooks,
|
||||
isGridMode,
|
||||
viewMode,
|
||||
coverFit,
|
||||
isSelectMode,
|
||||
booksTransferProgress,
|
||||
iconSize15,
|
||||
handleImportBooks,
|
||||
toggleSelection,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handleLibraryNavigation,
|
||||
handleUpdateReadingStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: number) => {
|
||||
if (isGridMode && index === sortedBookshelfItems.length) {
|
||||
return 'library-import-tile';
|
||||
}
|
||||
const item = sortedBookshelfItems[index];
|
||||
if (!item) return `library-item-${index}`;
|
||||
return `library-item-${'hash' in item ? item.hash : item.id}`;
|
||||
},
|
||||
[sortedBookshelfItems, isGridMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={autofocusRef}
|
||||
tabIndex={-1}
|
||||
role='main'
|
||||
aria-label={_('Bookshelf')}
|
||||
className='bookshelf min-h-0 flex-grow focus:outline-none'
|
||||
>
|
||||
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
|
||||
{hasItems && isGridMode && (
|
||||
<VirtuosoGrid<unknown, BookshelfListContext>
|
||||
overscan={200}
|
||||
totalCount={gridTotalCount}
|
||||
components={GRID_VIRTUOSO_COMPONENTS}
|
||||
context={listContext}
|
||||
computeItemKey={computeItemKey}
|
||||
itemContent={renderBookshelfItem}
|
||||
scrollerRef={handleScrollerRef}
|
||||
/>
|
||||
)}
|
||||
{hasItems && !isGridMode && (
|
||||
<Virtuoso
|
||||
overscan={200}
|
||||
totalCount={sortedBookshelfItems.length}
|
||||
components={LIST_VIRTUOSO_COMPONENTS}
|
||||
computeItemKey={computeItemKey}
|
||||
itemContent={renderBookshelfItem}
|
||||
scrollerRef={handleScrollerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading && (
|
||||
|
||||
@@ -388,12 +388,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
<div
|
||||
className={clsx(
|
||||
'visible-focus-inset-2 group',
|
||||
mode === 'grid' &&
|
||||
'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-2 sm:px-4 sm:py-4',
|
||||
'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-2 sm:rounded-md sm:px-4 sm:py-4',
|
||||
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
|
||||
appService?.isMobileApp && 'no-context-menu',
|
||||
pressing && mode === 'grid' ? 'not-eink:scale-95' : 'scale-100',
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useDemoBooks = () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const demoBooks = libraries[userLang] || (libraries.en as DemoBooks);
|
||||
const books = await Promise.all(
|
||||
demoBooks.library.map((url) => appService.importBook(url, [], false, true)),
|
||||
demoBooks.library.map((url) => appService.importBook(url, [], { saveBook: false })),
|
||||
);
|
||||
setBooks(books.filter((book) => book !== null) as Book[]);
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,11 +5,10 @@ import * as React from 'react';
|
||||
import { MdChevronRight } from 'react-icons/md';
|
||||
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
|
||||
import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService, DeleteAction } from '@/types/system';
|
||||
import { buildBookLookupIndex } from '@/services/bookService';
|
||||
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
|
||||
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
|
||||
import { getImportErrorMessage } from '@/services/errors';
|
||||
@@ -40,6 +39,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useTransferStore } from '@/store/transferStore';
|
||||
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
|
||||
import { lockScreenOrientation, selectDirectory } from '@/utils/bridge';
|
||||
import { requestStoragePermission } from '@/utils/permission';
|
||||
@@ -98,7 +98,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setLibrary,
|
||||
getGroupId,
|
||||
getGroupName,
|
||||
refreshGroups,
|
||||
checkOpenWithBooks,
|
||||
checkLastOpenBooks,
|
||||
setCheckOpenWithBooks,
|
||||
@@ -134,57 +133,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const iconSize = useResponsiveSize(18);
|
||||
const viewSettings = settings.globalViewSettings;
|
||||
const demoBooks = useDemoBooks();
|
||||
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleScrollerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
scrollRef.current = el;
|
||||
}, []);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`;
|
||||
|
||||
const saveScrollPosition = (group: string) => {
|
||||
const viewport = osRef.current?.osInstance()?.elements().viewport;
|
||||
if (viewport) {
|
||||
const scrollTop = viewport.scrollTop;
|
||||
sessionStorage.setItem(getScrollKey(group), scrollTop.toString());
|
||||
if (scrollRef.current) {
|
||||
sessionStorage.setItem(getScrollKey(group), scrollRef.current.scrollTop.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const restoreScrollPosition = useCallback((group: string) => {
|
||||
const savedPosition = sessionStorage.getItem(getScrollKey(group));
|
||||
if (savedPosition) {
|
||||
const scrollTop = parseInt(savedPosition, 10);
|
||||
const viewport = osRef.current?.osInstance()?.elements().viewport;
|
||||
if (viewport) {
|
||||
viewport.scrollTop = scrollTop;
|
||||
}
|
||||
if (savedPosition && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = parseInt(savedPosition, 10);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Unified navigation function that handles scroll position and direction
|
||||
const handleLibraryNavigation = useCallback(
|
||||
(targetGroup: string) => {
|
||||
const currentGroup = searchParams?.get('group') || '';
|
||||
|
||||
// Save current scroll position BEFORE navigation
|
||||
saveScrollPosition(currentGroup);
|
||||
|
||||
// Detect and set navigation direction
|
||||
const direction = currentGroup && !targetGroup ? 'back' : 'forward';
|
||||
document.documentElement.setAttribute('data-nav-direction', direction);
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (targetGroup) {
|
||||
params.set('group', targetGroup);
|
||||
} else {
|
||||
params.delete('group');
|
||||
}
|
||||
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[searchParams, router],
|
||||
);
|
||||
|
||||
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
|
||||
useUICSS();
|
||||
|
||||
@@ -195,7 +165,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const { isDragging } = useDragDropImport();
|
||||
|
||||
usePullToRefresh(
|
||||
containerRef,
|
||||
scrollRef,
|
||||
pullLibrary.bind(null, false, true),
|
||||
pullLibrary.bind(null, true, true),
|
||||
);
|
||||
@@ -229,6 +199,74 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
sessionStorage.setItem('lastLibraryParams', searchParams?.toString() || '');
|
||||
}, [searchParams]);
|
||||
|
||||
// Strip the empty `group=` param that `handleLibraryNavigation` sets as a
|
||||
// workaround for a Next.js 16.2 static-export regression (see the NOTE
|
||||
// above `handleLibraryNavigation` for full context). This effect runs
|
||||
// after the router.replace() has committed, so React has already
|
||||
// re-rendered with the new (empty) group state; we're only rewriting the
|
||||
// URL cosmetically via window.history.replaceState — Next.js' patched
|
||||
// replaceState will pick up the new canonical URL without triggering
|
||||
// another navigation.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (searchParams?.get('group') !== '') return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('group');
|
||||
const cleanHref = `${url.pathname}${url.search}${url.hash}`;
|
||||
window.history.replaceState(null, '', cleanHref);
|
||||
}, [searchParams]);
|
||||
|
||||
// Unified navigation function that handles scroll position and direction.
|
||||
// Workaround for a Next.js 16.2 static-export regression: navigating to a
|
||||
// same-pathname URL with an empty search string causes `router.replace()`
|
||||
// to silently no-op (e.g. `/library?group=foo` -> `/library`), which broke
|
||||
// the breadcrumb "All" button. By always calling `params.set('group',
|
||||
// targetGroup)` — including when `targetGroup` is an empty string — the
|
||||
// resulting URL becomes `/library?group=` instead of `/library`, which
|
||||
// Next.js does commit. The trailing empty `group=` is stripped via a
|
||||
// cleanup effect below (purely cosmetic URL rewrite). See
|
||||
// https://github.com/readest/readest/issues/3782.
|
||||
const handleLibraryNavigation = useCallback(
|
||||
(targetGroup: string) => {
|
||||
const currentGroup = searchParams?.get('group') || '';
|
||||
|
||||
// Save current scroll position BEFORE navigation
|
||||
saveScrollPosition(currentGroup);
|
||||
|
||||
// Detect and set navigation direction
|
||||
const direction = currentGroup && !targetGroup ? 'back' : 'forward';
|
||||
document.documentElement.setAttribute('data-nav-direction', direction);
|
||||
|
||||
// Build query params — always `set` so the search string is non-empty
|
||||
// even when targetGroup is '' (the Next.js 16.2 workaround).
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set('group', targetGroup);
|
||||
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[searchParams, router],
|
||||
);
|
||||
|
||||
const handleBackUpOneGroupLevel = () => {
|
||||
if (!currentGroupPath) return;
|
||||
const segments = currentGroupPath.split('/');
|
||||
const parentPath = segments.length > 1 ? segments.slice(0, -1).join('/') : undefined;
|
||||
const parentGroupId = parentPath ? getGroupId(parentPath) || '' : '';
|
||||
setIsSelectAll(false);
|
||||
setIsSelectNone(false);
|
||||
handleLibraryNavigation(parentGroupId);
|
||||
};
|
||||
|
||||
const handleBackUpOneGroupLevelRef = useRef(handleBackUpOneGroupLevel);
|
||||
handleBackUpOneGroupLevelRef.current = handleBackUpOneGroupLevel;
|
||||
const triggerBackUpOneGroupLevel = useCallback(() => handleBackUpOneGroupLevelRef.current(), []);
|
||||
|
||||
useKeyDownActions({
|
||||
onCancel: triggerBackUpOneGroupLevel,
|
||||
enabled: !!appService?.isAndroidApp && !!currentGroupPath,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
if (appService?.hasUpdater && settings.autoCheckUpdates) {
|
||||
@@ -250,27 +288,28 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
}, [appService]);
|
||||
|
||||
const handleRefreshLibrary = useCallback(async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
const library = await appService.loadLibraryBooks();
|
||||
setSettings(settings);
|
||||
setLibrary(library);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envConfig, appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appService?.hasWindow) {
|
||||
const currentWebview = getCurrentWebview();
|
||||
const unlisten = currentWebview.listen('close-reader-window', async () => {
|
||||
handleRefreshLibrary();
|
||||
// Reader windows are independent Tauri webviews with their own
|
||||
// libraryStore instance — progress / readingStatus / move-to-front
|
||||
// updates from the reader window do NOT propagate to this main
|
||||
// window's store. Reload from disk so the library reflects the
|
||||
// changes the reader just persisted.
|
||||
const appService = await envConfig.getAppService();
|
||||
const settings = await appService.loadSettings();
|
||||
const library = await appService.loadLibraryBooks();
|
||||
setSettings(settings);
|
||||
setLibrary(library);
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [appService, handleRefreshLibrary]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService, envConfig]);
|
||||
|
||||
const handleImportBookFiles = useCallback(async (event: CustomEvent) => {
|
||||
const selectedFiles: SelectedFile[] = event.detail.files;
|
||||
@@ -288,7 +327,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}, [handleImportBookFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshGroups();
|
||||
if (!libraryBooks.some((book) => !book.deletedAt)) {
|
||||
handleSetSelectMode(false);
|
||||
}
|
||||
@@ -303,7 +341,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
console.log('Open with book:', file);
|
||||
try {
|
||||
const temp = appService.isMobile ? false : !settings.autoImportBooksOnOpen;
|
||||
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
|
||||
const book = await appService.importBook(file, libraryBooks, { transient: temp });
|
||||
if (book) {
|
||||
bookIds.push(book.hash);
|
||||
}
|
||||
@@ -433,7 +471,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
isInitiating.current = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
@@ -496,6 +534,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const importBooks = async (files: SelectedFile[], groupId?: string) => {
|
||||
setLoading(true);
|
||||
const { library } = useLibraryStore.getState();
|
||||
// Build the lookup index ONCE per import batch so each book lookup is
|
||||
// O(1) instead of O(n) over the existing library. importBook also keeps
|
||||
// the index updated as new books are appended, so subsequent files in
|
||||
// the same batch see the additions.
|
||||
const lookupIndex = buildBookLookupIndex(library);
|
||||
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
|
||||
const successfulImports: string[] = [];
|
||||
|
||||
@@ -503,7 +546,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const file = selectedFile.file || selectedFile.path;
|
||||
if (!file) return null;
|
||||
try {
|
||||
const book = await appService?.importBook(file, library);
|
||||
const book = await appService?.importBook(file, library, { lookupIndex });
|
||||
if (!book) return null;
|
||||
const { path, basePath } = selectedFile;
|
||||
if (groupId) {
|
||||
@@ -536,7 +579,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
for (let i = 0; i < files.length; i += concurrency) {
|
||||
const batch = files.slice(i, i + concurrency);
|
||||
const importedBooks = (await Promise.all(batch.map(processFile))).filter((book) => !!book);
|
||||
await updateBooks(envConfig, importedBooks);
|
||||
// Update store state per batch (so the UI can render imported books
|
||||
// incrementally) but defer disk persistence until the entire batch is
|
||||
// done — saving library.json once per batch of 4 books was the dominant
|
||||
// cost for large imports.
|
||||
await updateBooks(envConfig, importedBooks, { skipSave: true });
|
||||
}
|
||||
|
||||
// Persist the full library once after every file in the batch is done.
|
||||
if (successfulImports.length > 0) {
|
||||
const finalLibrary = useLibraryStore.getState().library;
|
||||
const finalAppService = await envConfig.getAppService();
|
||||
await finalAppService.saveLibraryBooks(finalLibrary);
|
||||
}
|
||||
|
||||
pushLibrary();
|
||||
@@ -890,27 +944,15 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
aria-label={_('Your Bookshelf')}
|
||||
ref={osRef}
|
||||
className='flex-grow'
|
||||
options={{ scrollbars: { autoHide: 'scroll' } }}
|
||||
events={{
|
||||
initialized: (instance) => {
|
||||
const { content } = instance.elements();
|
||||
if (content) {
|
||||
containerRef.current = content as HTMLDivElement;
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
|
||||
<div
|
||||
className={clsx('scroll-container drop-zone flex-grow', isDragging && 'drag-over')}
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
paddingTop: '0px',
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingBottom: `${insets.bottom}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
}}
|
||||
>
|
||||
@@ -920,6 +962,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
onScrollerRef={handleScrollerRef}
|
||||
handleImportBooks={handleImportBooksFromFiles}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
@@ -931,7 +974,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoTrash, IoOpenOutline, IoBook, IoEyeOff, IoEye } from 'react-icons/io5';
|
||||
import { IoAdd, IoTrash, IoOpenOutline, IoBook, IoEyeOff, IoEye, IoPencil } from 'react-icons/io5';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -77,6 +77,7 @@ export function CatalogManager() {
|
||||
const { settings } = useSettingsStore();
|
||||
const [catalogs, setCatalogs] = useState<OPDSCatalog[]>(() => settings.opdsCatalogs || []);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingCatalogId, setEditingCatalogId] = useState<string | null>(null);
|
||||
const [newCatalog, setNewCatalog] = useState(EMPTY_NEW_CATALOG);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [urlError, setUrlError] = useState('');
|
||||
@@ -147,7 +148,7 @@ export function CatalogManager() {
|
||||
}
|
||||
|
||||
const catalog: OPDSCatalog = {
|
||||
id: Date.now().toString(),
|
||||
id: editingCatalogId || Date.now().toString(),
|
||||
name: newCatalog.name,
|
||||
url: newCatalog.url,
|
||||
description: newCatalog.description,
|
||||
@@ -158,15 +159,35 @@ export function CatalogManager() {
|
||||
: undefined,
|
||||
};
|
||||
|
||||
saveCatalogs([catalog, ...catalogs]);
|
||||
if (editingCatalogId) {
|
||||
saveCatalogs(catalogs.map((c) => (c.id === editingCatalogId ? catalog : c)));
|
||||
} else {
|
||||
saveCatalogs([catalog, ...catalogs]);
|
||||
}
|
||||
|
||||
setNewCatalog(EMPTY_NEW_CATALOG);
|
||||
setUrlError('');
|
||||
setHeaderError('');
|
||||
setProxyConsentError('');
|
||||
setIsValidating(false);
|
||||
setEditingCatalogId(null);
|
||||
setShowAddDialog(false);
|
||||
};
|
||||
|
||||
const handleEditCatalog = (catalog: OPDSCatalog) => {
|
||||
setNewCatalog({
|
||||
name: catalog.name,
|
||||
url: catalog.url,
|
||||
description: catalog.description || '',
|
||||
username: catalog.username || '',
|
||||
password: catalog.password || '',
|
||||
customHeadersInput: formatOPDSCustomHeadersInput(catalog.customHeaders),
|
||||
proxyConsent: false,
|
||||
});
|
||||
setEditingCatalogId(catalog.id);
|
||||
setShowAddDialog(true);
|
||||
};
|
||||
|
||||
const handleAddPopularCatalog = (popularCatalog: OPDSCatalog) => {
|
||||
if (catalogs.some((c) => c.url === popularCatalog.url)) {
|
||||
return;
|
||||
@@ -192,6 +213,7 @@ export function CatalogManager() {
|
||||
setHeaderError('');
|
||||
setProxyConsentError('');
|
||||
setShowPassword(false);
|
||||
setEditingCatalogId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -239,13 +261,22 @@ export function CatalogManager() {
|
||||
{catalog.icon && <span className=''>{catalog.icon}</span>}
|
||||
{catalog.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleRemoveCatalog(catalog.id)}
|
||||
className='btn btn-ghost btn-xs btn-square'
|
||||
title='Remove'
|
||||
>
|
||||
<IoTrash className='h-4 w-4' />
|
||||
</button>
|
||||
<div className='flex gap-1'>
|
||||
<button
|
||||
onClick={() => handleEditCatalog(catalog)}
|
||||
className='btn btn-ghost btn-xs btn-square'
|
||||
title={_('Edit')}
|
||||
>
|
||||
<IoPencil className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveCatalog(catalog.id)}
|
||||
className='btn btn-ghost btn-xs btn-square'
|
||||
title={_('Remove')}
|
||||
>
|
||||
<IoTrash className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{catalog.description && (
|
||||
<p className='text-base-content/70 mb-2 line-clamp-1 h-6 text-sm sm:line-clamp-2 sm:h-10'>
|
||||
@@ -329,12 +360,14 @@ export function CatalogManager() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Add Catalog Dialog */}
|
||||
{/* Add/Edit Catalog Dialog */}
|
||||
{showAddDialog && (
|
||||
<ModalPortal>
|
||||
<dialog className='modal modal-open'>
|
||||
<div className='modal-box'>
|
||||
<h3 className='mb-4 text-lg font-bold'>{_('Add OPDS Catalog')}</h3>
|
||||
<h3 className='mb-4 text-lg font-bold'>
|
||||
{editingCatalogId ? _('Edit OPDS Catalog') : _('Add OPDS Catalog')}
|
||||
</h3>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -518,6 +551,8 @@ export function CatalogManager() {
|
||||
<span className='loading loading-spinner loading-sm'></span>
|
||||
{_('Validating...')}
|
||||
</>
|
||||
) : editingCatalogId ? (
|
||||
_('Save Changes')
|
||||
) : (
|
||||
_('Add Catalog')
|
||||
)}
|
||||
|
||||
@@ -269,16 +269,41 @@ export const probeAuth = async (
|
||||
export const probeFilename = async (headers: Record<string, string>) => {
|
||||
const contentDisposition = headers['content-disposition'];
|
||||
if (contentDisposition) {
|
||||
// 1. Try RFC 5987 format (filename*=utf-8''encoded_name)
|
||||
const extendedMatch = contentDisposition.match(
|
||||
/filename\*\s*=\s*(?:utf-8|UTF-8)'[^']*'([^;\s]+)/i,
|
||||
);
|
||||
if (extendedMatch?.[1]) {
|
||||
return decodeURIComponent(extendedMatch[1]);
|
||||
try {
|
||||
return decodeURIComponent(extendedMatch[1]);
|
||||
} catch (e) {
|
||||
// If decoding fails, ignore and proceed to the next format
|
||||
console.warn('Failed to decode filename*', e);
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = contentDisposition.match(/filename\s*=\s*["']?([^"';\s]+)["']?/i);
|
||||
// 2. Try standard quoted format (supports spaces, apostrophes, and escaped quotes)
|
||||
const quotedMatch = contentDisposition.match(/filename\s*=\s*(["'])((?:(?!\1)[^\\]|\\.)*)\1/i);
|
||||
if (quotedMatch?.[2]) {
|
||||
// Unescape characters (e.g., \" becomes ")
|
||||
const unescaped = quotedMatch[2].replace(/\\(.)/g, '$1');
|
||||
try {
|
||||
// Attempt to decode in case the server incorrectly applied URL encoding
|
||||
return decodeURIComponent(unescaped);
|
||||
} catch {
|
||||
// If decoding fails (e.g., literal '%' symbols), return the unescaped string as-is
|
||||
return unescaped;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: standard format without quotes
|
||||
const plainMatch = contentDisposition.match(/filename\s*=\s*([^;\s]+)/i);
|
||||
if (plainMatch?.[1]) {
|
||||
return decodeURIComponent(plainMatch[1]);
|
||||
try {
|
||||
return decodeURIComponent(plainMatch[1]);
|
||||
} catch {
|
||||
return plainMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,11 +233,13 @@ const FoliateViewer: React.FC<{
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
||||
applyFixedlayoutStyles(detail.doc, viewSettings);
|
||||
const themeCode = getThemeCode();
|
||||
if (themeCode && renderer) {
|
||||
renderer.pageColors = {
|
||||
background: themeCode.bg,
|
||||
foreground: themeCode.fg,
|
||||
};
|
||||
if (bookData.book?.format === 'PDF' && themeCode && renderer) {
|
||||
renderer.pageColors = viewSettings.applyThemeToPDF
|
||||
? {
|
||||
background: themeCode.bg,
|
||||
foreground: themeCode.fg,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +342,7 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
const { handlePageFlip } = usePagination(bookKey, viewRef, containerRef);
|
||||
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip);
|
||||
const touchHandlers = useTouchEvent(bookKey, handlePageFlip);
|
||||
const touchHandlers = useTouchEvent(bookKey);
|
||||
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
|
||||
@@ -619,6 +621,7 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
const renderer = viewRef.current.renderer;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
|
||||
const docs = viewRef.current.renderer.getContents();
|
||||
@@ -631,13 +634,13 @@ const FoliateViewer: React.FC<{
|
||||
applyScrollbarStyle(document, viewSettings.hideScrollbar || false);
|
||||
});
|
||||
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
||||
if (themeCode && viewRef.current?.renderer) {
|
||||
viewRef.current.renderer.pageColors = {
|
||||
background: themeCode.bg,
|
||||
foreground: themeCode.fg,
|
||||
};
|
||||
}
|
||||
if (bookData?.book?.format === 'PDF' && themeCode && renderer) {
|
||||
renderer.pageColors = viewSettings.applyThemeToPDF
|
||||
? {
|
||||
background: themeCode.bg,
|
||||
foreground: themeCode.fg,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -647,6 +650,7 @@ const FoliateViewer: React.FC<{
|
||||
viewSettings?.scrolled,
|
||||
viewSettings?.overrideColor,
|
||||
viewSettings?.invertImgColorInDark,
|
||||
viewSettings?.applyThemeToPDF,
|
||||
viewSettings?.hideScrollbar,
|
||||
]);
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const iconSize16 = useResponsiveSize(16);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
|
||||
const docs = view?.renderer.getContents() ?? [];
|
||||
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
|
||||
@@ -138,6 +137,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
useSpatialNavigation(headerRef, isHeaderVisible);
|
||||
const trafficLightInHeader =
|
||||
appService?.hasTrafficLight && !trafficLightInFullscreen && !isSideBarVisible && isTopLeft;
|
||||
const windowButtonVisible =
|
||||
appService?.hasWindowBar && !isTrafficLightVisible && !trafficLightInHeader;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@ import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { Insets } from '@/types/misc';
|
||||
import ZoomControls from './ZoomControls';
|
||||
|
||||
@@ -39,6 +40,9 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Escape (desktop) and Android Back key → close the viewer.
|
||||
useKeyDownActions({ onCancel: onClose });
|
||||
|
||||
const hideZoomLabelAfterDelay = () => {
|
||||
if (zoomLabelTimeoutRef.current) {
|
||||
clearTimeout(zoomLabelTimeoutRef.current);
|
||||
@@ -79,10 +83,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// Escape is handled by useKeyDownActions (also covers Android Back key).
|
||||
|
||||
// Arrow key navigation
|
||||
if (e.key === 'ArrowLeft' && onPrevious) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { Fragment, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { md5 } from 'js-md5';
|
||||
import { type as osType } from '@tauri-apps/plugin-os';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -148,6 +148,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
serverUrl: url,
|
||||
username,
|
||||
userkey: md5(password),
|
||||
password,
|
||||
deviceName,
|
||||
enabled: true,
|
||||
};
|
||||
@@ -218,7 +219,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
{isOpen && (
|
||||
<div className='mb-4 mt-0 flex flex-col gap-4 p-2 sm:p-4'>
|
||||
{isConfigured ? (
|
||||
<>
|
||||
<Fragment key='configured'>
|
||||
<div className='text-center'>
|
||||
<p className='text-base-content/80 text-sm'>
|
||||
{_('Sync as {{userDisplayName}}', {
|
||||
@@ -275,9 +276,9 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
onChange={handleDeviceNameChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Fragment>
|
||||
) : (
|
||||
<>
|
||||
<Fragment key='login'>
|
||||
<p className='text-base-content/70 text-center text-sm'>
|
||||
{_('Connect to your KOReader Sync server.')}
|
||||
</p>
|
||||
@@ -333,7 +334,7 @@ export const KOSyncSettingsWindow: React.FC = () => {
|
||||
{connectionStatus && (
|
||||
<div className='text-error h-4 text-center text-sm'>{connectionStatus}</div>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { saveViewSettings } from '@/helpers/settings';
|
||||
import { READING_RULER_COLORS } from '@/services/constants';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useTouchInterceptor } from '../hooks/useTouchInterceptor';
|
||||
import {
|
||||
calculateReadingRulerSize,
|
||||
clampReadingRulerPosition,
|
||||
@@ -305,6 +306,82 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
}
|
||||
}, [containerSize.width, containerSize.height, isVertical, clampPosition, setRulerPosition]);
|
||||
|
||||
// Touch interceptor: allows dragging the ruler from anywhere on its body.
|
||||
// The ruler body stays pointer-events-none so text selection works through it.
|
||||
// Returning true consumes the gesture, preventing swipe/page-flip.
|
||||
const isTouchDraggingRef = useRef(false);
|
||||
const touchInRulerRef = useRef(false);
|
||||
|
||||
useTouchInterceptor(
|
||||
`ruler-drag-${bookKey}`,
|
||||
(bk, detail) => {
|
||||
if (bk !== bookKey) return false;
|
||||
|
||||
if (detail.phase === 'start') {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return false;
|
||||
const vx = detail.touch.screenX - (window.screenX || 0);
|
||||
const vy = detail.touch.screenY - (window.screenY || 0);
|
||||
const dim = isVertical ? rect.width : rect.height;
|
||||
const center = (currentPositionRef.current / 100) * dim;
|
||||
const half = rulerSize / 2;
|
||||
const rel = isVertical ? vx - rect.left : vy - rect.top;
|
||||
touchInRulerRef.current = rel >= center - half && rel <= center + half;
|
||||
isTouchDraggingRef.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (detail.phase === 'move') {
|
||||
if (!touchInRulerRef.current) return false;
|
||||
|
||||
if (!isTouchDraggingRef.current) {
|
||||
const dx = Math.abs(detail.deltaX);
|
||||
const dy = Math.abs(detail.deltaY);
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance >= 10) {
|
||||
const isDragGesture = isVertical ? dx >= 3 * dy : dy >= 3 * dx;
|
||||
if (isDragGesture) {
|
||||
isTouchDraggingRef.current = true;
|
||||
isDragging.current = true;
|
||||
dragPointerOffsetRef.current = 0;
|
||||
setShouldAnimate(false);
|
||||
} else {
|
||||
touchInRulerRef.current = false;
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return true;
|
||||
const vx = detail.touch.screenX - (window.screenX || 0);
|
||||
const vy = detail.touch.screenY - (window.screenY || 0);
|
||||
const dim = isVertical ? rect.width : rect.height;
|
||||
const rel = isVertical ? vx - rect.left : vy - rect.top;
|
||||
const newPos = clampPosition((rel / dim) * 100, dim);
|
||||
setCurrentPosition(newPos);
|
||||
currentPositionRef.current = newPos;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (detail.phase === 'end') {
|
||||
const wasConsumed = isTouchDraggingRef.current;
|
||||
if (wasConsumed) {
|
||||
isDragging.current = false;
|
||||
throttledSave(currentPositionRef.current);
|
||||
}
|
||||
touchInRulerRef.current = false;
|
||||
isTouchDraggingRef.current = false;
|
||||
return wasConsumed;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
10, // higher priority than swipe-to-flip (0)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (event: CustomEvent) => {
|
||||
const detail = (event.detail ?? {}) as {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { Insets } from '@/types/misc';
|
||||
import ZoomControls from './ZoomControls';
|
||||
|
||||
@@ -27,6 +28,9 @@ const TableViewer: React.FC<TableViewerProps> = ({ gridInsets, html, isDarkMode,
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Escape (desktop) and Android Back key → close the viewer.
|
||||
useKeyDownActions({ onCancel: onClose });
|
||||
|
||||
const hideZoomLabelAfterDelay = () => {
|
||||
if (zoomLabelTimeoutRef.current) {
|
||||
clearTimeout(zoomLabelTimeoutRef.current);
|
||||
@@ -63,10 +67,7 @@ const TableViewer: React.FC<TableViewerProps> = ({ gridInsets, html, isDarkMode,
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// Escape is handled by useKeyDownActions (also covers Android Back key).
|
||||
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
|
||||
const [invertImgColorInDark, setInvertImgColorInDark] = useState(
|
||||
viewSettings!.invertImgColorInDark,
|
||||
);
|
||||
const [applyThemeToPDF, setApplyThemeToPDF] = useState(viewSettings!.applyThemeToPDF!);
|
||||
|
||||
const zoomIn = () => setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM_LEVEL));
|
||||
const zoomOut = () => setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM_LEVEL));
|
||||
@@ -127,6 +128,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invertImgColorInDark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (applyThemeToPDF === viewSettings.applyThemeToPDF) return;
|
||||
saveViewSettings(envConfig, bookKey, 'applyThemeToPDF', applyThemeToPDF, true, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [applyThemeToPDF]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoomMode === viewSettings.zoomMode) return;
|
||||
viewSettings.zoomMode = zoomMode;
|
||||
@@ -328,6 +335,13 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
|
||||
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
|
||||
onClick={cycleThemeMode}
|
||||
/>
|
||||
{bookData.book?.format === 'PDF' && (
|
||||
<MenuItem
|
||||
label={_('Apply Theme Colors to PDF')}
|
||||
Icon={applyThemeToPDF ? MdCheck : undefined}
|
||||
onClick={() => setApplyThemeToPDF(!applyThemeToPDF)}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
label={_('Invert Image In Dark Mode')}
|
||||
disabled={!isDarkMode}
|
||||
|
||||
@@ -36,7 +36,13 @@ function createAnnotationToolButtons<T extends AnnotationToolType>(
|
||||
}
|
||||
|
||||
export const annotationToolButtons = createAnnotationToolButtons([
|
||||
{ type: 'copy', label: _('Copy'), tooltip: _('Copy text after selection'), Icon: FiCopy },
|
||||
{
|
||||
type: 'copy',
|
||||
label: _('Copy'),
|
||||
tooltip: _('Copy text after selection'),
|
||||
Icon: FiCopy,
|
||||
quickAction: true,
|
||||
},
|
||||
{
|
||||
type: 'highlight',
|
||||
label: _('Highlight'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user