Compare commits

...

58 Commits

Author SHA1 Message Date
github-actions[bot] cb1a5d9e82 chore: Release v2.1.3 2026-08-22 06:13:17 +00:00
Mohammad 7e20f7dccb feat: Increase Maximum Reader Text Size from 20 to 50 (#1998)
Increased maximum text size limit in Reader component, and settings page
2026-08-22 08:55:41 +05:30
Rajarshee Chatterjee 0fab0143b1 fix: Prevent Large EPUB Stalls And Optimize Imports
Closes #1932
Closes #2005
2026-08-22 08:41:48 +05:30
Rajarshee Chatterjee 5a0717d2c7 fix: Prevent Duplicate TTS Text From Nested Formatting (Closes #1994) 2026-08-18 11:50:06 +05:30
Patrick Loser 09652f9962 fix: Refetch Library After Category Update (#1988)
Add tests covering NovelScreenButtonGroup category assignment
flows to ensure the library is refreshed and the novel state
is updated when needed.
2026-08-18 09:28:07 +05:30
Rajarshee Chatterjee eef58f212f fix: Support Namespace-Prefixed EPUB Imports 2026-08-17 21:16:29 +05:30
Rajarshee Chatterjee 523aa90c19 fix: Include Novel Covers In Library Backups (Closes #1983) 2026-08-16 12:48:54 +05:30
Ahjie521 eb310f7821 feat: Add Empty Chapter Refresh in Reader (#1978)
* feat(reader): swipe down to re-fetch a chapter, never cache empty responses

- pull down at the top of a chapter to re-fetch it, recovering from
  transient empty responses without leaving the reader
- empty responses are evicted from the chapter cache so a refresh
  re-fetches instead of replaying the empty message
- add a refresh button to the reader's bottom bar and a 'Refresh
  chapter' link on the empty-chapter message
- register the typescript-eslint plugin and ignore assets/ in the
  eslint flat config so lint-staged can lint core.js

* feat(reader): move refresh option to appbar menu
2026-08-16 07:36:41 +05:30
Patrick Loser af2a224781 fix: Novel Screen Scroll Lag, Source Pagination Stalls, and Skeleton Overlap (#1971)
* fix

* Update pnpm-lock.yaml

* fix tests
2026-08-16 07:34:17 +05:30
Rajarshee Chatterjee 990cd4f2eb fix: Upgrade WorkManager for Repeated Library Updates (Closes #1972) 2026-08-11 15:58:51 +05:30
Rajarshee Chatterjee 3ac611f633 fix: Normalize EPUB SVG Images in Nitro Module (Closes #1622) 2026-08-11 14:51:50 +05:30
Rajarshee Chatterjee 909504a721 feat: Add Repository Enable And Disable Controls (Closes #1628) 2026-08-11 13:50:25 +05:30
Rajarshee Chatterjee 63349de1bb fix: Select All Chapters Across Lazy-Loaded Batches (Closes #1960) 2026-08-11 11:58:18 +05:30
Rajarshee Chatterjee c6679b7f41 fix: Draw Content Behind Android Navigation Bar (Closes #1260) 2026-08-11 11:53:03 +05:30
Ahjie521 15560b67b1 fix: Pass Plugin imageRequestInit Headers to Novel Detail Cover (#1977)
The novel detail header rendered its cover with a bare { uri } source,
so plugins whose cover CDN requires request headers (e.g. RanobeLib's
cover.cdnlibs.org, which 403s without a Referer) showed no cover on the
novel detail screen even though covers worked in browse/library lists.

Mirror the LibraryListView pattern: fetch getPlugin(novel.pluginId)?
.imageRequestInit and attach its headers to the cover source.
2026-08-11 08:26:12 +05:30
Alvan Noah 7883b28cd9 fix: Stop TTS During Incoming and Outgoing Phone Calls (#1976)
fix: TTS continues playing during incoming and outgoing phone calls

Implement audio focus handling for TTS playback on Android, including support for request and abandonment during playback and transient interruptions.

Closes #1975
2026-08-11 08:01:55 +05:30
Patrick Loser 64707409bb feat: Create Dedicated Custom Code Settings Page V2 (#1902)
* init

* text modification without webview rerender

* Fix SettingsReaderScreen

* remove unneeded

* solve ts issue

* add tests

* Add KeyboardProvider

* Update SimpleCodeEditor.tsx

* fix ts error

* add types package

* reworked SimpleCodeEditor

* Preserve highlightmode

* Update SimpleCodeEditor.tsx

* performance improvement

* simplified

* fix tsc

* fix alignment issues

* load current snippet into example view

* Merge branch 'master' into custom-code-clean

* fix type + lint

* Resolve can't read property id of undefined error

---------

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
2026-08-11 07:09:52 +05:30
Rajarshee Chatterjee 7f1f76408b docs: Add AGENTS.md with Agent Setup, Commands, and Conventions (#1974) 2026-08-10 09:17:43 +05:30
Rajarshee Chatterjee 51560195ba fix: Soften Skeleton Loading Colors (Closes #1964) 2026-08-10 08:25:10 +05:30
Rajarshee Chatterjee 084dcccabc fix: Match Chapter Drawer Read Color (Closes #1973)
[skip ci]
2026-08-10 08:17:22 +05:30
Rajarshee Chatterjee 3ad6e372fd fix: Correct Translation Strings Across Locales 2026-08-10 08:16:32 +05:30
Ali 179feb56ee fix: Include All Chapters Beyond the 1000-Row UI Limit (#1938)
* chapter backup and restore fix

* Update .gitignore

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>

* test: update backup mocks for getAllNovelChaptersForBackup

---------

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
2026-08-09 16:28:12 +05:30
Patrick Loser 67e01bc2df fix: Resolve Stats Screen Logic Issue (#1968)
* fix genre distribution

* add tests

* open edit dialog when creatig new taxonomy category
2026-08-08 16:28:12 +05:30
Patrick Loser 3ece098b94 perf: Optimize APK Size (#1969)
* optimize android build

* update lodash imports

* remove react-navigation/stack

* remove flash-list
2026-08-08 16:24:07 +05:30
Patrick Loser 586e085141 fix: Resolve Migration Crash (#1967)
init
2026-08-08 15:38:25 +05:30
Rajarshee Chatterjee e0c89cdd9f fix: Neutralize Skeleton Loading Colors 2026-08-05 07:07:20 +05:30
Rajarshee Chatterjee 3bf025108a fix: Restore Indonesian Translations 2026-08-05 06:05:03 +05:30
Rajarshee Chatterjee f69e5d6a7e chore: Update Translations (#1934)
* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]
2026-08-05 05:37:25 +05:30
Rajarshee Chatterjee a727c229ca fix: Optimize Novel Counter Migration 2026-08-05 05:32:58 +05:30
Rajarshee Chatterjee 57eca11a9e fix: Pass Library Status Through Novel Navigation 2026-08-04 22:06:05 +05:30
Rajarshee Chatterjee 197d8670f8 fix: Resolve EPUB Cover Document Imports (Closes #1948) 2026-08-04 20:49:38 +05:30
Rajarshee Chatterjee 675f19ef90 fix: Make Update Chapters Reactive and Remove Display Limit (Closes #1953) 2026-08-04 20:25:19 +05:30
Rajarshee Chatterjee 8a12529ba8 fix: Prevent Update Clearing From Freezing App (Closes #1955) 2026-08-04 19:52:11 +05:30
Rajarshee Chatterjee c3482a8510 fix: Resolve Nitro EPUB Jest Alias 2026-08-04 19:41:27 +05:30
Rajarshee Chatterjee e4246dee59 fix: Replace EPUB Exports Atomically With NativeFile (Closes #1954) 2026-08-04 19:32:12 +05:30
Rajarshee Chatterjee 23f9b183be fix: Refresh Updates Screen on Focus (Closes #1956) 2026-08-04 19:13:05 +05:30
Rajarshee Chatterjee 1eb8c587c7 fix: Prevent Library From Remaining in Loading State 2026-08-04 18:45:16 +05:30
Rajarshee Chatterjee c3b75ebeb2 fix: Migrate Plugin Bundles to Reliable Internal Storage
Closes #1907
2026-08-04 16:34:19 +05:30
Rajarshee Chatterjee 91358ad3dd fix: Support All EPUB Image Formats (Closes #1946) 2026-08-04 10:59:53 +05:30
Rajarshee Chatterjee 13885320ae fix: Add Novels To Library When Setting Categories (Closes #1945) 2026-08-03 22:59:14 +05:30
Rajarshee Chatterjee b9d1abcf27 fix: Refresh Library Download Counts After Deletion 2026-08-03 20:56:05 +05:30
Rajarshee Chatterjee c3260e8e01 fix: Resolve Chapter Download Queue and Removal Issues (Closes #1937) 2026-08-01 17:10:29 +05:30
Rajarshee Chatterjee c0877a9b06 feat: Add Ignorable App Update Notifications
Closes #600
Closes #1632
2026-08-01 12:06:28 +05:30
Patrick Loser 96fb52df17 feat: Enhance Stats With Genre Taxonomy and Charts (#1919)
* Add react-native-svg and refresh stats UI

Normalize status counts, add donut chart with legend, and improve bars

* Add genre stats screen and taxonomy link

* Update StatsQueries.test.ts

* change genre taxonomy style

* Use LegendList instead of ScrollView

* added more colors to chart

* Add Highlight animation

* Add ChartLegend animation

* Maintainance

* updated stats queries

* Added animations to collapsibles and optimized performance

* Extract tabs

* update time tracking tab
2026-08-01 11:31:03 +05:30
Rajarshee Chatterjee 76106317b6 fix: Restore Midnight Dusk Chip Contrast (Closes #1939) 2026-07-31 08:11:08 +05:30
K1ngfish3r e75f4fba5d feat: Add More Novel Statuses and Icons (#1931)
add more novelStatus and icons
2026-07-29 22:30:20 +05:30
Rajarshee Chatterjee 4dc933ee5a fix: Remove White Flash During Screen Transitions (Closes #1926) (#1927)
`AppTheme` never set `android:windowBackground`, so it was inherited from
`Theme.AppCompat.DayNight.NoActionBar` and resolved to a near-white colour
whenever the system was in light mode — independently of the theme picked
inside the app. Nothing between that window and the navigators painted a
background of its own: the root view, `GestureHandlerRootView` and
`SafeAreaProvider` are all transparent, and only the individual screens carry
`contentStyle`. Any frame in which no screen was drawn — mounting a nested
navigator such as `MoreStack` or `ReaderStack`, or freezing the screen being
left — therefore showed the white window through.

Paint the root view with the active theme background so the window stays
covered, and give `AppTheme` a day/night `android:windowBackground` so the
frames before React draws are themed as well. The chapter screen additionally
uses the reader background instead of the app background, so pushing it no
longer flashes the app theme before the reader appears.


Claude-Session: https://claude.ai/code/session_014uxuC7JVkShhWnN1WhkTaH

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-29 21:27:22 +05:30
Rajarshee Chatterjee bfb12b04aa feat: Add Configurable Automatic Backup Scheduling 2026-07-29 21:20:19 +05:30
Patrick Loser b7639ab55a feat: Upload JavaScript Source Maps (#1924)
* init

* fix paths

* add hermes flag
2026-07-29 20:58:00 +05:30
github-actions[bot] ecc5b84a64 chore: Release v2.1.2 2026-07-29 10:17:01 +00:00
Rajarshee Chatterjee eb12bdfd62 fix: Prevent Novel Genre Rendering Crashes 2026-07-29 15:44:44 +05:30
Rajarshee Chatterjee 9612bf6f89 chore: Update Translations (#1928)
* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]
2026-07-29 14:36:36 +05:30
Rajarshee Chatterjee 0ed7d878b4 fix: Prevent White Reader Drawer Seam 2026-07-29 14:21:59 +05:30
Rajarshee Chatterjee 5f45e4c7f2 fix: Recover Interrupted Chapter Migrations 2026-07-28 20:36:59 +05:30
Rajarshee Chatterjee ca24671a2f fix: Support Fork-Safe Preview Builds 2026-07-28 15:38:09 +05:30
Rajarshee Chatterjee ee3a4f20bc fix: Preserve Default Category After Reordering (Closes #1922) 2026-07-28 14:46:03 +05:30
Rajarshee Chatterjee 0c3542836c fix: Synchronize Library Updates And Optimize Import Refreshes 2026-07-28 14:46:03 +05:30
Patrick Loser f71c9c8594 fix: Ignore Discord Notifications Outside the Main Repository (#1923) 2026-07-28 14:30:52 +05:30
288 changed files with 21351 additions and 3782 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ body:
label: LNReader version
description: You can find your LNReader version in **More → About**.
placeholder: |
Example: "2.1.1"
Example: "2.1.3"
validations:
required: true
@@ -88,7 +88,7 @@ body:
required: true
- label: If this is an issue with a plugin, I should be opening an issue in the [plugins repository](https://github.com/lnreader/lnreader-plugins/issues/new/choose).
required: true
- label: I have updated the app to version **[2.1.1](https://github.com/lnreader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.1.3](https://github.com/lnreader/lnreader/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+1 -1
View File
@@ -34,7 +34,7 @@ body:
required: true
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/lnreader/lnreader-sources/issues/new/choose).
required: true
- label: I have updated the app to version **[2.1.1](https://github.com/lnreader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.1.3](https://github.com/lnreader/lnreader/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+21 -3
View File
@@ -100,9 +100,7 @@ jobs:
run: |
pnpm generate:env:release \
--build-type "GitHub Action" \
--node-env "production" \
--myanimelist-client-id "$MYANIMELIST_CLIENT_ID" \
--anilist-client-id "$ANILIST_CLIENT_ID"
--node-env "production"
- name: Generate Native Android Project
run: |
@@ -148,6 +146,14 @@ jobs:
app/build/outputs/apk/preRelease/app-preRelease.apk \
"app/build/outputs/apk/preRelease/$PRERELEASE_APK_NAME"
- name: Upload Preview Sourcemap
uses: actions/upload-artifact@v4
with:
name: ${{ env.PRERELEASE_ARTIFACT_NAME }}-sourcemap
path: android/app/build/generated/sourcemaps/react/preRelease/index.android.bundle.map
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Upload Preview APK
id: upload-nightly
uses: actions/upload-artifact@v4
@@ -158,7 +164,19 @@ jobs:
retention-days: 14
compression-level: 0
- name: Check Discord Notification
id: discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_NIGHTLY_WEBHOOK }}
run: |
if [ -n "$DISCORD_WEBHOOK_URL" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
fi
- name: Notify Discord
if: steps.discord.outputs.configured == 'true'
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_NIGHTLY_WEBHOOK }}
ARTIFACT_URL: ${{ steps.upload-nightly.outputs.artifact-url }}
+5
View File
@@ -183,6 +183,11 @@ jobs:
- name: Package Release APKs
run: pnpm release:package "$RELEASE_VERSION"
- name: Upload Sourcemaps
run: |
cp android/app/build/generated/sourcemaps/react/release/index.android.bundle.map \
"release-artifacts/LNReader-v${RELEASE_VERSION}-sourcemap.map"
- name: Verify APK Signatures
run: |
APKSIGNER="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner |
+3
View File
@@ -1 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm run type-check
+132
View File
@@ -0,0 +1,132 @@
# AGENTS.md
Guidance for AI coding agents working in this repository.
## Project overview
LNReader is a free, open-source light novel reader for Android, built with
React Native (Expo) and TypeScript. Content is fetched at runtime by
JavaScript "plugins" (per-source scrapers) rather than bundled with the app.
Plugin _requests_ live in a separate repo, [lnreader-plugins](https://github.com/lnreader/lnreader-plugins);
this repo is the reader app itself.
## Setup
```bash
pnpm install
```
- Node >= 22.11.0, pnpm (see `packageManager` in `package.json`, currently pnpm@11.20.0).
- This is a React Native/Expo app targeting Android; there is no web or iOS
build target maintained here. Building/running the app requires an Android
SDK and a device/emulator, which is generally not available in an agent
sandbox — prefer running lint, type-check, and tests to verify changes.
- See `CONTRIBUTING.md` (and `CONTRIBUTING-NIX.md` for a Nix-based dev shell)
for full local Android setup instructions.
## Commands
Run these from the repo root:
```bash
pnpm run lint # eslint ./src
pnpm run lint:fix # eslint --fix
pnpm run format:check # prettier --check
pnpm run format # prettier --write
pnpm run type-check # tsc --noEmit
pnpm run check # format:check + lint + type-check (run before finishing any change)
pnpm test # jest (all projects)
pnpm run test:rn # jest --selectProjects rn (React Native/component/hook tests)
pnpm run test:db # jest --selectProjects db (database/query tests, node env)
pnpm run test:watch
pnpm run test:coverage
```
Always run `pnpm run check` (or at minimum lint + type-check + the relevant
test project) before considering a change complete. A pre-commit hook
(`husky` + `lint-staged`) runs prettier/eslint on staged files, and a
pre-push hook runs `type-check`, so failures here will also block commits/pushes.
## Code style
- TypeScript, functional React components, hooks-based state.
- Formatting is enforced by Prettier (`.prettierrc.js`) — don't hand-format,
run `pnpm run format` instead.
- ESLint config is `eslint.config.js` (flat config, based on `eslint-config-expo`).
Notable rules: `no-console` is an error (use the project's logging/toast
utilities, not `console.*`), `prefer-const`, `no-var`, and several
`react-hooks/*` correctness rules are enforced.
- Use the `@alias/*` path aliases defined in `tsconfig.json` (and mirrored in
`jest.config.js`) instead of deep relative imports, e.g. `@components`,
`@hooks`, `@database/*`, `@screens/*`, `@services/*`, `@utils/*`,
`@api/*`, `@theme/*`, `@plugins/*`, `@navigators/*`, `@i18n/*`, `@type/*`.
- Don't add comments that just restate what the code does; only comment
non-obvious "why" (workarounds, invariants, native/platform quirks).
## Localization (i18n)
- `src/i18n/languages/en/strings.json` is the source-of-truth string file —
it's the only locale you should hand-edit. Every other folder under
`src/i18n/languages/*` is a translation synced automatically from Crowdin
(see `crowdin.yml`); don't edit or "fix" translated strings in those files.
- After adding/changing keys in `en/strings.json`, run
`pnpm run generate:string-types` to regenerate the associated TypeScript types.
## Repository layout
- `src/api` – external API clients (drive/cloud backup providers, etc.)
- `src/components` – shared/reusable UI components
- `src/database` – SQLite (op-sqlite/Drizzle) schema, queries, and migrations
helpers; `src/database/queries/*Queries.ts` are the main data-access layer
- `src/hooks` – shared and "persisted" (MMKV-backed) React hooks
- `src/i18n` – translated strings (see Crowdin integration in `crowdin.yml`)
- `src/navigators` – React Navigation stack/tab setup
- `src/plugins` – plugin manager and plugin type definitions (not the plugin
source scrapers themselves, which live in the separate lnreader-plugins repo)
- `src/screens` – feature screens (library, browse, novel, reader, settings, etc.)
- `src/services` – background tasks, downloads, backups, trackers, EPUB export, updates
- `src/theme` – Material3 theming
- `src/utils` – general utilities
- `drizzle/` – generated SQL migrations (see `pnpm run generate:db-migration`)
- `modules/` – native modules (Nitro modules: epub, tts, file, zip-archive, etc.)
- `test/` – Jest global mocks and the shared `test-utils.tsx` render helpers
- `scripts/` – Node scripts for env file generation, release packaging, etc.
## Testing notes
- Don't add tests for the sake of coverage. Write tests for user-facing
interactions (screens, components a user directly touches) and for core
complex logic (parsing, database queries, sync/backup, chapter/novel
matching, etc.), not for trivial passthroughs, simple type/prop plumbing,
or generated/boilerplate code.
- Tests live in `__tests__` directories next to the code under test and use
Jest + `@testing-library/react-native`.
- Jest is split into two projects (see `jest.config.js`):
- `db` — pure logic/query tests under `src/database`, run in a Node environment.
- `rn` — component/hook/integration tests under `src/`, run with the `jest-expo` preset.
- Import the shared render helpers from `@test-utils` (`render`, `renderNovel`,
`AllTheProviders`) rather than using `@testing-library/react-native`'s
`render` directly, so providers (theme, gesture handler, safe area, novel
context) are set up consistently.
- Mock at module level with `jest.mock(...)`, not inside `beforeEach` — see
`TESTING.md` for the reasoning and common pitfalls (ESM transform errors,
`mockReturnValue` failures, test isolation).
- Global mocks for native modules and database queries live in `test/mocks/`;
hook-specific mocks live in `src/hooks/__mocks__` and `src/hooks/__tests__/mocks.ts`.
## Database changes
- Schema lives under `src/database/schema`; the ORM is Drizzle
(`drizzle-orm`, config in `drizzle.config.ts`).
- After changing the schema, generate a migration rather than hand-writing
SQL: `pnpm run generate:db-migration`. Do not edit files under `drizzle/`
by hand.
## PR / commit conventions
- Keep changes scoped and consistent with existing patterns in the touched
directory; prefer editing existing files over introducing new abstractions.
- Match existing code style; don't reformat unrelated code.
- If you are an AI agent opening a PR or making a commit, attribute it as
such (e.g. a "Generated with <agent/tool name>" note in the commit
message/PR description, and a co-author trailer if your tool supports one).
+40 -14
View File
@@ -22,10 +22,31 @@ import { useInitializeAppServices } from '@hooks/common/useInitializeAppServices
import { opSqliteAdapter } from './src/rozenite/opSqliteAdapter';
import { useRozeniteSqlitePlugin } from '@rozenite/sqlite-plugin';
import { ThemeProvider, useTheme } from '@hooks/persisted/useTheme';
import { KeyboardProvider } from 'react-native-keyboard-controller';
enableFreeze(true);
const sqliteAdapters = __DEV__ && opSqliteAdapter ? [opSqliteAdapter] : [];
/**
* The Android window background is resolved from the system light/dark setting,
* so it is bright white whenever the system is light – regardless of the theme
* picked inside the app. Nothing between it and the navigators paints a
* background of its own, so every frame in which no screen is drawn (mounting a
* nested navigator, freezing the screen being left) shows through as a flash.
* Painting the root view keeps the window covered at all times.
*/
const ThemedRootView = ({ children }: PropsWithChildren) => {
const theme = useTheme();
return (
<GestureHandlerRootView
style={[styles.flex, { backgroundColor: theme.background }]}
>
{children}
</GestureHandlerRootView>
);
};
const ThemedPaperProvider = ({ children }: PropsWithChildren) => {
const theme = useTheme();
const paperTheme = useMemo(() => {
@@ -71,20 +92,25 @@ const App = () => {
return (
<Suspense fallback={null}>
<GestureHandlerRootView style={styles.flex}>
<ThemeProvider>
<AppErrorBoundary>
<SafeAreaProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar translucent={true} backgroundColor="transparent" />
<Main />
</BottomSheetModalProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
</ThemeProvider>
</GestureHandlerRootView>
<ThemeProvider>
<ThemedRootView>
<KeyboardProvider>
<AppErrorBoundary>
<SafeAreaProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar
translucent={true}
backgroundColor="transparent"
/>
<Main />
</BottomSheetModalProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
</KeyboardProvider>
</ThemedRootView>
</ThemeProvider>
</Suspense>
);
};
+73
View File
@@ -0,0 +1,73 @@
# Debugging Production Builds
Symbolicate production stack traces from LNReader using uploaded sourcemaps.
## Prerequisites
- **Node.js** (v18 or later)
- The **sourcemap** matching the crashing APK version
- A **stack trace** from either `adb logcat` (device attached) or pasted from a Discord / GitHub issue report
## Getting the Sourcemap
### Release builds
Sourcemaps are uploaded as GitHub release assets. Download the one matching the crashing version:
```
LNReader-v{version}-sourcemap.map
```
Example for `v2.5.0`:
1. Go to the [releases page](https://github.com/lnreader/lnreader/releases)
2. Find release `v2.5.0`
3. Under **Assets**, download `LNReader-v2.5.0-sourcemap.map`
### Preview / nightly builds
Sourcemaps are uploaded as a separate artifact alongside the preview APK, named `LNReader-{sha}-sourcemap`.
1. Go to the [Actions tab](https://github.com/lnreader/lnreader/actions) → **Build Preview**
2. Find the workflow run matching the build hash
3. Under **Artifacts**, download the one with the `-sourcemap` suffix
## Symbolicating a Stack Trace
Save the raw stack trace to a file (from `adb logcat`, or paste from Discord/GitHub into `trace.txt`), then run:
```bash
npx metro-symbolicate <sourcemap.map> < trace.txt
```
Or pipe it directly:
```bash
adb logcat -d | npx metro-symbolicate <sourcemap.map>
```
## Example
Before (Hermes-obfuscated):
```
com.lnreader E Error: Unexpected token
at 93795 (address at index.js:1:729193)
at 28471 (address at index.js:1:219482)
at 10384 (address at index.js:1:81729)
```
After symbolication:
```
com.lnreader E Error: Unexpected token
at handleResponse (src/api/plugins.ts:120:12)
at fetchPlugin (src/hooks/usePlugins.ts:45:8)
at getPluginList (src/screens/browse/BrowseScreen.tsx:89:20)
```
## Troubleshooting
- **Version mismatch**: the sourcemap must match the exact APK build. A mismatched version produces wrong file names and line numbers.
- **No sourcemap artifact**: verify the workflow that built the APK ran successfully and that the "Upload Sourcemaps" / "Upload Preview Sourcemap" step completed without errors.
- **Hermes bytecode**: Hermes compiles JS to bytecode. The sourcemap is generated during this step and maps bytecode offsets back to source. `metro-symbolicate` handles this transparently.
+13 -4
View File
@@ -1,6 +1,6 @@
{
"expo": {
"version": "2.1.1",
"version": "2.1.3",
"name": "LNReader",
"slug": "LNReader",
"scheme": "lnreader",
@@ -30,7 +30,14 @@
}
],
"expo-localization",
"react-native-edge-to-edge",
[
"react-native-edge-to-edge",
{
"android": {
"enforceNavigationBarContrast": false
}
}
],
"expo-web-browser",
"./plugins/withReaderAssets",
[
@@ -41,13 +48,15 @@
"manifestApplicationAttributes": {
"android:largeHeap": "true",
"android:allowBackup": "true"
}
},
"enableMinifyInReleaseBuilds": true,
"enableShrinkResourcesInReleaseBuilds": true
}
}
]
],
"android": {
"versionCode": 4196353,
"versionCode": 4196355,
"package": "com.rajarsheechatterjee.LNReader",
"adaptiveIcon": {
"foregroundImage": "./assets/native/ic_launcher_foreground.png",
+12
View File
@@ -332,6 +332,9 @@ window.tts = new (function () {
if (!el) return;
if (this.readable(el)) {
elements.push(el);
// innerText already includes readable descendants, so descending any
// further would add overlapping text to the speech queue.
return;
}
for (let i = 0; i < el.children.length; i++) {
traverse(el.children[i]);
@@ -907,6 +910,15 @@ window.addEventListener('load', () => {
) {
return;
}
if (
diffY > 80 &&
Math.abs(diffY) > Math.abs(diffX) * 2 &&
window.scrollY <= 0
) {
e.preventDefault();
reader.post({ type: 'refresh' });
return;
}
if (
reader.generalSettings.val.swipeGestures &&
Math.abs(diffX) > Math.abs(diffY) * 2 &&
+262
View File
@@ -0,0 +1,262 @@
// Text selection functionality
window.textRemover = new (function () {
let selectionUI = null;
let isUIActive = false;
function createSelectionUI() {
if (selectionUI) return selectionUI;
const { div, button } = van.tags;
selectionUI = div(
{
id: 'text-selection-ui',
style: `
position: fixed;
background: color-mix(in srgb, var(--theme-surface), transparent 10%);
border-radius: 8px;
padding: 8px;
z-index: 100000;
opacity: 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
transition: opacity 150ms
`,
},
button(
{
style: `
background: var(--theme-secondary);
color: var(--theme-onSecondary);
padding: 6px 12px;
margin: 2px;
border: 0;
border-radius: 4px;
font-size: 12px;
`,
onclick: e => {
if (reader.hidden.val) {
e.stopPropagation();
}
removeSelectedText();
},
},
'Remove',
),
button(
{
style: `
background: var(--theme-secondary);
color: var(--theme-onSecondary);
padding: 6px 12px;
margin: 2px;
border: 0;
border-radius: 4px;
font-size: 12px;
`,
onclick: e => {
if (reader.hidden.val) {
e.stopPropagation();
}
replaceSelectedText();
},
},
'Replace',
),
);
document.body.appendChild(selectionUI);
return selectionUI;
}
function showSelectionUI() {
const ui = createSelectionUI();
// Get selection bounds
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// Get UI element heights from CSS variables (with fallbacks)
const statusBarHeight =
parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
'--StatusBar-currentHeight',
),
10,
) || 24;
const navigationBarHeight =
parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
'--bottom-inset',
),
10,
) || 24;
const readerPadding =
parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
'--readerSettings-padding',
),
10,
) || 16;
const uiHeight = 50; // Approximate height of our UI
// Calculate available space
const viewportHeight = window.innerHeight;
const selectionCenterY = rect.top + rect.height / 2;
const topSafeArea = statusBarHeight + readerPadding + 10;
const bottomSafeArea = readerPadding + uiHeight + navigationBarHeight;
// Position UI based on selection location
let topPosition;
if (selectionCenterY < (viewportHeight / 5) * 4) {
// Selection is in top 4/5, position UI at bottom
//TODO: make this dynamic
const avoidScrollbar = reader.generalSettings.val.verticalSeekbar
? 0
: 42;
const avoidUI = !reader.hidden.val ? 46 + avoidScrollbar : 0;
topPosition = viewportHeight - bottomSafeArea - avoidUI - 4;
ui.style.top = topPosition + 'px';
ui.style.bottom = 'auto';
} else {
// Selection is in bottom 1/5, position UI at top (accounting for status bar)
topPosition = Math.max(topSafeArea, statusBarHeight + 20);
const avoidUI = !reader.hidden.val ? 34 : 0;
ui.style.top = topPosition + avoidUI + 'px';
ui.style.bottom = 'auto';
}
// Center horizontally
ui.style.left = '50%';
ui.style.transform = 'translateX(-50%)';
} else {
// Fallback: position at top if no selection rect available
ui.style.top = '20px';
ui.style.left = '50%';
ui.style.transform = 'translateX(-50%)';
ui.style.bottom = 'auto';
}
ui.style.opacity = '1';
isUIActive = true;
}
function hideSelectionUI() {
if (selectionUI) {
selectionUI.style.opacity = '0';
}
isUIActive = false;
}
function getSelectedText() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
return selection.toString().trim();
}
return '';
}
function removeSelectedText() {
const selectedText = getSelectedText();
if (selectedText) {
reader.post({
type: 'text-action',
data: { remove: selectedText },
});
}
hideSelectionUI();
window.getSelection().removeAllRanges();
}
function replaceSelectedText() {
const selectedText = getSelectedText();
if (selectedText) {
// For replace, we need user input, so send a different message
reader.post({
type: 'text-action',
data: { replace: selectedText },
});
}
hideSelectionUI();
window.getSelection().removeAllRanges();
}
// Handle text selection
document.addEventListener('selectionchange', function () {
const selectedText = getSelectedText();
if (selectedText) {
showSelectionUI();
} else if (!isUIActive) {
hideSelectionUI();
}
});
// Hide UI when clicking/tapping elsewhere
document.addEventListener('touchstart', function (e) {
if (isUIActive && selectionUI && !selectionUI.contains(e.target)) {
const selectedText = getSelectedText();
if (!selectedText) {
hideSelectionUI();
}
}
});
document.addEventListener('click', function (e) {
if (isUIActive && selectionUI && !selectionUI.contains(e.target)) {
const selectedText = getSelectedText();
if (!selectedText) {
hideSelectionUI();
}
}
});
// Hide UI on scroll
window.addEventListener('scroll', function () {
hideSelectionUI();
});
})();
/**
* Directly remove text from the chapter DOM without reloading the WebView.
* Also updates reader.rawHTML so the text-options deriver (bionic reading,
* paragraph spacing) doesn't re-introduce removed text on its next run.
*/
window.textRemover.performRemove = function (text) {
const el = document.querySelector('#LNReader-chapter');
if (!el) return;
const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/);
let result;
if (m) {
try {
result = el.innerHTML.replace(new RegExp(m[1], m[2]), '');
} catch (_e) {
return;
}
} else {
result = el.innerHTML.split(text).join('');
}
el.innerHTML = result;
reader.rawHTML = result;
};
/**
* Directly replace text in the chapter DOM without reloading the WebView.
* Also updates reader.rawHTML so the text-options deriver stays in sync.
*/
window.textRemover.performReplace = function (from, to) {
const el = document.querySelector('#LNReader-chapter');
if (!el) return;
const m = from.match(/^\/(.*)\/([gmiyuvsd]*)$/);
let result;
if (m) {
try {
result = el.innerHTML.replace(new RegExp(m[1], m[2]), to);
} catch (_e) {
return;
}
} else {
result = el.innerHTML.split(from).join(to);
}
el.innerHTML = result;
reader.rawHTML = result;
};
+1
View File
@@ -28,6 +28,7 @@ export default function (api) {
'@specs': './specs',
'@test-utils': './test/test-utils',
'@env': './src/generated/build-info',
'@modules/nitro-epub': './modules/nitro-epub/src/index',
'@modules/nitro-tts': './modules/nitro-tts/src/index',
'@modules': './modules',
'react-native-vector-icons/MaterialCommunityIcons':
@@ -60,39 +60,64 @@ SELECT
`__migration_Novel`.`inLibrary`,
`__migration_Novel`.`isLocal`,
`__migration_Novel`.`totalPages`,
(
SELECT COUNT(*)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
AND `__migration_Chapter`.`isDownloaded` = 1
),
(
SELECT COUNT(*)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
AND `__migration_Chapter`.`unread` = 1
),
(
SELECT COUNT(*)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
),
(
SELECT MAX(`__migration_Chapter`.`readTime`)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
),
(
SELECT MAX(`__migration_Chapter`.`updatedTime`)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
)
FROM `__migration_Novel`;--> statement-breakpoint
COALESCE(`chapter_stats`.`chaptersDownloaded`, 0),
COALESCE(`chapter_stats`.`chaptersUnread`, 0),
COALESCE(`chapter_stats`.`totalChapters`, 0),
`chapter_stats`.`lastReadAt`,
`chapter_stats`.`lastUpdatedAt`
FROM `__migration_Novel`
LEFT JOIN (
SELECT
`novelId`,
SUM(CASE WHEN `isDownloaded` = 1 THEN 1 ELSE 0 END) AS `chaptersDownloaded`,
SUM(CASE WHEN `unread` = 1 THEN 1 ELSE 0 END) AS `chaptersUnread`,
COUNT(*) AS `totalChapters`,
MAX(`readTime`) AS `lastReadAt`,
MAX(`updatedTime`) AS `lastUpdatedAt`
FROM `__migration_Chapter`
GROUP BY `novelId`
) AS `chapter_stats`
ON `chapter_stats`.`novelId` = `__migration_Novel`.`id`;--> statement-breakpoint
DELETE FROM `NovelCategory`;--> statement-breakpoint
DELETE FROM `Chapter`;--> statement-breakpoint
DROP TABLE IF EXISTS `Novel`;--> statement-breakpoint
ALTER TABLE `__new_Novel` RENAME TO `Novel`;--> statement-breakpoint
INSERT OR REPLACE INTO `Chapter` SELECT * FROM `__migration_Chapter`;--> statement-breakpoint
INSERT OR REPLACE INTO `Chapter` (
`id`,
`novelId`,
`path`,
`name`,
`releaseTime`,
`bookmark`,
`unread`,
`readTime`,
`isDownloaded`,
`updatedTime`,
`chapterNumber`,
`page`,
`position`,
`progress`,
`scanlator`,
`timeSpent`
)
SELECT
`id`,
`novelId`,
`path`,
`name`,
`releaseTime`,
`bookmark`,
`unread`,
`readTime`,
`isDownloaded`,
`updatedTime`,
`chapterNumber`,
`page`,
`position`,
`progress`,
`scanlator`,
`timeSpent`
FROM `__migration_Chapter`;--> statement-breakpoint
INSERT OR REPLACE INTO `NovelCategory` SELECT * FROM `__migration_NovelCategory`;--> statement-breakpoint
DROP TABLE `__migration_Novel`;--> statement-breakpoint
DROP TABLE `__migration_Chapter`;--> statement-breakpoint
@@ -0,0 +1 @@
ALTER TABLE `Repository` ADD `enabled` integer DEFAULT true NOT NULL;
@@ -0,0 +1,654 @@
{
"version": "7",
"dialect": "sqlite",
"id": "2fada83f-d142-46d9-ba03-5ae1e232e3ce",
"prevIds": [
"498c0b85-a3f4-413f-8fed-7914c3ba15d9"
],
"ddl": [
{
"name": "Category",
"entityType": "tables"
},
{
"name": "Chapter",
"entityType": "tables"
},
{
"name": "NovelCategory",
"entityType": "tables"
},
{
"name": "Novel",
"entityType": "tables"
},
{
"name": "Repository",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "Category"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "Category"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sort",
"entityType": "columns",
"table": "Category"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "novelId",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "path",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "releaseTime",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "bookmark",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "true",
"generated": null,
"name": "unread",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "readTime",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "isDownloaded",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updatedTime",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "real",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "chapterNumber",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": "'1'",
"generated": null,
"name": "page",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "position",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "progress",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "scanlator",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "timeSpent",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "NovelCategory"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "novelId",
"entityType": "columns",
"table": "NovelCategory"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "categoryId",
"entityType": "columns",
"table": "NovelCategory"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "path",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "pluginId",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cover",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "author",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "artist",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": "'Unknown'",
"generated": null,
"name": "status",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "genres",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "inLibrary",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "isLocal",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "totalPages",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "chaptersDownloaded",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "chaptersUnread",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "totalChapters",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "lastReadAt",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "lastUpdatedAt",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "Repository"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "Repository"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "true",
"generated": null,
"name": "enabled",
"entityType": "columns",
"table": "Repository"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "Category_pk",
"table": "Category",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "Chapter_pk",
"table": "Chapter",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "NovelCategory_pk",
"table": "NovelCategory",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "Novel_pk",
"table": "Novel",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "Repository_pk",
"table": "Repository",
"entityType": "pks"
},
{
"columns": [
{
"value": "name",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "category_name_unique",
"entityType": "indexes",
"table": "Category"
},
{
"columns": [
{
"value": "sort",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "category_sort_idx",
"entityType": "indexes",
"table": "Category"
},
{
"columns": [
{
"value": "novelId",
"isExpression": false
},
{
"value": "path",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "chapter_novel_path_unique",
"entityType": "indexes",
"table": "Chapter"
},
{
"columns": [
{
"value": "novelId",
"isExpression": false
},
{
"value": "position",
"isExpression": false
},
{
"value": "page",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "chapterNovelIdIndex",
"entityType": "indexes",
"table": "Chapter"
},
{
"columns": [
{
"value": "novelId",
"isExpression": false
},
{
"value": "categoryId",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "novel_category_unique",
"entityType": "indexes",
"table": "NovelCategory"
},
{
"columns": [
{
"value": "path",
"isExpression": false
},
{
"value": "pluginId",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "novel_path_plugin_unique",
"entityType": "indexes",
"table": "Novel"
},
{
"columns": [
{
"value": "pluginId",
"isExpression": false
},
{
"value": "path",
"isExpression": false
},
{
"value": "id",
"isExpression": false
},
{
"value": "inLibrary",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "NovelIndex",
"entityType": "indexes",
"table": "Novel"
},
{
"columns": [
{
"value": "url",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "repository_url_unique",
"entityType": "indexes",
"table": "Repository"
}
],
"renames": []
}
+2
View File
@@ -4,6 +4,7 @@ import m0000 from './20251222152612_past_mandrill/migration.sql';
import m0001 from './20260612232322_normal_saracen/migration.sql';
import m0002 from './20260719143427_long_moondragon/migration.sql';
import m0003 from './20260727081855_calm_chimera/migration.sql';
import m0004 from './20260811071655_parched_human_torch/migration.sql';
export default {
migrations: {
@@ -11,5 +12,6 @@ export default {
'20260612232322_normal_saracen': m0001,
'20260719143427_long_moondragon': m0002,
'20260727081855_calm_chimera': m0003,
'20260811071655_parched_human_torch': m0004,
},
};
+5
View File
@@ -1,6 +1,7 @@
const { defineConfig, globalIgnores } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
const { FlatCompat } = require('@eslint/eslintrc');
const tseslint = require('@typescript-eslint/eslint-plugin');
const compat = new FlatCompat({
baseDirectory: __dirname,
@@ -19,6 +20,7 @@ module.exports = defineConfig([
'.expo/**',
'dist/**',
'coverage/**',
'assets/**',
]),
expoConfig,
...compat
@@ -30,6 +32,9 @@ module.exports = defineConfig([
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
'no-shadow': 'off',
'no-undef': 'off',
+1
View File
@@ -16,6 +16,7 @@ const baseModuleNameMapper = {
'^@api/(.*)$': '<rootDir>/src/api/$1',
'^@type/(.*)$': '<rootDir>/src/type/$1',
'^@specs/(.*)$': '<rootDir>/specs/$1',
'^@modules/nitro-epub$': '<rootDir>/modules/nitro-epub/src/index',
'^@modules/nitro-tts$': '<rootDir>/modules/nitro-tts/src/index',
'^@modules/(.*)$': '<rootDir>/modules/$1',
'^@test-utils$': '<rootDir>/test/test-utils',
@@ -22,7 +22,7 @@ dependencies {
implementation 'com.facebook.react:react-android'
implementation 'androidx.room:room-runtime:2.7.0'
implementation 'androidx.room:room-ktx:2.7.0'
implementation 'androidx.work:work-runtime-ktx:2.10.0'
implementation 'androidx.work:work-runtime-ktx:2.11.2'
kapt 'androidx.room:room-compiler:2.7.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
}
}
@@ -0,0 +1,30 @@
package expo.modules.nativebackgroundtasks
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
class AutomaticBackupScheduleWorker(
appContext: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val title = inputData.getString(AutomaticBackupScheduler.TITLE)
?: AutomaticBackupScheduler.DEFAULT_TITLE
val description = inputData.getString(AutomaticBackupScheduler.DESCRIPTION)
?: AutomaticBackupScheduler.DEFAULT_DESCRIPTION
val directoryUri = inputData.getString(AutomaticBackupScheduler.DIRECTORY_URI)
return try {
BackgroundTaskScheduler.enqueueAutomaticBackup(
applicationContext,
title,
description,
directoryUri,
)
Result.success()
} catch (_: Exception) {
Result.retry()
}
}
}
@@ -0,0 +1,56 @@
package expo.modules.nativebackgroundtasks
import android.content.Context
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
object AutomaticBackupScheduler {
const val TITLE = "title"
const val DESCRIPTION = "description"
const val DIRECTORY_URI = "directoryUri"
const val DEFAULT_TITLE = "Local Backup"
const val DEFAULT_DESCRIPTION = "Preparing"
private const val WORK_NAME = "lnreader-automatic-backup"
private val allowedIntervals = setOf(6L, 12L, 24L, 48L, 168L)
fun schedule(
context: Context,
intervalHours: Long,
title: String,
description: String,
directoryUri: String?,
) {
require(intervalHours in allowedIntervals) {
"Unsupported automatic backup interval: $intervalHours"
}
val request =
PeriodicWorkRequestBuilder<AutomaticBackupScheduleWorker>(
intervalHours,
TimeUnit.HOURS,
)
.setInitialDelay(intervalHours, TimeUnit.HOURS)
.setInputData(
Data.Builder()
.putString(TITLE, title)
.putString(DESCRIPTION, description)
.putString(DIRECTORY_URI, directoryUri)
.build(),
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
request,
)
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
@@ -1,12 +1,15 @@
package expo.modules.nativebackgroundtasks
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.work.Data
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import java.util.UUID
object BackgroundTaskScheduler {
@@ -34,6 +37,10 @@ object BackgroundTaskScheduler {
return request.id
}
suspend fun cancel(context: Context, taskId: String) {
WorkManager.getInstance(context).cancelAllWorkByTag(taskId).await()
}
suspend fun enqueueLibraryUpdate(
context: Context,
title: String,
@@ -65,6 +72,62 @@ object BackgroundTaskScheduler {
return enqueue(context, task.id)
}
suspend fun enqueueAutomaticBackup(
context: Context,
title: String,
description: String,
directoryUri: String?,
): UUID? {
val dao = BackgroundTaskDatabase.get(context).tasks()
if (dao.getActiveByType(LOCAL_BACKUP_TASK_TYPE) != null) {
return null
}
val filename = "lnreader_backup_${System.currentTimeMillis()}.zip"
val destination = if (directoryUri != null) {
val treeUri = Uri.parse(directoryUri)
val documentId = DocumentsContract.getTreeDocumentId(treeUri)
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
DocumentsContract.createDocument(
context.contentResolver,
parentUri,
"application/zip",
filename,
)?.toString() ?: throw IllegalStateException(
"Could not create a backup in the selected directory",
)
} else {
val backupDirectory = context.getExternalFilesDir(null)?.resolve("Backups")
?: throw IllegalStateException("External files directory is unavailable")
if (!backupDirectory.exists() && !backupDirectory.mkdirs()) {
throw IllegalStateException("Could not create automatic backup directory")
}
backupDirectory.resolve(filename).absolutePath
}
val escapedDestination = destination
.replace("\\", "\\\\")
.replace("\"", "\\\"")
val now = System.currentTimeMillis()
val task = BackgroundTaskEntity(
id = UUID.randomUUID().toString(),
type = LOCAL_BACKUP_TASK_TYPE,
payload = """{"name":"$LOCAL_BACKUP_TASK_TYPE","data":{"destinationUri":"$escapedDestination","automatic":true}}""",
title = title,
description = description,
queueName = "lnreader-background-task:task:$LOCAL_BACKUP_TASK_TYPE",
state = BackgroundTaskState.QUEUED,
progress = null,
progressText = null,
checkpoint = null,
attempt = 0,
workId = null,
createdAt = now,
updatedAt = now,
)
dao.insert(task)
return enqueue(context, task.id)
}
private val NETWORK_TASKS = setOf(
LIBRARY_UPDATE_TASK_TYPE,
"DRIVE_BACKUP",
@@ -76,4 +139,5 @@ object BackgroundTaskScheduler {
)
private const val LIBRARY_UPDATE_TASK_TYPE = "UPDATE_LIBRARY"
private const val LOCAL_BACKUP_TASK_TYPE = "LOCAL_BACKUP"
}
@@ -8,6 +8,7 @@ import com.facebook.react.jstasks.HeadlessJsTaskConfig
class LNReaderHeadlessTaskService : HeadlessJsTaskService() {
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
val extras = intent?.extras ?: return null
if (!extras.containsKey(BackgroundTaskScheduler.TASK_ID)) return null
return HeadlessJsTaskConfig(
"LNReaderBackgroundTask",
Arguments.fromBundle(extras),
@@ -15,4 +16,4 @@ class LNReaderHeadlessTaskService : HeadlessJsTaskService() {
true,
)
}
}
}
@@ -38,16 +38,13 @@ class LNReaderTaskWorker(
setForeground(createForegroundInfo(runningTask))
val execution = TaskExecutionRegistry.register(taskId)
applicationContext.startService(
Intent(applicationContext, LNReaderHeadlessTaskService::class.java).apply {
putExtra("taskId", runningTask.id)
putExtra("type", runningTask.type)
putExtra("payload", runningTask.payload)
runningTask.checkpoint?.let { putExtra("checkpoint", it) }
},
)
return try {
applicationContext.startService(
Intent(applicationContext, LNReaderHeadlessTaskService::class.java).apply {
putExtra(BackgroundTaskScheduler.TASK_ID, runningTask.id)
},
)
when (val executionResult = execution.await()) {
TaskExecutionResult.Success -> {
val currentState = dao.get(taskId)?.state
@@ -88,6 +85,18 @@ class LNReaderTaskWorker(
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
}
throw error
} catch (error: Exception) {
val latestState = dao.get(taskId)?.state
if (latestState !in listOf(BackgroundTaskState.PAUSED, BackgroundTaskState.CANCELLED)) {
val now = System.currentTimeMillis()
val message = error.message ?: error.javaClass.simpleName
dao.updateProgress(taskId, null, message, now)
dao.updateState(taskId, BackgroundTaskState.FAILED, now)
dao.get(taskId)?.let {
TaskNotificationFactory.postTerminal(applicationContext, it)
}
}
Result.failure()
} finally {
TaskExecutionRegistry.cancel(taskId)
}
@@ -57,24 +57,13 @@ class NativeBackgroundTasksModule : Module() {
AsyncFunction("getTasks") {
runBlocking(Dispatchers.IO) {
val result = mutableListOf<Map<String, Any?>>()
dao.getAll().forEach { task ->
result.add(mapOf(
"id" to task.id,
"type" to task.type,
"payload" to task.payload,
"title" to task.title,
"description" to task.description,
"state" to task.state,
"progress" to task.progress,
"progressText" to task.progressText,
"checkpoint" to task.checkpoint,
"attempt" to task.attempt,
"createdAt" to task.createdAt.toDouble(),
"updatedAt" to task.updatedAt.toDouble(),
))
}
result
dao.getAll().map(::toRecord)
}
}
AsyncFunction("getTask") { taskId: String ->
runBlocking(Dispatchers.IO) {
dao.get(taskId)?.let(::toRecord)
}
}
@@ -107,6 +96,7 @@ class NativeBackgroundTasksModule : Module() {
if (TaskExecutionRegistry.isActive(taskId)) {
emitInterruption(taskId, "cancel")
}
BackgroundTaskScheduler.cancel(appContext.reactContext!!, taskId)
dao.updateCheckpoint(taskId, null, System.currentTimeMillis())
TaskNotificationFactory.dismiss(appContext.reactContext!!, taskId)
}
@@ -168,11 +158,40 @@ class NativeBackgroundTasksModule : Module() {
AsyncFunction("cancelLibraryUpdates") {
LibraryUpdateScheduler.cancel(appContext.reactContext!!)
}
AsyncFunction("scheduleAutomaticBackups") { intervalHours: Long, title: String, description: String, directoryUri: String ->
AutomaticBackupScheduler.schedule(
appContext.reactContext!!,
intervalHours,
title,
description,
directoryUri.ifEmpty { null },
)
}
AsyncFunction("cancelAutomaticBackups") {
AutomaticBackupScheduler.cancel(appContext.reactContext!!)
}
}
private suspend fun requireTask(taskId: String): BackgroundTaskEntity =
dao.get(taskId) ?: throw IllegalArgumentException("Unknown background task: $taskId")
private fun toRecord(task: BackgroundTaskEntity): Map<String, Any?> = mapOf(
"id" to task.id,
"type" to task.type,
"payload" to task.payload,
"title" to task.title,
"description" to task.description,
"state" to task.state,
"progress" to task.progress,
"progressText" to task.progressText,
"checkpoint" to task.checkpoint,
"attempt" to task.attempt,
"createdAt" to task.createdAt.toDouble(),
"updatedAt" to task.updatedAt.toDouble(),
)
companion object {
@Volatile
private var reactContextRef: WeakReference<ReactApplicationContext>? = null
@@ -33,6 +33,7 @@ class TaskActionReceiver : BroadcastReceiver() {
if (TaskExecutionRegistry.isActive(taskId)) {
NativeBackgroundTasksModule.emitInterruption(taskId, "cancel")
}
BackgroundTaskScheduler.cancel(context, taskId)
TaskNotificationFactory.dismiss(context, taskId)
}
}
@@ -41,4 +42,4 @@ class TaskActionReceiver : BroadcastReceiver() {
}
}
}
}
}
@@ -25,6 +25,7 @@ type NativeBackgroundTasksModule = {
queueName: string,
): Promise<string>;
getTasks(): Promise<NativeBackgroundTaskRecord[]>;
getTask(taskId: string): Promise<NativeBackgroundTaskRecord | null>;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
cancel(taskId: string): Promise<void>;
@@ -42,6 +43,13 @@ type NativeBackgroundTasksModule = {
description: string,
): Promise<void>;
cancelLibraryUpdates(): Promise<void>;
scheduleAutomaticBackups(
intervalHours: number,
title: string,
description: string,
directoryUri: string,
): Promise<void>;
cancelAutomaticBackups(): Promise<void>;
};
export default requireNativeModule<NativeBackgroundTasksModule>(
@@ -1,9 +1,11 @@
package expo.modules.nativefile
import android.app.Activity
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.modules.network.CookieJarContainer
@@ -32,6 +34,7 @@ import java.io.FileWriter
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
import java.io.PushbackInputStream
import java.util.zip.GZIPInputStream
import kotlin.coroutines.coroutineContext
@@ -47,7 +50,11 @@ class NativeFileModule : Module() {
private val activityEventListener = object : BaseActivityEventListener() {
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != CREATE_DOCUMENT_REQUEST && requestCode != PICK_DOCUMENT_REQUEST) return
if (
requestCode != CREATE_DOCUMENT_REQUEST &&
requestCode != PICK_DOCUMENT_REQUEST &&
requestCode != PICK_DIRECTORY_REQUEST
) return
val promise = pendingDocumentPromise ?: return
pendingDocumentPromise = null
val uri = data?.data
@@ -62,7 +69,24 @@ class NativeFileModule : Module() {
} catch (_: SecurityException) {
// Some providers do not support persisted grants.
}
promise.resolve(uri.toString())
if (requestCode == PICK_DIRECTORY_REQUEST) {
val documentId = try {
DocumentsContract.getTreeDocumentId(uri)
} catch (_: IllegalArgumentException) {
uri.lastPathSegment.orEmpty()
}
promise.resolve(
mapOf(
"uri" to uri.toString(),
"name" to documentId
.substringAfterLast(':')
.substringAfterLast('/')
.ifEmpty { "Selected folder" },
),
)
} else {
promise.resolve(uri.toString())
}
}
}
@@ -97,9 +121,10 @@ class NativeFileModule : Module() {
filepath: String,
destPath: String,
onDone: (() -> Unit)? = null,
) {
): Long {
try {
val inputStream = getInputStream(filepath)
var copiedBytes = 0L
try {
val outputStream = getOutputStream(destPath)
try {
@@ -108,7 +133,9 @@ class NativeFileModule : Module() {
while (inputStream.read(buffer).also { length = it } > 0) {
coroutineContext.ensureActive()
outputStream.write(buffer, 0, length)
copiedBytes += length
}
outputStream.flush()
} finally {
outputStream.close()
}
@@ -118,11 +145,201 @@ class NativeFileModule : Module() {
if (onDone != null) {
onDone()
}
return copiedBytes
} catch (e: IOException) {
throw Exception("Failed to copy file from '$filepath' to '$destPath': ${e.message}")
}
}
private fun contentResolver(): ContentResolver =
reactContext?.contentResolver
?: throw IOException("React context is unavailable")
private suspend fun copyToOutputStream(sourcePath: String, outputStream: OutputStream): Long {
var copiedBytes = 0L
getInputStream(sourcePath).use { inputStream ->
outputStream.use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0) {
coroutineContext.ensureActive()
output.write(buffer, 0, length)
copiedBytes += length
}
output.flush()
}
}
return copiedBytes
}
private fun resolveDirectoryFile(directoryUri: String): File {
val uri = Uri.parse(directoryUri)
val directory = if (uri.scheme == null) {
File(directoryUri)
} else if (uri.scheme == ContentResolver.SCHEME_FILE) {
File(uri.path ?: throw IOException("Invalid directory URI: '$directoryUri'"))
} else {
throw IOException("Unsupported filesystem directory URI: '$directoryUri'")
}
if (!directory.isDirectory) {
throw IOException("Destination directory does not exist: '$directoryUri'")
}
return directory
}
private suspend fun copyFileToFilesystemDirectory(
sourcePath: String,
directoryUri: String,
fileName: String,
replace: Boolean,
): Map<String, Any> {
val directory = resolveDirectoryFile(directoryUri)
val destination = File(directory, fileName)
if (destination.exists() && !replace) {
throw IOException("File already exists: ${destination.absolutePath}")
}
val staging = File(directory, ".$fileName.${UUID.randomUUID()}.tmp")
val backup = File(directory, ".$fileName.${UUID.randomUUID()}.bak")
try {
val copiedBytes = FileOutputStream(staging).use { output ->
copyToOutputStream(sourcePath, output)
}
if (staging.length() != copiedBytes) {
throw IOException("Copied file size does not match the source stream")
}
val hadDestination = destination.exists()
if (hadDestination && !destination.renameTo(backup)) {
throw IOException("Could not stage the existing destination for replacement")
}
if (!staging.renameTo(destination)) {
if (hadDestination) {
backup.renameTo(destination)
}
throw IOException("Could not move the completed copy into the destination")
}
if (backup.exists() && !backup.delete()) {
throw IOException("Could not remove the replaced destination backup")
}
return mapOf("uri" to destination.absolutePath, "size" to copiedBytes)
} finally {
staging.delete()
if (backup.exists() && !destination.exists()) {
backup.renameTo(destination)
}
}
}
private fun resolveTreeDirectoryUri(treeUri: Uri): Uri {
if (!DocumentsContract.isTreeUri(treeUri)) {
throw IOException("Destination is not a Storage Access Framework directory: '$treeUri'")
}
val documentId = DocumentsContract.getTreeDocumentId(treeUri)
return DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
}
private fun findChildDocument(treeUri: Uri, directoryUri: Uri, fileName: String): Uri? {
val resolver = contentResolver()
val documentId = DocumentsContract.getDocumentId(directoryUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
)
resolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
if (cursor.getString(1) == fileName) {
return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(0))
}
}
}
return null
}
private fun renameDocument(documentUri: Uri, displayName: String): Uri =
DocumentsContract.renameDocument(contentResolver(), documentUri, displayName)
?: throw IOException("Could not rename destination document to '$displayName'")
private suspend fun copyFileToSafDirectory(
sourcePath: String,
directoryUriString: String,
fileName: String,
mimeType: String,
replace: Boolean,
): Map<String, Any> {
val resolver = contentResolver()
val treeUri = Uri.parse(directoryUriString)
val directoryUri = resolveTreeDirectoryUri(treeUri)
val existing = findChildDocument(treeUri, directoryUri, fileName)
if (existing != null && !replace) {
throw IOException("File already exists: $fileName")
}
val token = UUID.randomUUID().toString()
val stagingName = "$fileName.lnreader-$token.tmp"
val backupName = "$fileName.lnreader-$token.bak"
var stagingUri = DocumentsContract.createDocument(
resolver,
directoryUri,
mimeType,
stagingName,
) ?: throw IOException("Could not create a temporary destination document")
var backupUri: Uri? = null
var completedUri: Uri? = null
try {
val copiedBytes = copyToOutputStream(
sourcePath,
resolver.openOutputStream(stagingUri, writeAccessByAPILevel)
?: throw IOException("Could not open the temporary destination document"),
)
if (existing != null) {
backupUri = renameDocument(existing, backupName)
}
try {
completedUri = renameDocument(stagingUri, fileName)
stagingUri = completedUri
} catch (error: Exception) {
backupUri?.let {
try {
renameDocument(it, fileName)
backupUri = null
} catch (_: Exception) {
// Keep the original replacement error.
}
}
throw error
}
backupUri?.let {
try {
DocumentsContract.deleteDocument(resolver, it)
} catch (_: Exception) {
// The completed destination is already in place.
}
backupUri = null
}
return mapOf("uri" to completedUri.toString(), "size" to copiedBytes)
} finally {
if (completedUri == null) {
try {
DocumentsContract.deleteDocument(resolver, stagingUri)
} catch (_: Exception) {
// Preserve the original copy or replacement error.
}
}
if (completedUri == null) backupUri?.let {
try {
renameDocument(it, fileName)
} catch (_: Exception) {
// Preserve the original copy or replacement error.
}
}
}
}
private suspend fun deleteRecursive(fileOrDirectory: File) {
coroutineContext.ensureActive()
if (fileOrDirectory.isDirectory) {
@@ -195,6 +412,14 @@ class NativeFileModule : Module() {
)
}
AsyncFunction("pickDirectory") { promise: Promise ->
launchDocumentIntent(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),
PICK_DIRECTORY_REQUEST,
promise,
)
}
AsyncFunction("writeFile") { path: String, content: String, promise: Promise ->
coroutineScope.launch {
try {
@@ -232,6 +457,25 @@ class NativeFileModule : Module() {
}
}
AsyncFunction("copyFileToDirectory") { sourcePath: String, directoryUri: String, fileName: String, mimeType: String, replace: Boolean, promise: Promise ->
coroutineScope.launch {
try {
require(fileName.isNotBlank() && fileName != "." && fileName != ".." && !fileName.contains('/') && !fileName.contains('\\')) {
"Invalid destination file name"
}
val uri = Uri.parse(directoryUri)
val result = if (uri.scheme == ContentResolver.SCHEME_CONTENT) {
copyFileToSafDirectory(sourcePath, directoryUri, fileName, mimeType, replace)
} else {
copyFileToFilesystemDirectory(sourcePath, directoryUri, fileName, replace)
}
promise.resolve(result)
} catch (e: Exception) {
rejectFileOperation(promise, "copy into directory", directoryUri, e)
}
}
}
AsyncFunction("moveFile") { filepath: String, destPath: String, promise: Promise ->
coroutineScope.launch {
try {
@@ -262,6 +506,9 @@ class NativeFileModule : Module() {
coroutineScope.launch {
try {
val file = File(filepath)
if (file.exists() && !file.isDirectory) {
throw IOException("A file already exists at the directory path")
}
if (!file.exists() && !file.mkdirs()) {
throw IOException("Directory could not be created")
}
@@ -353,14 +600,21 @@ class NativeFileModule : Module() {
}
}
Constant("DocumentDirectoryPath") {
val context = reactContext ?: appContext.currentActivity
context?.filesDir?.absolutePath.orEmpty()
}
Constant("ExternalDirectoryPath") {
val externalDirectory = reactContext?.getExternalFilesDir(null)
externalDirectory?.absolutePath ?: ""
val context = reactContext ?: appContext.currentActivity
val directory = context?.getExternalFilesDir(null) ?: context?.filesDir
directory?.absolutePath.orEmpty()
}
Constant("ExternalCachesDirectoryPath") {
val externalCachesDirectory = reactContext?.externalCacheDir
externalCachesDirectory?.absolutePath ?: ""
val context = reactContext ?: appContext.currentActivity
val directory = context?.externalCacheDir ?: context?.cacheDir
directory?.absolutePath.orEmpty()
}
}
@@ -386,5 +640,6 @@ class NativeFileModule : Module() {
companion object {
private const val CREATE_DOCUMENT_REQUEST = 48120
private const val PICK_DOCUMENT_REQUEST = 48121
private const val PICK_DIRECTORY_REQUEST = 48122
}
}
}
@@ -17,6 +17,55 @@ public class NativeFileModule: Module {
try FileManager.default.copyItem(atPath: sourcePath, toPath: destPath)
}
AsyncFunction("copyFileToDirectory") { (sourcePath: String, directoryUri: String, fileName: String, mimeType: String, replace: Bool) -> [String: Any] in
_ = mimeType
guard !fileName.isEmpty,
fileName != ".",
fileName != "..",
!fileName.contains("/"),
!fileName.contains("\\") else {
throw NSError(
domain: "NativeFile",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid destination file name"]
)
}
let fileManager = FileManager.default
let directoryURL: URL
if let parsedURL = URL(string: directoryUri), parsedURL.isFileURL {
directoryURL = parsedURL
} else {
directoryURL = URL(fileURLWithPath: directoryUri, isDirectory: true)
}
let sourceURL = URL(fileURLWithPath: sourcePath)
let destinationURL = directoryURL.appendingPathComponent(fileName)
let stagingURL = directoryURL.appendingPathComponent(".\(fileName).\(UUID().uuidString).tmp")
guard fileManager.fileExists(atPath: directoryURL.path) else {
throw NSError(
domain: "NativeFile",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Destination directory does not exist"]
)
}
if fileManager.fileExists(atPath: destinationURL.path) && !replace {
throw CocoaError(.fileWriteFileExists)
}
defer { try? fileManager.removeItem(at: stagingURL) }
try fileManager.copyItem(at: sourceURL, to: stagingURL)
let attributes = try fileManager.attributesOfItem(atPath: stagingURL.path)
let copiedSize = (attributes[.size] as? NSNumber)?.int64Value ?? 0
if fileManager.fileExists(atPath: destinationURL.path) {
_ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL)
} else {
try fileManager.moveItem(at: stagingURL, to: destinationURL)
}
return ["uri": destinationURL.absoluteString, "size": copiedSize]
}
Function("moveFile") { (sourcePath: String, destPath: String) in
try FileManager.default.moveItem(atPath: sourcePath, toPath: destPath)
}
@@ -52,6 +101,11 @@ public class NativeFileModule: Module {
promise.reject("NOT_IMPLEMENTED", "downloadFile is not implemented on iOS")
}
Constant("DocumentDirectoryPath") {
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
return paths.first ?? ""
}
Constant("ExternalDirectoryPath") {
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
return paths.first ?? ""
+20 -1
View File
@@ -6,14 +6,33 @@ export type ReadDirResult = {
isDirectory: boolean;
};
export type DirectorySelection = {
uri: string;
name: string;
};
export type FileCopyResult = {
uri: string;
size: number;
};
type NativeFileModule = {
DocumentDirectoryPath: string;
ExternalDirectoryPath: string;
ExternalCachesDirectoryPath: string;
createDocument(filename: string, mimeType: string): Promise<string>;
pickDocument(mimeType: string): Promise<string>;
pickDirectory(): Promise<DirectorySelection>;
writeFile(path: string, content: string): Promise<void>;
readFile(path: string): Promise<string>;
copyFile(filepath: string, destPath: string): Promise<void>;
copyFileToDirectory(
sourcePath: string,
directoryUri: string,
fileName: string,
mimeType: string,
replace: boolean,
): Promise<FileCopyResult>;
moveFile(filepath: string, destPath: string): Promise<void>;
exists(filepath: string): Promise<boolean>;
mkdir(filepath: string): Promise<void>;
@@ -28,4 +47,4 @@ type NativeFileModule = {
): Promise<void>;
};
export default requireNativeModule<NativeFileModule>('NativeFile');
export default requireNativeModule<NativeFileModule>('NativeFile');
@@ -13,8 +13,6 @@ import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class NativeZipArchiveModule : Module() {
private val BUFFER_SIZE = 4096
private fun zipProcess(sourceDirPath: String, zos: ZipOutputStream) {
val sourceDir = File(sourceDirPath)
sourceDir.walkBottomUp().filter { it.isFile }.forEach { file ->
@@ -23,10 +21,8 @@ class NativeZipArchiveModule : Module() {
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
zos.putNextEntry(entry)
file.inputStream().use { fis ->
fis.copyTo(zos, BUFFER_SIZE)
fis.close()
fis.copyTo(zos, COPY_BUFFER_SIZE)
}
Thread.yield()
}
}
@@ -41,9 +37,8 @@ class NativeZipArchiveModule : Module() {
val newFile = File(distDirPath, zipEntry.name)
newFile.parentFile?.mkdirs()
zis.getInputStream(zipEntry).use { inputStream ->
FileOutputStream(newFile).use { fos -> inputStream.copyTo(fos, BUFFER_SIZE) }
FileOutputStream(newFile).use { fos -> inputStream.copyTo(fos, COPY_BUFFER_SIZE) }
}
Thread.yield()
}
}
promise.resolve(null)
@@ -80,8 +75,7 @@ class NativeZipArchiveModule : Module() {
.forEach { zipEntry ->
val newFile = File(distDirPath, zipEntry.name)
newFile.parentFile?.mkdirs()
FileOutputStream(newFile).use { fos -> zis.copyTo(fos, BUFFER_SIZE) }
Thread.yield()
FileOutputStream(newFile).use { fos -> zis.copyTo(fos, COPY_BUFFER_SIZE) }
}
}
if (connection.responseCode == 200) {
@@ -121,4 +115,8 @@ class NativeZipArchiveModule : Module() {
}
}
private companion object {
const val COPY_BUFFER_SIZE = 64 * 1024
}
}
+1
View File
@@ -6,6 +6,7 @@ Modules.
## Capabilities
- Parse an extracted EPUB into novel metadata and chapter paths.
- Normalize raster-only SVG image wrappers into HTML images during import.
- Export downloaded chapter files as an EPUB 3 archive.
- Copy local chapter images into the archive and remove missing images.
- Generate EPUB navigation, NCX compatibility navigation, metadata, CSS, and
@@ -8,6 +8,7 @@ set (CMAKE_CXX_STANDARD 20)
# Define C++ library and add all sources
add_library(${PACKAGE_NAME} SHARED
src/main/cpp/cpp-adapter.cpp
../cpp/import/EpubChapterNormalizer.cpp
../cpp/import/EpubParser.cpp
../cpp/export/EpubContentProcessor.cpp
../cpp/export/EpubDocuments.cpp
@@ -0,0 +1,135 @@
#include "EpubChapterNormalizer.hpp"
#include "../pugixml.hpp"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <string>
#include <vector>
namespace {
std::string getLocalName(const std::string& qualifiedName) {
const size_t separator = qualifiedName.find(':');
return separator == std::string::npos
? qualifiedName
: qualifiedName.substr(separator + 1);
}
std::string toLower(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](char character) {
return static_cast<char>(
std::tolower(static_cast<unsigned char>(character)));
});
return value;
}
std::string trim(std::string value) {
const auto isNotWhitespace = [](char character) {
return !std::isspace(static_cast<unsigned char>(character));
};
const auto start = std::find_if(value.begin(), value.end(), isNotWhitespace);
const auto end = std::find_if(value.rbegin(), value.rend(), isNotWhitespace)
.base();
return start < end ? std::string(start, end) : "";
}
std::string getImageReference(const pugi::xml_node& image) {
for (const pugi::xml_attribute attribute : image.attributes()) {
if (toLower(getLocalName(attribute.name())) == "href") {
return attribute.value();
}
}
return "";
}
void collectSvgNodes(const pugi::xml_node& parent,
std::vector<pugi::xml_node>& svgNodes) {
for (const pugi::xml_node child : parent.children()) {
if (child.type() != pugi::node_element) {
continue;
}
if (toLower(getLocalName(child.name())) == "svg") {
svgNodes.push_back(child);
}
collectSvgNodes(child, svgNodes);
}
}
bool normalizeImageWrapper(const pugi::xml_node& svg) {
std::vector<pugi::xml_node> images;
std::string description;
for (const pugi::xml_node child : svg.children()) {
if (child.type() != pugi::node_element) {
continue;
}
const std::string childName = toLower(getLocalName(child.name()));
if (childName == "image") {
if (getImageReference(child).empty()) {
return false;
}
images.push_back(child);
} else if (childName == "title" || childName == "desc") {
if (description.empty()) {
description = trim(child.text().as_string());
}
} else {
return false;
}
}
if (images.empty()) {
return false;
}
pugi::xml_node parent = svg.parent();
for (const pugi::xml_node image : images) {
pugi::xml_node htmlImage = parent.insert_child_before("img", svg);
for (const pugi::xml_attribute attribute : image.attributes()) {
if (toLower(getLocalName(attribute.name())) != "href") {
htmlImage.append_attribute(attribute.name()) = attribute.value();
}
}
htmlImage.append_attribute("src") = getImageReference(image).c_str();
if (!description.empty() && !htmlImage.attribute("alt")) {
htmlImage.append_attribute("alt") = description.c_str();
}
}
return parent.remove_child(svg);
}
} // namespace
bool normalizeEpubChapter(const std::string& chapterPath) {
pugi::xml_document document;
if (!document.load_file(chapterPath.c_str(), pugi::parse_full)) {
return false;
}
std::vector<pugi::xml_node> svgNodes;
collectSvgNodes(document, svgNodes);
bool changed = false;
for (const pugi::xml_node svg : svgNodes) {
changed = normalizeImageWrapper(svg) || changed;
}
if (!changed) {
return false;
}
const std::string temporaryPath = chapterPath + ".lnreader-normalized";
const unsigned int format = pugi::format_raw | pugi::format_no_declaration;
if (!document.save_file(temporaryPath.c_str(), "", format)) {
std::remove(temporaryPath.c_str());
return false;
}
if (std::rename(temporaryPath.c_str(), chapterPath.c_str()) != 0) {
std::remove(temporaryPath.c_str());
return false;
}
return true;
}
@@ -0,0 +1,5 @@
#pragma once
#include <string>
bool normalizeEpubChapter(const std::string& chapterPath);
+207 -41
View File
@@ -1,11 +1,16 @@
#include <string>
#include "../pugixml.hpp"
#include <iostream>
#include <stdexcept>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "EpubChapterNormalizer.hpp"
#include "EpubParser.hpp"
#include <sstream>
namespace {
std::string join(const std::string &folder_path, const std::string &child_path)
{
std::string full_path = folder_path;
@@ -62,11 +67,118 @@ std::string getParentPath(const std::string &path)
return path.substr(0, pos);
}
std::string getLocalName(const std::string &qualified_name)
{
size_t separator = qualified_name.find(':');
return separator == std::string::npos
? qualified_name
: qualified_name.substr(separator + 1);
}
bool hasLocalName(const pugi::xml_node &node, const char *local_name)
{
return getLocalName(node.name()) == local_name;
}
pugi::xml_node childByLocalName(const pugi::xml_node &parent, const char *local_name)
{
for (pugi::xml_node child : parent.children())
{
if (hasLocalName(child, local_name))
{
return child;
}
}
return {};
}
void collectDescendantsByLocalName(const pugi::xml_node &parent,
const char *local_name,
std::vector<pugi::xml_node> &result)
{
for (pugi::xml_node child : parent.children())
{
if (hasLocalName(child, local_name))
{
result.push_back(child);
}
collectDescendantsByLocalName(child, local_name, result);
}
}
std::string findImageReference(const pugi::xml_node &node)
{
std::string node_name = getLocalName(node.name());
if (node_name == "img" || node_name == "image")
{
for (const char *attribute_name : {"src", "href", "xlink:href"})
{
std::string reference = node.attribute(attribute_name).as_string();
if (!reference.empty())
{
size_t fragment = reference.find('#');
return fragment == std::string::npos
? reference
: reference.substr(0, fragment);
}
}
}
for (pugi::xml_node child : node.children())
{
std::string reference = findImageReference(child);
if (!reference.empty())
{
return reference;
}
}
return "";
}
std::string findCoverImagePath(
const std::string &cover_document_path,
const std::unordered_set<std::string> &image_paths)
{
pugi::xml_document cover_document;
if (!cover_document.load_file(cover_document_path.c_str()))
{
return "";
}
std::string image_reference = findImageReference(cover_document);
if (image_reference.empty())
{
return "";
}
std::string image_path = join(getParentPath(cover_document_path), image_reference);
return image_paths.count(image_path) ? image_path : "";
}
bool hasProperty(const std::string &properties, const std::string &property)
{
std::stringstream property_stream(properties);
std::string value;
while (property_stream >> value)
{
if (value == property)
{
return true;
}
}
return false;
}
std::string find_toc_href(const pugi::xml_document &opf_doc)
{
auto manifest = opf_doc.child("package").child("manifest");
for (pugi::xml_node item : manifest.children("item"))
pugi::xml_node package = childByLocalName(opf_doc, "package");
pugi::xml_node manifest = childByLocalName(package, "manifest");
for (pugi::xml_node item : manifest.children())
{
if (!hasLocalName(item, "item"))
continue;
std::string media_type = item.attribute("media-type").value();
std::string id = item.attribute("id").value();
if (media_type == "application/x-dtbncx+xml" || id == "ncx" || id == "nav")
@@ -80,9 +192,12 @@ std::string find_toc_href(const pugi::xml_document &opf_doc)
void parse_navele_recursive(const pugi::xml_node &parent,
std::unordered_map<std::string, std::string> &href_to_label, std::string &nav_folder)
{
for (pugi::xml_node li : parent.children("li"))
for (pugi::xml_node li : parent.children())
{
pugi::xml_node a = li.child("a");
if (!hasLocalName(li, "li"))
continue;
pugi::xml_node a = childByLocalName(li, "a");
if (a)
{
std::string href = a.attribute("href").as_string();
@@ -96,7 +211,7 @@ void parse_navele_recursive(const pugi::xml_node &parent,
href_to_label[join(nav_folder, href)] = label;
}
if (pugi::xml_node sublist = li.child("ol"))
if (pugi::xml_node sublist = childByLocalName(li, "ol"))
{
parse_navele_recursive(sublist, href_to_label, nav_folder);
}
@@ -111,11 +226,11 @@ void parse_nav_xhtml(const std::string &nav_path,
if (!nav_doc.load_file(nav_path.c_str()))
return;
for (pugi::xpath_node nav : nav_doc.select_nodes("//nav"))
std::vector<pugi::xml_node> nav_nodes;
collectDescendantsByLocalName(nav_doc, "nav", nav_nodes);
for (pugi::xml_node node : nav_nodes)
{
pugi::xml_node node = nav.node();
std::string nav_type = node.attribute("epub:type").as_string();
pugi::xml_node ol = node.child("ol");
pugi::xml_node ol = childByLocalName(node, "ol");
if (ol)
{
parse_navele_recursive(ol, path_to_label, nav_folder);
@@ -126,15 +241,19 @@ void parse_nav_xhtml(const std::string &nav_path,
void parse_navpoint_recursive(const pugi::xml_node &navPoint,
std::unordered_map<std::string, std::string> &result, std::string &ncx_folder)
{
for (pugi::xml_node point : navPoint.children("navPoint"))
for (pugi::xml_node point : navPoint.children())
{
if (!hasLocalName(point, "navPoint"))
continue;
std::string label;
pugi::xml_node labelNode = point.child("navLabel").child("text");
pugi::xml_node labelNode = childByLocalName(
childByLocalName(point, "navLabel"), "text");
if (labelNode)
label = labelNode.text().as_string();
std::string src;
pugi::xml_node contentNode = point.child("content");
pugi::xml_node contentNode = childByLocalName(point, "content");
if (contentNode)
src = contentNode.attribute("src").as_string();
@@ -157,7 +276,8 @@ void parse_toc_ncx(const std::string &ncx_path, std::unordered_map<std::string,
if (!doc.load_file(ncx_path.c_str()))
return;
pugi::xml_node navMap = doc.child("ncx").child("navMap");
pugi::xml_node navMap = childByLocalName(
childByLocalName(doc, "ncx"), "navMap");
if (!navMap)
return;
@@ -172,38 +292,42 @@ void parse_opf_from_folder(const std::string &base_dir,
std::string opf_dir = getParentPath(opf_path);
pugi::xml_document opf_doc;
if (!opf_doc.load_file(opf_path.c_str()))
return;
std::string version;
pugi::xml_node package = opf_doc.child("package");
if (package)
{
version = package.attribute("version").as_string();
}
throw std::runtime_error("Failed to load EPUB package document");
pugi::xml_node package = childByLocalName(opf_doc, "package");
if (!package)
throw std::runtime_error("EPUB package document is missing package");
std::unordered_map<std::string, std::string> path_to_label;
std::string toc_href = find_toc_href(opf_doc);
if (toc_href.find("ncx") != std::string::npos)
if (!toc_href.empty() && toc_href.find("ncx") != std::string::npos)
{
std::string ncx_path = join(opf_dir, toc_href);
parse_toc_ncx(ncx_path, path_to_label);
}
else
else if (!toc_href.empty())
{
std::string nav_path = join(opf_dir, toc_href);
parse_nav_xhtml(nav_path, path_to_label);
}
auto metadata = opf_doc.child("package").child("metadata");
meta_out.name = metadata.child("dc:title").text().as_string();
meta_out.author = metadata.child("dc:creator").text().as_string();
meta_out.artist = metadata.child("dc:contributor").text().as_string();
meta_out.summary = metadata.child("dc:description").text().as_string();
pugi::xml_node metadata = childByLocalName(package, "metadata");
meta_out.name = childByLocalName(metadata, "title").text().as_string();
meta_out.author = childByLocalName(metadata, "creator").text().as_string();
meta_out.artist = childByLocalName(metadata, "contributor").text().as_string();
meta_out.summary = childByLocalName(metadata, "description").text().as_string();
std::unordered_map<std::string, std::string> id_to_href;
std::unordered_map<std::string, std::string> id_to_media_type;
std::unordered_set<std::string> image_paths;
std::string property_cover_id;
std::string cover_id;
for (pugi::xml_node meta : metadata.children("meta"))
for (pugi::xml_node meta : metadata.children())
{
if (!hasLocalName(meta, "meta"))
continue;
if (std::string(meta.attribute("name").value()) == "cover")
{
cover_id = meta.attribute("content").value();
@@ -211,40 +335,76 @@ void parse_opf_from_folder(const std::string &base_dir,
}
}
auto manifest = opf_doc.child("package").child("manifest");
for (pugi::xml_node item : manifest.children("item"))
pugi::xml_node manifest = childByLocalName(package, "manifest");
if (!manifest)
throw std::runtime_error("EPUB package document is missing manifest");
for (pugi::xml_node item : manifest.children())
{
if (!hasLocalName(item, "item"))
continue;
std::string id = item.attribute("id").value();
std::string href = item.attribute("href").value();
std::string media_type = item.attribute("media-type").value();
std::string properties = item.attribute("properties").value();
id_to_href[id] = href;
id_to_media_type[id] = media_type;
if (media_type == "text/css")
{
meta_out.cssPaths.push_back(join(opf_dir, href));
}
else if (media_type == "image/jpeg" || media_type == "image/png" || media_type == "image/jpg")
else if (media_type.rfind("image/", 0) == 0)
{
meta_out.imagePaths.push_back(join(opf_dir, href));
std::string image_path = join(opf_dir, href);
meta_out.imagePaths.push_back(image_path);
image_paths.insert(image_path);
if (property_cover_id.empty() && hasProperty(properties, "cover-image"))
{
property_cover_id = id;
}
}
}
if (!cover_id.empty() && id_to_href.count(cover_id))
if (!cover_id.empty() && id_to_href.count(cover_id) &&
id_to_media_type[cover_id].rfind("image/", 0) == 0)
{
meta_out.cover = join(opf_dir, id_to_href[cover_id]);
}
else if (!property_cover_id.empty())
{
meta_out.cover = join(opf_dir, id_to_href[property_cover_id]);
}
else if (id_to_href.count("cover-image") &&
id_to_media_type["cover-image"].rfind("image/", 0) == 0)
{
meta_out.cover = join(opf_dir, id_to_href["cover-image"]);
}
else if (!cover_id.empty() && id_to_href.count(cover_id))
{
std::string cover_document_path = join(opf_dir, id_to_href[cover_id]);
meta_out.cover = findCoverImagePath(cover_document_path, image_paths);
}
pugi::xml_node spine = childByLocalName(package, "spine");
if (!spine)
throw std::runtime_error("EPUB package document is missing spine");
auto spine = opf_doc.child("package").child("spine");
std::string prev_name = "";
int part = 2;
for (pugi::xml_node itemref : spine.children("itemref"))
for (pugi::xml_node itemref : spine.children())
{
if (!hasLocalName(itemref, "itemref"))
continue;
std::string idref = itemref.attribute("idref").value();
if (id_to_href.count(idref))
{
std::string chapter_href = id_to_href[idref];
Chapter chapter;
chapter.path = join(opf_dir, chapter_href);
normalizeEpubChapter(chapter.path);
if (path_to_label.count(chapter.path))
{
@@ -254,7 +414,7 @@ void parse_opf_from_folder(const std::string &base_dir,
{
if (prev_name.empty())
{
int start_pos = chapter_href.find_last_of("/");
size_t start_pos = chapter_href.find_last_of("/");
if (start_pos == std::string::npos)
{
start_pos = 0;
@@ -280,8 +440,13 @@ void parse_opf_from_folder(const std::string &base_dir,
meta_out.chapters.push_back(chapter);
}
}
if (meta_out.chapters.empty())
throw std::runtime_error("EPUB spine contains no readable chapters");
}
} // namespace
EpubMetadata parseEpub(const std::string epub_path)
{
std::string container_path = join(epub_path, "META-INF/container.xml");
@@ -290,11 +455,12 @@ EpubMetadata parseEpub(const std::string epub_path)
if (!container_doc.load_file(container_path.c_str()))
throw std::runtime_error("Failed to load container.xml");
std::string opf_path = container_doc.child("container")
.child("rootfiles")
.child("rootfile")
.attribute("full-path")
.value();
pugi::xml_node container = childByLocalName(container_doc, "container");
pugi::xml_node rootfiles = childByLocalName(container, "rootfiles");
pugi::xml_node rootfile = childByLocalName(rootfiles, "rootfile");
std::string opf_path = rootfile.attribute("full-path").value();
if (opf_path.empty())
throw std::runtime_error("container.xml is missing a package path");
EpubMetadata metadata;
parse_opf_from_folder(epub_path, opf_path, metadata);
@@ -0,0 +1,131 @@
#include "../import/EpubParser.hpp"
#include <algorithm>
#include <cassert>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace {
void writeFile(const std::filesystem::path& path, const std::string& content) {
std::filesystem::create_directories(path.parent_path());
std::ofstream file(path);
file << content;
}
std::string readFile(const std::filesystem::path& path) {
std::ifstream file(path);
return {std::istreambuf_iterator<char>(file),
std::istreambuf_iterator<char>()};
}
bool containsPath(const std::vector<std::string>& paths,
const std::filesystem::path& expectedPath) {
return std::find(paths.begin(), paths.end(), expectedPath.string()) !=
paths.end();
}
} // namespace
int main(int argc, char** argv) {
assert(argc == 2);
const std::filesystem::path fixtureDirectory = argv[1];
std::filesystem::remove_all(fixtureDirectory);
writeFile(
fixtureDirectory / "META-INF/container.xml",
R"xml(<?xml version="1.0"?>
<ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container">
<ocf:rootfiles>
<ocf:rootfile full-path="OEBPS/content.opf"/>
</ocf:rootfiles>
</ocf:container>)xml");
writeFile(
fixtureDirectory / "OEBPS/content.opf",
R"xml(<?xml version="1.0"?>
<opf:package xmlns:opf="http://www.idpf.org/2007/opf"
xmlns:dc="http://purl.org/dc/elements/1.1/" version="3.0">
<opf:metadata>
<dc:title>Image fixture</dc:title>
<opf:meta name="cover" content="cover-document"/>
</opf:metadata>
<opf:manifest>
<opf:item id="nav" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<opf:item id="cover-document" href="Text/cover.xhtml" media-type="application/xhtml+xml"/>
<opf:item id="chapter" href="Text/chapter.xhtml" media-type="application/xhtml+xml"/>
<opf:item id="jpeg" href="Images/cover.jpg" media-type="image/jpeg"/>
<opf:item id="png" href="Images/illustration.png" media-type="image/png"/>
<opf:item id="webp" href="Images/illustration.webp" media-type="image/webp"/>
<opf:item id="gif" href="Images/illustration.gif" media-type="image/gif"/>
<opf:item id="svg" href="Images/illustration.svg" media-type="image/svg+xml"/>
</opf:manifest>
<opf:spine>
<opf:itemref idref="cover-document"/>
<opf:itemref idref="chapter"/>
</opf:spine>
</opf:package>)xml");
writeFile(
fixtureDirectory / "OEBPS/toc.ncx",
R"xml(<?xml version="1.0"?>
<ncx:ncx xmlns:ncx="http://www.daisy.org/z3986/2005/ncx/">
<ncx:navMap>
<ncx:navPoint>
<ncx:navLabel><ncx:text>Chapter</ncx:text></ncx:navLabel>
<ncx:content src="Text/chapter.xhtml"/>
</ncx:navPoint>
</ncx:navMap>
</ncx:ncx>)xml");
writeFile(
fixtureDirectory / "OEBPS/Text/chapter.xhtml",
R"xml(<html><body>
<svg><title>Chapter art</title><image width="1200" height="1600" href="../Images/illustration.png"/></svg>
<svg xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="../Images/illustration.webp"/><path d="M0 0h10v10z"/></svg>
</body></html>)xml");
writeFile(
fixtureDirectory / "OEBPS/Text/cover.xhtml",
R"xml(<html><body><svg xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="../Images/cover.jpg"/></svg></body></html>)xml");
const EpubMetadata metadata = parseEpub(fixtureDirectory.string());
const std::filesystem::path imageDirectory = fixtureDirectory / "OEBPS/Images";
assert(metadata.name == "Image fixture");
assert(metadata.imagePaths.size() == 5);
assert(containsPath(metadata.imagePaths, imageDirectory / "cover.jpg"));
assert(containsPath(metadata.imagePaths,
imageDirectory / "illustration.png"));
assert(containsPath(metadata.imagePaths,
imageDirectory / "illustration.webp"));
assert(containsPath(metadata.imagePaths,
imageDirectory / "illustration.gif"));
assert(containsPath(metadata.imagePaths,
imageDirectory / "illustration.svg"));
assert(metadata.cover == (imageDirectory / "cover.jpg").string());
assert(metadata.chapters.size() == 2);
assert(metadata.chapters.front().path ==
(fixtureDirectory / "OEBPS/Text/cover.xhtml").string());
assert(metadata.chapters[1].name == "Chapter");
const std::string coverChapter =
readFile(fixtureDirectory / "OEBPS/Text/cover.xhtml");
assert(coverChapter.find("<svg") == std::string::npos);
assert(coverChapter.find("<img src=\"../Images/cover.jpg\"") !=
std::string::npos);
const std::string regularChapter =
readFile(fixtureDirectory / "OEBPS/Text/chapter.xhtml");
assert(regularChapter.find(
"<img width=\"1200\" height=\"1600\" "
"src=\"../Images/illustration.png\" alt=\"Chapter art\"") !=
std::string::npos);
assert(regularChapter.find(
"<image xlink:href=\"../Images/illustration.webp\"") !=
std::string::npos);
assert(regularChapter.find("<path d=\"M0 0h10v10z\"") !=
std::string::npos);
return 0;
}
@@ -3,6 +3,10 @@ package com.margelo.nitro.nitrotts
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -29,9 +33,18 @@ internal object TtsPlaybackStore {
private var state = TtsPlaybackState.IDLE
private var generation = 0L
private var audioManager: AudioManager? = null
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus = false
private var resumeOnFocusGain = false
fun prepare(context: Context, completion: (Result<Unit>) -> Unit) {
runOnOwner {
applicationContext = context.applicationContext
if (audioManager == null) {
audioManager =
applicationContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
}
if (isReady && boundEngineName == settings.engineName) {
completion(Result.success(Unit))
return@runOnOwner
@@ -163,14 +176,16 @@ internal object TtsPlaybackStore {
speakCurrent()
}
/**
* Manual pause (from the user, media notification, or MediaSession controls).
* Always wins over a later focus regain — even if we're currently paused because of a
* transient focus loss (e.g. a call), calling this makes sure TTS stays paused once the
* call ends instead of auto-resuming.
*/
fun pause() {
if (state != TtsPlaybackState.PLAYING) {
return
}
generation += 1
engine?.stop()
state = TtsPlaybackState.PAUSED
emitState()
resumeOnFocusGain = false
abandonAudioFocus()
pauseEngine()
}
fun stop() {
@@ -180,10 +195,97 @@ internal object TtsPlaybackStore {
currentIndex = 0
metadata = null
state = TtsPlaybackState.IDLE
resumeOnFocusGain = false
abandonAudioFocus()
emitState()
applicationContext?.let { TtsPlaybackService.stop(it) }
}
/** Stops the engine and marks playback paused, without touching audio focus. */
private fun pauseEngine() {
if (state != TtsPlaybackState.PLAYING) {
return
}
generation += 1
engine?.stop()
state = TtsPlaybackState.PAUSED
emitState()
}
/**
* Requests playback focus so the system can tell us to pause for a call or other audio
* that genuinely needs exclusive use of the speaker. Duck-only interruptions (notification
* sounds, alerts) are left alone - see [handleAudioFocusChange].
*/
private fun requestAudioFocus(): Boolean {
if (hasAudioFocus) return true
val manager = audioManager ?: return false
val granted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attributes)
.setOnAudioFocusChangeListener(audioFocusChangeListener, ownerHandler)
.build()
audioFocusRequest = request
manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} else {
@Suppress("DEPRECATION")
manager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN,
) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
hasAudioFocus = granted
return granted
}
private fun abandonAudioFocus() {
if (!hasAudioFocus) return
val manager = audioManager
if (manager != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { manager.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
manager.abandonAudioFocus(audioFocusChangeListener)
}
}
hasAudioFocus = false
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
runOnOwner { handleAudioFocusChange(focusChange) }
}
private fun handleAudioFocusChange(focusChange: Int) {
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
// Another app now owns audio focus outright (e.g. a call was answered).
hasAudioFocus = false
resumeOnFocusGain = false
pauseEngine()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
// Something needs exclusive use of the speaker for a short while
// Pause and remember to resume once it's done.
resumeOnFocusGain = state == TtsPlaybackState.PLAYING
pauseEngine()
}
AudioManager.AUDIOFOCUS_GAIN -> {
hasAudioFocus = true
if (resumeOnFocusGain) {
resumeOnFocusGain = false
play()
}
}
}
}
fun skipPrevious() {
check(paragraphs.isNotEmpty()) { "Load a paragraph queue before seeking." }
currentIndex = (currentIndex - 1).coerceAtLeast(0)
@@ -255,6 +357,11 @@ internal object TtsPlaybackStore {
return
}
if (!requestAudioFocus()) {
fail("Couldn't get audio focus - another app or call may be using audio.")
return
}
val activeEngine = checkNotNull(engine)
val paragraph = paragraphs[currentIndex]
generation += 1
@@ -276,6 +383,7 @@ internal object TtsPlaybackStore {
}
state = TtsPlaybackState.PLAYING
resumeOnFocusGain = false
emitProgress()
emitState()
}
@@ -392,4 +500,4 @@ internal object TtsPlaybackStore {
}
}
}
}
}
+15 -12
View File
@@ -1,6 +1,6 @@
{
"name": "lnreader",
"version": "2.1.1",
"version": "2.1.3",
"private": true,
"scripts": {
"dev:android:release": "pnpm run generate:env:release && npx expo run:android --variant release",
@@ -8,7 +8,7 @@
"dev:ios": "npx expo run:ios",
"dev:start": "npx expo start",
"dev:clean-start": "npx expo start --clear",
"build:release:android": "pnpm run generate:env:release && pnpm exec expo prebuild --platform android --clean --no-install && mkdir -p android/app/build/intermediates/sourcemaps/react/release && cd android && NODE_ENV=production ./gradlew :app:assembleRelease --build-cache --parallel",
"build:release:android": "pnpm run generate:env:release && pnpm exec expo prebuild --platform android --clean --no-install && mkdir -p android/app/build/intermediates/sourcemaps/react/release && cd android && NODE_ENV=production ./gradlew :app:assembleRelease --build-cache --parallel -PreleaseAbiSplits=true",
"build:open-apk": "open ./android/app/build/outputs/apk/release/",
"generate:env:debug": "node scripts/generate-env-file.cjs --build-type Debug",
"generate:env:release": "node scripts/generate-env-file.cjs --build-type Release",
@@ -36,23 +36,19 @@
"nix:emulator": "nix develop .#emulator --extra-experimental-features nix-command --extra-experimental-features flakes"
},
"dependencies": {
"@expo/dom-webview": "^57.0.1",
"@gorhom/bottom-sheet": "^5.2.14",
"@legendapp/list": "^3.3.3",
"@noble/ciphers": "^2.2.0",
"@op-engineering/op-sqlite": "^15.2.14",
"@pchmn/expo-material3-theme": "^1.4.0",
"@preeternal/react-native-cookie-manager": "^6.3.3",
"@react-native-documents/picker": "^12.0.1",
"@react-native-google-signin/google-signin": "^16.1.2",
"@react-native-vector-icons/common": "^13.0.1",
"@react-native-vector-icons/material-design-icons": "^13.1.2",
"@react-navigation/bottom-tabs": "^7.18.11",
"@react-navigation/native": "^7.3.11",
"@react-navigation/native-stack": "^7.18.3",
"@react-navigation/stack": "^7.10.14",
"@shopify/flash-list": "^2.0.2",
"babel-plugin-inline-import": "^3.0.0",
"@rozenite/react-navigation-plugin": "^2.0.0",
"cheerio": "1.0.0-rc.12",
"color": "^5.0.3",
"dayjs": "^1.11.21",
@@ -76,28 +72,29 @@
"htmlparser2": "^12.0.0",
"i18n-js": "^4.5.3",
"lodash-es": "^4.18.1",
"lottie-react-native": "^7.3.8",
"protobufjs": "^8.7.1",
"react": "19.2.3",
"react-native": "0.86.0",
"react-native": "^0.86.0",
"react-native-device-info": "^15.0.2",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-drawer-layout": "^4.2.8",
"react-native-edge-to-edge": "^1.8.1",
"react-native-error-boundary": "^3.1.0",
"react-native-gesture-handler": "^2.32.0",
"react-native-keyboard-controller": "^1.22.2",
"react-native-mmkv": "^4.3.2",
"react-native-pager-view": "^8.0.2",
"react-native-paper": "^5.15.3",
"react-native-reanimated": "^4.5.0",
"react-native-saf-x": "^2.2.3",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.25.2",
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-svg": "15.15.4",
"react-native-tab-view": "^4.3.2",
"react-native-url-polyfill": "^3.0.0",
"react-native-webview": "^13.16.1",
"react-native-worklets": "^0.10.0",
"react-syntax-highlighter": "^16.1.1",
"sanitize-html": "^2.17.6",
"urlencode": "^2.0.0",
"zustand": "^5.0.14"
@@ -117,9 +114,11 @@
"@types/jest": "^29.5.14",
"@types/lodash-es": "^4.17.12",
"@types/react": "~19.2.17",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/sanitize-html": "^2.16.1",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/parser": "^8.64.0",
"babel-plugin-inline-import": "^3.0.0",
"babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-react-compiler": "^1.0.0",
"better-sqlite3": "^12.11.1",
@@ -140,6 +139,11 @@
"typescript": "~6.0.3"
},
"expo": {
"autolinking": {
"exclude": [
"@expo/dom-webview"
]
},
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
@@ -150,7 +154,6 @@
"nitro-epub",
"native-background-tasks",
"@react-native-vector-icons/material-design-icons",
"react-native-saf-x",
"lottie-ios"
]
}
@@ -168,5 +171,5 @@
"engines": {
"node": ">=22.11.0"
},
"packageManager": "pnpm@11.15.0"
"packageManager": "pnpm@11.20.0"
}
@@ -3,7 +3,18 @@ def releaseAbiSplitsEnabled = providers
.map { value -> value.toBoolean() }
.getOrElse(false)
react {
hermesFlags = ["-O", "-output-source-map"]
}
android {
defaultConfig {
// Library resources (Material Components, RN, play-services) only for
// languages the app itself ships (src/i18n/languages/*); other locales
// fall back to English, matching the app's own JS fallback.
resConfigs "af", "ar", "as", "ca", "cs", "da", "de", "el", "en", "es", "fi", "fr", "he", "hi", "hu", "id", "it", "ja", "ko", "nl", "no", "or", "pl", "pt", "ro", "ru", "sq", "sr", "sv", "tr", "uk", "vi", "zh"
}
splits {
abi {
enable releaseAbiSplitsEnabled
+53
View File
@@ -1,4 +1,8 @@
const {
AndroidConfig,
withAndroidColors,
withAndroidColorsNight,
withAndroidStyles,
withAppBuildGradle,
withDangerousMod,
} = require('@expo/config-plugins');
@@ -35,6 +39,53 @@ const DRAWABLE_RESOURCE_NAMES = [
];
const ANDROID_DENSITIES = ['mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi'];
/**
* `AppTheme` inherits `android:windowBackground` from
* `Theme.AppCompat.DayNight.NoActionBar`, which resolves to a near-white colour
* whenever the system is in light mode. The window is visible for the frames
* between the splash screen going away and React drawing its first frame, so
* the app opens with a white flash for anyone using a dark theme on a light
* system. These are the backgrounds of the default light and dark themes.
*/
const WINDOW_BACKGROUND_RESOURCE = 'lnreaderWindowBackground';
const WINDOW_BACKGROUND_LIGHT = '#FEFBFF';
const WINDOW_BACKGROUND_DARK = '#1B1B1F';
const withWindowBackgroundColors = config => {
config = withAndroidColors(config, config => {
config.modResults = AndroidConfig.Colors.assignColorValue(
config.modResults,
{ name: WINDOW_BACKGROUND_RESOURCE, value: WINDOW_BACKGROUND_LIGHT },
);
return config;
});
return withAndroidColorsNight(config, config => {
config.modResults = AndroidConfig.Colors.assignColorValue(
config.modResults,
{ name: WINDOW_BACKGROUND_RESOURCE, value: WINDOW_BACKGROUND_DARK },
);
return config;
});
};
const withWindowBackgroundStyle = config =>
withAndroidStyles(config, config => {
config.modResults = AndroidConfig.Styles.assignStylesValue(
config.modResults,
{
add: true,
parent: AndroidConfig.Styles.getAppThemeGroup(),
name: 'android:windowBackground',
value: `@color/${WINDOW_BACKGROUND_RESOURCE}`,
},
);
return config;
});
const withBuildGradleCustomizations = config =>
withAppBuildGradle(config, config => {
const { contents } = config.modResults;
@@ -133,5 +184,7 @@ const withAndroidVariantAssets = config =>
module.exports = config => {
config = withBuildGradleCustomizations(config);
config = withWindowBackgroundColors(config);
config = withWindowBackgroundStyle(config);
return withAndroidVariantAssets(config);
};
+540 -195
View File
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,10 @@
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, View, Text, StatusBar } from 'react-native';
import ErrorBoundary from 'react-native-error-boundary';
import * as Clipboard from 'expo-clipboard';
import { getString } from '@i18n/translations';
import { getErrorChainMessages } from '@utils/error';
import { showToast } from '@utils/showToast';
import { Button, List } from '@components';
import { useTheme } from '@hooks/persisted';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -17,6 +20,36 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
}) => {
const theme = useTheme();
const fallbackGetString = (
key: Parameters<typeof getString>[0],
fallback: string,
options?: Parameters<typeof getString>[1],
) => {
try {
return getString(key, options);
} catch {
return fallback;
}
};
const chainMessages = useMemo(() => getErrorChainMessages(error), [error]);
const handleCopyStackTrace = async () => {
try {
const message = chainMessages.join('\n\nCaused by: ');
await Clipboard.setStringAsync(`${message}\n\n${error.stack}`);
showToast(
fallbackGetString(
'common.copiedToClipboard',
'Copied to clipboard: Stack trace',
{ name: 'Stack trace' },
),
);
} catch {
// clipboard failure is non-critical
}
};
return (
<SafeAreaView
style={[styles.mainCtn, { backgroundColor: theme.background }]}
@@ -24,12 +57,16 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
<StatusBar translucent={true} backgroundColor="transparent" />
<View style={styles.errorInfoCtn}>
<Text style={[styles.errorTitle, { color: theme.onSurface }]}>
An Unexpected Error Ocurred
{fallbackGetString(
'errorBoundary.title',
'An Unexpected Error Occurred',
)}
</Text>
<Text style={[styles.errorDesc, { color: theme.onSurface }]}>
The application ran into an unexpected error. We suggest you
screenshot this message and then share it in our support channel on
Discord.
{fallbackGetString(
'errorBoundary.description',
'The application ran into an unexpected error. Please copy the stack trace below and share it on our Discord support channel.',
)}
</Text>
<Text
style={[
@@ -41,13 +78,25 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
]}
numberOfLines={20}
>
{`${error.message}\n\n${error.stack}`}
{`${chainMessages.join('\n\nCaused by: ')}\n\n${error.stack}`}
</Text>
</View>
<List.Divider theme={theme} />
<Button
onPress={handleCopyStackTrace}
title={fallbackGetString(
'errorBoundary.copyStackTrace',
'Copy stack trace',
)}
style={styles.copyButtonCtn}
mode="outlined"
/>
<Button
onPress={resetError}
title={'Restart the application'}
title={fallbackGetString(
'errorBoundary.restart',
'Restart the application',
)}
style={styles.buttonCtn}
mode="contained"
/>
@@ -72,6 +121,10 @@ const styles = StyleSheet.create({
margin: 16,
marginBottom: 32,
},
copyButtonCtn: {
margin: 16,
marginBottom: 8,
},
errorCtn: {
borderRadius: 8,
lineHeight: 20,
@@ -0,0 +1,39 @@
import { useCallback, useState } from 'react';
import AppUpdateDialog from './AppUpdateDialog';
import { useAppUpdateChecker } from './useAppUpdateChecker';
const AppUpdateChecker = () => {
const { ignoreVersion, isNewVersion, latestRelease } = useAppUpdateChecker();
const [dismissedVersion, setDismissedVersion] = useState<string>();
const versionTag = latestRelease?.tag_name;
const dismissUpdate = useCallback(() => {
setDismissedVersion(versionTag);
}, [versionTag]);
const ignoreUpdate = useCallback(() => {
if (versionTag) {
ignoreVersion(versionTag);
}
}, [ignoreVersion, versionTag]);
if (
!isNewVersion ||
!latestRelease ||
dismissedVersion === latestRelease.tag_name
) {
return null;
}
return (
<AppUpdateDialog
release={latestRelease}
onDismiss={dismissUpdate}
onIgnore={ignoreUpdate}
/>
);
};
export default AppUpdateChecker;
@@ -0,0 +1,66 @@
import { StyleSheet, useWindowDimensions } from 'react-native';
import * as Linking from 'expo-linking';
import { ScrollView } from 'react-native-gesture-handler';
import { getString } from '@i18n/translations';
import { Dialog } from '../Dialog';
import type { AppRelease } from './useAppUpdateChecker';
interface AppUpdateDialogProps {
release: AppRelease;
onDismiss: () => void;
onIgnore: () => void;
}
const AppUpdateDialog = ({
release,
onDismiss,
onIgnore,
}: AppUpdateDialogProps) => {
const maxContentHeight = useWindowDimensions().height / 2;
const installUpdate = () => {
if (release.downloadUrl) {
void Linking.openURL(release.downloadUrl);
}
};
return (
<Dialog.Root visible onDismiss={onDismiss}>
<Dialog.Title>
{`${getString('common.newUpdateAvailable')} ${release.tag_name}`}
</Dialog.Title>
<Dialog.ScrollArea>
<ScrollView
contentContainerStyle={styles.scrollContent}
style={{ maxHeight: maxContentHeight }}
testID="app-update-release-notes"
>
<Dialog.Description>{release.body.trim()}</Dialog.Description>
</ScrollView>
</Dialog.ScrollArea>
<Dialog.Actions>
<Dialog.Action title={getString('common.later')} onPress={onDismiss} />
<Dialog.Action
title={getString('common.skipVersion')}
onPress={onIgnore}
/>
<Dialog.Action
title={getString('common.install')}
disabled={!release.downloadUrl}
onPress={installUpdate}
/>
</Dialog.Actions>
</Dialog.Root>
);
};
export default AppUpdateDialog;
const styles = StyleSheet.create({
scrollContent: {
paddingHorizontal: 24,
paddingVertical: 16,
},
});
@@ -0,0 +1,72 @@
import { act, render, screen } from '@testing-library/react-native';
import AppUpdateChecker from '../AppUpdateChecker';
import { useAppUpdateChecker } from '../useAppUpdateChecker';
jest.mock('../useAppUpdateChecker', () => ({
useAppUpdateChecker: jest.fn(),
}));
jest.mock('../AppUpdateDialog', () => {
const React = jest.requireActual<typeof import('react')>('react');
return function MockAppUpdateDialog({
onDismiss,
onIgnore,
}: {
onDismiss: () => void;
onIgnore: () => void;
}) {
return React.createElement('View', {
onDismiss,
onIgnore,
testID: 'app-update-dialog',
});
};
});
const mockIgnoreVersion = jest.fn();
const mockAppRelease = {
tag_name: 'v9.0.0',
body: 'Release notes',
downloadUrl: 'https://example.com/lnreader.apk',
};
describe('AppUpdateChecker', () => {
beforeEach(() => {
jest.mocked(useAppUpdateChecker).mockReturnValue({
ignoreVersion: mockIgnoreVersion,
isNewVersion: true,
latestRelease: mockAppRelease,
});
});
it('renders nothing when no update is available', () => {
jest.mocked(useAppUpdateChecker).mockReturnValue({
ignoreVersion: mockIgnoreVersion,
isNewVersion: false,
latestRelease: undefined,
});
render(<AppUpdateChecker />);
expect(screen.queryByTestId('app-update-dialog')).toBeNull();
});
it('dismisses the current release for the session', () => {
render(<AppUpdateChecker />);
act(() => screen.getByTestId('app-update-dialog').props.onDismiss());
expect(screen.queryByTestId('app-update-dialog')).toBeNull();
expect(mockIgnoreVersion).not.toHaveBeenCalled();
});
it('persists the release tag when ignored', () => {
render(<AppUpdateChecker />);
act(() => screen.getByTestId('app-update-dialog').props.onIgnore());
expect(mockIgnoreVersion).toHaveBeenCalledWith(mockAppRelease.tag_name);
});
});
@@ -0,0 +1,81 @@
import { fireEvent, render, screen } from '@testing-library/react-native';
import * as Linking from 'expo-linking';
import AppUpdateDialog from '../AppUpdateDialog';
jest.mock('@i18n/translations', () => ({
getString: (key: string) =>
({
'common.install': 'Install',
'common.later': 'Later',
'common.newUpdateAvailable': 'New update available',
'common.skipVersion': 'Skip version',
}[key] ?? key),
}));
jest.mock('@hooks/persisted', () => ({
useTheme: () => ({
onSurface: '#1d1b20',
onSurfaceVariant: '#49454f',
outlineVariant: '#cac4d0',
primary: '#6750a4',
scrim: '#000000',
surface: '#fffbfe',
surfaceContainerHigh: '#ece6f0',
}),
}));
jest.mock('expo-linking', () => ({
openURL: jest.fn(),
}));
const release = {
tag_name: 'v9.0.0',
body: '## Changes\n- First\n- Second\n',
downloadUrl: 'https://example.com/lnreader.apk',
};
describe('AppUpdateDialog', () => {
it('preserves release-note spacing within a content-sized scroll area', () => {
render(
<AppUpdateDialog
release={release}
onDismiss={jest.fn()}
onIgnore={jest.fn()}
/>,
);
expect(screen.getByText('## Changes\n- First\n- Second')).toBeTruthy();
expect(screen.getByTestId('app-update-release-notes').props.style).toEqual({
maxHeight: expect.any(Number),
});
expect(
screen.getByTestId('app-update-release-notes').props
.contentContainerStyle,
).toEqual({
paddingHorizontal: 24,
paddingVertical: 16,
});
});
it('exposes dismiss, skip, and install actions', () => {
const onDismiss = jest.fn();
const onIgnore = jest.fn();
render(
<AppUpdateDialog
release={release}
onDismiss={onDismiss}
onIgnore={onIgnore}
/>,
);
fireEvent.press(screen.getByText('Later'));
fireEvent.press(screen.getByText('Skip version'));
fireEvent.press(screen.getByText('Install'));
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(onIgnore).toHaveBeenCalledTimes(1);
expect(Linking.openURL).toHaveBeenCalledWith(release.downloadUrl);
});
});
@@ -0,0 +1,91 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { useAppUpdateChecker } from '../useAppUpdateChecker';
jest.mock('@utils/mmkv/mmkv', () => ({
MMKVStorage: {
getNumber: jest.fn(),
getString: jest.fn(),
set: jest.fn(),
},
}));
jest.mock('react-native-device-info', () => ({
__esModule: true,
default: {
supportedAbis: jest.fn().mockResolvedValue(['arm64-v8a']),
},
}));
const mockRelease = {
tag_name: 'v9.0.0',
body: 'Release notes',
assets: [
{
name: 'LNReader-v9.0.0-arm64-v8a.apk',
browser_download_url: 'https://example.com/lnreader.apk',
},
],
};
const mockFetchRelease = () =>
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockRelease),
} as unknown as Response);
describe('useAppUpdateChecker', () => {
beforeEach(() => {
jest.mocked(MMKVStorage.getNumber).mockReturnValue(undefined);
jest.mocked(MMKVStorage.getString).mockReturnValue(undefined);
});
it('reports a newer release that has not been ignored', async () => {
mockFetchRelease();
const { result } = renderHook(useAppUpdateChecker);
await waitFor(() => expect(result.current.isNewVersion).toBe(true));
expect(result.current.latestRelease).toEqual({
tag_name: mockRelease.tag_name,
body: mockRelease.body,
downloadUrl: mockRelease.assets[0].browser_download_url,
});
});
it('persists and immediately suppresses the ignored release', async () => {
mockFetchRelease();
const { result } = renderHook(useAppUpdateChecker);
await waitFor(() => expect(result.current.isNewVersion).toBe(true));
act(() => result.current.ignoreVersion(mockRelease.tag_name));
expect(MMKVStorage.set).toHaveBeenCalledWith(
'IGNORED_UPDATE_VERSION',
mockRelease.tag_name,
);
expect(result.current.isNewVersion).toBe(false);
});
it('does not report a release that was ignored previously', async () => {
jest.mocked(MMKVStorage.getString).mockReturnValue(mockRelease.tag_name);
mockFetchRelease();
const { result } = renderHook(useAppUpdateChecker);
await waitFor(() => expect(result.current.latestRelease).toBeDefined());
expect(result.current.isNewVersion).toBe(false);
});
it('reports a newer release after an older version was ignored', async () => {
jest.mocked(MMKVStorage.getString).mockReturnValue('v8.0.0');
mockFetchRelease();
const { result } = renderHook(useAppUpdateChecker);
await waitFor(() => expect(result.current.isNewVersion).toBe(true));
});
});
+1
View File
@@ -0,0 +1 @@
export { default } from './AppUpdateChecker';
@@ -1,22 +1,40 @@
import { useState, useEffect, useCallback } from 'react';
import { version } from '../../../package.json';
import { newer } from '@utils/compareVersion';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { useCallback, useEffect, useState } from 'react';
import DeviceInfo from 'react-native-device-info';
interface GithubUpdate {
isNewVersion: boolean;
latestRelease: any;
}
import { newer } from '@utils/compareVersion';
import { MMKVStorage } from '@utils/mmkv/mmkv';
const LAST_UPDATE_CHECK_KEY = 'LAST_UPDATE_CHECK';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
import { version } from '../../../package.json';
interface AppUpdate {
isNewVersion: boolean;
latestRelease?: AppRelease;
ignoreVersion: (versionTag: string) => void;
}
interface GithubReleaseAsset {
name?: string;
browser_download_url?: string;
}
interface GithubRelease {
tag_name?: string;
body?: string;
assets?: GithubReleaseAsset[];
}
export interface AppRelease {
tag_name: string;
body: string;
downloadUrl?: string;
}
const LATEST_RELEASE_URL =
'https://api.github.com/repos/LNReader/lnreader/releases/latest';
const LAST_UPDATE_CHECK_KEY = 'LAST_UPDATE_CHECK';
const IGNORED_UPDATE_VERSION_KEY = 'IGNORED_UPDATE_VERSION';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const getReleaseDownloadUrl = async (
assets: GithubReleaseAsset[] = [],
): Promise<string | undefined> => {
@@ -48,24 +66,31 @@ const getReleaseDownloadUrl = async (
);
};
export const useGithubUpdateChecker = (): GithubUpdate => {
const latestReleaseUrl =
'https://api.github.com/repos/LNReader/lnreader/releases/latest';
const shouldCheckForUpdate = (): boolean => {
const lastCheckTime = MMKVStorage.getNumber(LAST_UPDATE_CHECK_KEY);
if (!lastCheckTime) {
return true;
}
return Date.now() - lastCheckTime >= ONE_DAY_MS;
};
const isNewerVersion = (versionTag: string): boolean => {
const normalizedVersion = versionTag.replace(/[^\d.]/, '');
return newer(normalizedVersion, `${version}`);
};
export const useAppUpdateChecker = (): AppUpdate => {
const [checking, setChecking] = useState(true);
const [latestRelease, setLatestRelease] = useState<any>();
const [latestRelease, setLatestRelease] = useState<AppRelease>();
const [ignoredVersion, setIgnoredVersion] = useState(() =>
MMKVStorage.getString(IGNORED_UPDATE_VERSION_KEY),
);
const shouldCheckForUpdate = (): boolean => {
const lastCheckTime = MMKVStorage.getNumber(LAST_UPDATE_CHECK_KEY);
if (!lastCheckTime) {
return true;
}
const now = Date.now();
const timeSinceLastCheck = now - lastCheckTime;
return timeSinceLastCheck >= ONE_DAY_MS;
};
const ignoreVersion = useCallback((versionTag: string) => {
MMKVStorage.set(IGNORED_UPDATE_VERSION_KEY, versionTag);
setIgnoredVersion(versionTag);
}, []);
const checkForRelease = useCallback(async () => {
if (!shouldCheckForUpdate()) {
@@ -74,60 +99,49 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
}
try {
const res = await fetch(latestReleaseUrl);
const response = await fetch(LATEST_RELEASE_URL);
if (!res.ok) {
if (!response.ok) {
setChecking(false);
return;
}
const data = await res.json();
const data = (await response.json()) as GithubRelease;
if (!data || !data.tag_name) {
if (!data.tag_name) {
setChecking(false);
return;
}
const downloadUrl = await getReleaseDownloadUrl(data.assets);
const release = {
tag_name: data.tag_name,
body: data.body,
downloadUrl,
};
MMKVStorage.set(LAST_UPDATE_CHECK_KEY, Date.now());
setLatestRelease(release);
setLatestRelease({
tag_name: data.tag_name,
body: data.body ?? '',
downloadUrl,
});
setChecking(false);
} catch {
// Silently fail in offline mode or on network errors
// Silently fail in offline mode or on network errors.
setChecking(false);
}
}, []);
const isNewVersion = (versionTag: string) => {
const currentVersion = `${version}`;
const regex = /[^\d.]/;
const newVersion = versionTag.replace(regex, '');
return newer(newVersion, currentVersion);
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
checkForRelease();
void checkForRelease();
}, [checkForRelease]);
if (!checking && latestRelease?.tag_name) {
return {
latestRelease,
isNewVersion: isNewVersion(latestRelease.tag_name),
};
}
const isNewVersion =
!checking &&
latestRelease !== undefined &&
latestRelease.tag_name !== ignoredVersion &&
isNewerVersion(latestRelease.tag_name);
return {
latestRelease: undefined,
isNewVersion: false,
latestRelease,
isNewVersion,
ignoreVersion,
};
};
-1
View File
@@ -33,7 +33,6 @@ const SelectableChip: React.FC<SelectableChipProps> = ({
showSelectedCheck={showCheckIcon}
style={styles.chip}
textStyle={{ fontFamily: customFontFamily }}
selectedColor={theme.primary}
theme={{ colors: theme }}
mode={selected ? 'flat' : mode}
>
+18 -1
View File
@@ -4,10 +4,27 @@ import { View, StyleSheet } from 'react-native';
const Row = ({
children,
style = {},
horizontalSpacing,
verticalSpacing,
}: {
children?: React.ReactNode;
style?: any;
}) => <View style={[styles.row, style]}>{children}</View>;
horizontalSpacing?: number | `${number}%`;
verticalSpacing?: number | `${number}%`;
}) => (
<View
style={[
styles.row,
style,
{
paddingHorizontal: horizontalSpacing,
paddingVertical: verticalSpacing,
},
]}
>
{children}
</View>
);
export { Row };
+8 -2
View File
@@ -1,16 +1,19 @@
import React from 'react';
import { Pressable, View, StyleSheet } from 'react-native';
import { View, StyleSheet } from 'react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { ThemeColors } from '../../theme/types';
import Color from 'color';
import { MaterialDesignIconName } from '@type/icon';
import { Pressable } from 'react-native-gesture-handler';
// --- Dynamic style helpers ---
const getToggleButtonPressableStyle = (
selected: boolean,
theme: ThemeColors,
disabled?: boolean,
) => ({
opacity: disabled ? 0.6 : 1,
backgroundColor: selected
? Color(theme.primary).alpha(0.12).string()
: 'transparent',
@@ -24,6 +27,7 @@ interface ToggleButtonProps {
theme: ThemeColors;
color?: string;
onPress: () => void;
disabled?: boolean;
}
export const ToggleButton: React.FC<ToggleButtonProps> = ({
@@ -32,15 +36,17 @@ export const ToggleButton: React.FC<ToggleButtonProps> = ({
theme,
color,
onPress,
disabled,
}) => (
<View style={styles.toggleButtonContainer}>
<Pressable
android_ripple={{ color: theme.rippleColor }}
style={[
styles.toggleButtonPressable,
getToggleButtonPressableStyle(selected, theme),
getToggleButtonPressableStyle(selected, theme, disabled),
]}
onPress={onPress}
disabled={disabled}
>
<MaterialCommunityIcons
name={icon}
@@ -0,0 +1,96 @@
import './mocks';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { ToggleButton } from '../ToggleButton';
// Mock native icon module
jest.mock('@react-native-vector-icons/material-design-icons', () => {
const React = require('react');
const { View } = require('react-native');
const MockIcon = (props: Record<string, unknown>) =>
React.createElement(View, { ...props, testID: 'icon' });
MockIcon.displayName = 'MaterialCommunityIcons';
return { __esModule: true, default: MockIcon };
});
const mockTheme = {
id: 0,
name: 'test',
isDark: false,
primary: '#6200ee',
onPrimary: '#fff',
primaryContainer: '#e8def8',
onPrimaryContainer: '#21005d',
secondary: '#625b71',
onSecondary: '#fff',
secondaryContainer: '#e8def8',
onSecondaryContainer: '#1d192b',
tertiary: '#7d5260',
onTertiary: '#fff',
tertiaryContainer: '#ffd8e4',
onTertiaryContainer: '#31111d',
error: '#f00',
onError: '#fff',
errorContainer: '#f9dedc',
onErrorContainer: '#410e0b',
background: '#fff',
onBackground: '#000',
surface: '#f5f5f5',
onSurface: '#000',
surfaceVariant: '#e8e8e8',
onSurfaceVariant: '#666',
outline: '#ccc',
outlineVariant: '#cac4d0',
shadow: '#000',
scrim: '#000',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
inversePrimary: '#d0bcff',
surfaceDisabled: 'rgba(28, 27, 31, 0.12)',
onSurfaceDisabled: 'rgba(28, 27, 31, 0.38)',
backdrop: 'rgba(0, 0, 0, 0.4)',
rippleColor: 'rgba(0,0,0,0.1)',
};
describe('ToggleButton', () => {
it('renders icon via MaterialCommunityIcons', () => {
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={() => { }} />,
);
expect(screen.getByTestId('icon')).toBeTruthy();
});
it('selected state: icon color is theme.primary', () => {
render(
<ToggleButton icon="cog" selected={true} theme={mockTheme} onPress={() => { }} />,
);
const icon = screen.getByTestId('icon');
expect(icon.props.color).toBe(mockTheme.primary);
});
it('unselected state: icon color is theme.onSurface', () => {
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={() => { }} />,
);
const icon = screen.getByTestId('icon');
expect(icon.props.color).toBe(mockTheme.onSurface);
});
it('calls onPress on press', () => {
const onPress = jest.fn();
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={onPress} />,
);
fireEvent.press(screen.getByTestId('icon'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('disabled: press does not call onPress', () => {
const onPress = jest.fn();
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={onPress} disabled={true} />,
);
fireEvent.press(screen.getByTestId('icon'));
expect(onPress).not.toHaveBeenCalled();
});
});
+30
View File
@@ -0,0 +1,30 @@
jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
jest.mock('@i18n/translations', () => ({
getString: jest.fn(key => key),
}));
jest.mock('@utils/parseChapterNumber', () => ({
parseChapterNumber: jest.fn(() => 1),
}));
jest.mock('@utils/Storages', () => ({
NOVEL_STORAGE: '/mock/storage',
}));
jest.mock('@hooks/persisted/usePlugins');
jest.mock('@hooks/persisted/useTracker');
jest.mock('@hooks/persisted/useDownload');
jest.mock('@hooks/persisted/useUserAgent');
jest.mock('@hooks/persisted/useSettings');
jest.mock('@hooks/persisted/useNovelSettings');
jest.mock('@hooks/persisted/useHistory');
jest.mock('@hooks/persisted/useImport');
jest.mock('@hooks/persisted/useSelfHost');
jest.mock('@hooks/persisted/useTheme');
jest.mock('@hooks/persisted/useTrackedNovel');
jest.mock('@hooks/persisted/useUpdates');
jest.mock('@services/plugin/fetch');
jest.mock('@components/Context/LibraryContext');
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { getString } from '@i18n/translations';
@@ -11,6 +11,7 @@ interface ConfirmationDialogProps {
confirmLabel: string;
cancelLabel?: string;
confirmTone?: DialogActionTone;
confirmFirst?: boolean;
onConfirm: () => void | Promise<void>;
onDismiss: () => void;
}
@@ -22,25 +23,52 @@ const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
confirmLabel,
cancelLabel = getString('common.cancel'),
confirmTone = 'danger',
confirmFirst = false,
onDismiss,
onConfirm,
}) => {
const handleConfirm = () => {
void onConfirm();
onDismiss();
const [isConfirming, setIsConfirming] = useState(false);
const handleConfirm = async () => {
setIsConfirming(true);
try {
await onConfirm();
onDismiss();
} finally {
setIsConfirming(false);
}
};
const cancelAction = (
<Dialog.Action key="cancel" disabled={isConfirming} onPress={onDismiss}>
{cancelLabel}
</Dialog.Action>
);
const confirmAction = (
<Dialog.Action
key="confirm"
disabled={isConfirming}
loading={isConfirming}
tone={confirmTone}
onPress={handleConfirm}
>
{confirmLabel}
</Dialog.Action>
);
return (
<Dialog.Root visible={visible} onDismiss={onDismiss}>
<Dialog.Root
visible={visible}
onDismiss={isConfirming ? () => {} : onDismiss}
>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
{message ? <Dialog.Description>{message}</Dialog.Description> : null}
</Dialog.Header>
<Dialog.Actions>
<Dialog.Action onPress={onDismiss}>{cancelLabel}</Dialog.Action>
<Dialog.Action tone={confirmTone} onPress={handleConfirm}>
{confirmLabel}
</Dialog.Action>
{confirmFirst
? [confirmAction, cancelAction]
: [cancelAction, confirmAction]}
</Dialog.Actions>
</Dialog.Root>
);
@@ -0,0 +1,90 @@
import React from 'react';
import { Pressable, StyleSheet, View, ViewStyle } from 'react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import Color from 'color';
import { ThemeColors } from '../../theme/types';
import { MaterialDesignIconName } from '@type/icon';
import Animated, {
SharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
const AnimatedIcon = Animated.createAnimatedComponent(MaterialCommunityIcons);
type Props = {
name: MaterialDesignIconName;
color?: string;
size?: number;
disabled?: boolean;
padding?: number;
onPress?: () => void;
theme: ThemeColors;
style?: ViewStyle;
rotation?: SharedValue<number>;
scale?: SharedValue<number>;
};
const AnimatedIconButton: React.FC<Props> = ({
name,
color,
size = 24,
padding = 8,
onPress,
disabled,
theme,
style,
rotation,
scale: _scale,
}) => {
const IconStyle = useAnimatedStyle(() => {
const rotate = rotation
? withTiming(rotation.value + 'deg', { duration: 250 })
: '0deg';
const scale = _scale ? withTiming(_scale.value, { duration: 250 }) : 1;
return {
textAlign: 'center',
transform: [
{
rotate,
},
{
scale,
},
],
};
});
return (
<View style={[styles.container, style]}>
<Pressable
style={[styles.pressable, { padding }]}
onPress={onPress}
disabled={disabled}
android_ripple={
onPress
? { color: Color(theme.primary).alpha(0.12).string() }
: undefined
}
>
<AnimatedIcon
name={name}
size={size}
color={disabled ? theme.outline : color || theme.onSurface}
style={IconStyle}
/>
</Pressable>
</View>
);
};
export default React.memo(AnimatedIconButton);
const styles = StyleSheet.create({
container: {
borderRadius: 50,
overflow: 'hidden',
},
pressable: {
padding: 8,
},
});
-67
View File
@@ -1,67 +0,0 @@
import React, { useState } from 'react';
import { StyleSheet, Text, useWindowDimensions } from 'react-native';
import * as Linking from 'expo-linking';
import { ScrollView } from 'react-native-gesture-handler';
import { getString } from '@i18n/translations';
import { useTheme } from '@hooks/persisted';
import { Dialog } from '@components';
interface NewUpdateDialogProps {
newVersion: {
tag_name: string;
body: string;
downloadUrl: string;
};
}
const NewUpdateDialog: React.FC<NewUpdateDialogProps> = ({ newVersion }) => {
const [newUpdateDialog, showNewUpdateDialog] = useState(true);
const theme = useTheme();
const modalHeight = useWindowDimensions().height / 2;
return (
<Dialog.Root
visible={newUpdateDialog}
onDismiss={() => showNewUpdateDialog(false)}
>
<Dialog.Title>
{`${getString('common.newUpdateAvailable')} ${newVersion.tag_name}`}
</Dialog.Title>
<Dialog.ScrollArea>
<ScrollView
contentContainerStyle={styles.scrollContent}
style={{ height: modalHeight }}
>
<Text style={[styles.body, { color: theme.onSurfaceVariant }]}>
{newVersion.body.split('\n').join('\n\n')}
</Text>
</ScrollView>
</Dialog.ScrollArea>
<Dialog.Actions>
<Dialog.Action
title={getString('common.cancel')}
onPress={() => showNewUpdateDialog(false)}
/>
<Dialog.Action
title={getString('common.install')}
onPress={() => Linking.openURL(newVersion.downloadUrl)}
/>
</Dialog.Actions>
</Dialog.Root>
);
};
export default NewUpdateDialog;
const styles = StyleSheet.create({
body: {
fontSize: 15,
fontWeight: '500',
},
scrollContent: {
paddingHorizontal: 24,
},
});
+10 -15
View File
@@ -1,10 +1,10 @@
import React, { useMemo } from 'react';
import { StyleSheet } from 'react-native';
import {
FlashList,
type FlashListProps,
type ListRenderItem,
} from '@shopify/flash-list';
LegendList,
type LegendListProps,
type LegendListRenderItemProps,
} from '@legendapp/list/react-native';
import { NovelItem } from '@plugins/types';
import { NovelInfo } from '../database/types';
import { DisplayModes } from '@screens/library/constants/constants';
@@ -13,14 +13,16 @@ import {
useNovelCoverLayoutValue,
} from './NovelCoverLayoutContext';
export type NovelListRenderItem = ListRenderItem<NovelInfo | NovelItem>;
export type NovelListRenderItem = (
info: LegendListRenderItemProps<NovelInfo | NovelItem>,
) => React.ReactNode;
export type NovelListDataItem = (NovelInfo | NovelItem) & {
completeRow?: number;
};
interface NovelListProps
extends Omit<FlashListProps<NovelInfo | NovelItem>, 'data'> {
extends Omit<LegendListProps<NovelInfo | NovelItem>, 'data'> {
inSource?: boolean;
data: NovelListDataItem[];
}
@@ -52,14 +54,6 @@ export const extendNovelList = (
}
}
extension.push({
id: undefined,
cover: '',
name: '',
path: '__loading-row',
completeRow: 2,
});
return [...data, ...extension];
};
@@ -76,7 +70,7 @@ const NovelList: React.FC<NovelListProps> = props => {
return (
<NovelCoverLayoutProvider value={layout}>
<FlashList
<LegendList
contentContainerStyle={[
!isListView && styles.listView,
styles.flatListCont,
@@ -84,6 +78,7 @@ const NovelList: React.FC<NovelListProps> = props => {
numColumns={numColumns}
key={numColumns}
keyExtractor={novelListKeyExtractor}
estimatedItemSize={layout.coverHeight}
{...listProps}
data={extendedNovelList}
/>
+12 -1
View File
@@ -25,6 +25,8 @@ const THUMB_OFFSET_ON =
interface SwitchProps {
accessible?: boolean;
accessibilityLabel?: string;
containerStyle?: StyleProp<ViewStyle>;
value: boolean;
onValueChange?: () => void;
style?: StyleProp<ViewStyle>;
@@ -32,6 +34,8 @@ interface SwitchProps {
const Switch = ({
accessible = true,
accessibilityLabel,
containerStyle,
value,
onValueChange,
style,
@@ -110,7 +114,14 @@ const Switch = ({
}));
return (
<Pressable accessible={accessible} onPress={onValueChange}>
<Pressable
accessible={accessible}
accessibilityLabel={accessibilityLabel}
accessibilityRole={accessible ? 'switch' : undefined}
accessibilityState={accessible ? { checked: value } : undefined}
style={containerStyle}
onPress={onValueChange}
>
<Animated.View
style={[styles.track, style, trackColorStyle, trackBorderStyle]}
>
+6 -1
View File
@@ -14,7 +14,9 @@ interface SwitchItemProps {
value: boolean;
label: string;
description?: string;
descriptionNumberOfLines?: number;
onPress: () => void;
onLongPress?: () => void;
theme: ThemeColors;
style?: StyleProp<ViewStyle>;
}
@@ -22,7 +24,9 @@ interface SwitchItemProps {
const SwitchItem: React.FC<SwitchItemProps> = ({
label,
description,
descriptionNumberOfLines,
onPress,
onLongPress,
theme,
value,
style,
@@ -34,11 +38,12 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
android_ripple={{ color: theme.rippleColor }}
style={[styles.container, style]}
onPress={onPress}
onLongPress={onLongPress}
>
<View style={styles.labelContainer}>
<Text style={[{ color: theme.onSurface }, styles.label]}>{label}</Text>
{description ? (
<Text style={[styles.description, { color: theme.onSurfaceVariant }]}>
<Text numberOfLines={descriptionNumberOfLines} style={[styles.description, { color: theme.onSurfaceVariant }]}>
{description}
</Text>
) : null}
@@ -0,0 +1,122 @@
import './mocks';
import { render, screen, fireEvent } from '@testing-library/react-native';
import SwitchItem from '../SwitchItem';
// Mock reanimated — setUpTests in global setup may have failed.
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
return {
useSharedValue: (init: unknown) => ({ value: init }),
useAnimatedStyle: (fn: () => Record<string, unknown>) => fn(),
useDerivedValue: (fn: () => unknown) => fn(),
withTiming: (val: unknown) => val,
withSpring: (val: unknown) => val,
interpolateColor: () => 'transparent',
createAnimatedComponent: (c: unknown) => c,
default: { View },
__esModule: true,
};
});
const mockUseTheme = jest.fn();
jest.mock('@hooks/persisted', () => ({
useTheme: () => mockUseTheme(),
}));
const mockTheme = {
id: 0,
name: 'test',
isDark: false,
primary: '#6200ee',
onPrimary: '#fff',
primaryContainer: '#e8def8',
onPrimaryContainer: '#21005d',
secondary: '#625b71',
onSecondary: '#fff',
secondaryContainer: '#e8def8',
onSecondaryContainer: '#1d192b',
tertiary: '#7d5260',
onTertiary: '#fff',
tertiaryContainer: '#ffd8e4',
onTertiaryContainer: '#31111d',
error: '#f00',
onError: '#fff',
errorContainer: '#f9dedc',
onErrorContainer: '#410e0b',
background: '#fff',
onBackground: '#000',
surface: '#f5f5f5',
onSurface: '#000',
surfaceVariant: '#e8e8e8',
onSurfaceVariant: '#666',
outline: '#ccc',
outlineVariant: '#cac4d0',
shadow: '#000',
scrim: '#000',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
inversePrimary: '#d0bcff',
surfaceDisabled: 'rgba(28, 27, 31, 0.12)',
onSurfaceDisabled: 'rgba(28, 27, 31, 0.38)',
backdrop: 'rgba(0, 0, 0, 0.4)',
rippleColor: 'rgba(0,0,0,0.1)',
};
describe('SwitchItem', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseTheme.mockReturnValue(mockTheme);
});
it('renders label text', () => {
render(<SwitchItem label="Test Label" value={false} onPress={() => { }} theme={mockTheme} />);
expect(screen.getByText('Test Label')).toBeTruthy();
});
it('renders description when provided', () => {
render(
<SwitchItem
label="Test"
description="A helpful description"
value={false}
onPress={() => { }}
theme={mockTheme}
/>,
);
expect(screen.getByText('A helpful description')).toBeTruthy();
});
it('does not render description when omitted', () => {
render(<SwitchItem label="Test" value={false} onPress={() => { }} theme={mockTheme} />);
expect(screen.queryByText('A helpful description')).toBeNull();
});
it('calls onPress on press', () => {
const onPress = jest.fn();
render(<SwitchItem label="Pressable" value={false} onPress={onPress} theme={mockTheme} />);
fireEvent.press(screen.getByText('Pressable'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('calls onLongPress on long press', () => {
const onLongPress = jest.fn();
render(
<SwitchItem
label="Long Press"
value={false}
onPress={() => { }}
onLongPress={onLongPress}
theme={mockTheme}
/>,
);
fireEvent(screen.getByText('Long Press'), 'onLongPress');
expect(onLongPress).toHaveBeenCalledTimes(1);
});
it('has correct accessibility role and label', () => {
render(<SwitchItem label="Accessible" value={false} onPress={() => { }} theme={mockTheme} />);
const element = screen.getByRole('switch', { name: 'Accessible' });
expect(element).toBeTruthy();
});
});
+30
View File
@@ -0,0 +1,30 @@
jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
jest.mock('@i18n/translations', () => ({
getString: jest.fn(key => key),
}));
jest.mock('@utils/parseChapterNumber', () => ({
parseChapterNumber: jest.fn(() => 1),
}));
jest.mock('@utils/Storages', () => ({
NOVEL_STORAGE: '/mock/storage',
}));
jest.mock('@hooks/persisted/usePlugins');
jest.mock('@hooks/persisted/useTracker');
jest.mock('@hooks/persisted/useDownload');
jest.mock('@hooks/persisted/useUserAgent');
jest.mock('@hooks/persisted/useSettings');
jest.mock('@hooks/persisted/useNovelSettings');
jest.mock('@hooks/persisted/useHistory');
jest.mock('@hooks/persisted/useImport');
jest.mock('@hooks/persisted/useSelfHost');
jest.mock('@hooks/persisted/useTheme');
jest.mock('@hooks/persisted/useTrackedNovel');
jest.mock('@hooks/persisted/useUpdates');
jest.mock('@services/plugin/fetch');
jest.mock('@components/Context/LibraryContext');
@@ -0,0 +1,132 @@
jest.mock('react-native-gesture-handler', () => {
const RN = require('react-native');
const View = RN.View || require('react-native').View;
return {
TextInput: RN.TextInput,
GestureHandlerRootView: View,
Swipeable: View,
Pressable: RN.Pressable || View,
State: { BEGAN: 0, ACTIVE: 1, END: 2 },
PanGestureHandler: View,
NativeViewGestureHandler: View,
TapGestureHandler: View,
LongPressGestureHandler: View,
PinchGestureHandler: View,
RotationGestureHandler: View,
FlingGestureHandler: View,
Directions: { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8 },
gestureHandlerRootHOC: (c: unknown) => c,
createNativeWrapper: (c: unknown) => c,
// Default export used by some imports
default: {
TextInput: RN.TextInput,
GestureHandlerRootView: View,
},
};
});
import './mocks';
import { render, screen, fireEvent } from '@testing-library/react-native';
import TextInput from '../index';
const mockUseTheme = jest.fn();
jest.mock('@hooks/persisted', () => ({
useTheme: () => mockUseTheme(),
}));
const mockTheme = {
primary: '#6200ee',
onSurface: '#000',
onSurfaceVariant: '#666',
surfaceVariant: '#e8e8e8',
rippleColor: 'rgba(0,0,0,0.1)',
outline: '#ccc',
error: '#f00',
background: '#fff',
surface: '#f5f5f5',
onPrimary: '#fff',
onBackground: '#000',
};
describe('TextInput', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseTheme.mockReturnValue(mockTheme);
});
it('renders with placeholder', () => {
render(<TextInput placeholder="Enter text" />);
expect(screen.getByPlaceholderText('Enter text')).toBeTruthy();
});
it('on focus: border color changes to theme.primary, border width 2', () => {
render(<TextInput placeholder="Test" />);
const input = screen.getByPlaceholderText('Test');
fireEvent(input, 'focus');
// The style array contains the dynamic style object as first element
const styleArray = input.props.style;
expect(styleArray[0].borderColor).toBe(mockTheme.primary);
expect(styleArray[0].borderWidth).toBe(2);
});
it('on blur: border color reverts to theme.outline, border width 1', () => {
render(<TextInput placeholder="Test" />);
const input = screen.getByPlaceholderText('Test');
fireEvent(input, 'focus');
fireEvent(input, 'blur');
const styleArray = input.props.style;
expect(styleArray[0].borderColor).toBe(mockTheme.outline);
expect(styleArray[0].borderWidth).toBe(1);
});
it('error state: border color is theme.error, border width 2 regardless of focus', () => {
render(<TextInput placeholder="Test" error />);
const input = screen.getByPlaceholderText('Test');
const styleArray = input.props.style;
expect(styleArray[0].borderColor).toBe(mockTheme.error);
expect(styleArray[0].borderWidth).toBe(2);
// Blur should not change error styling
fireEvent(input, 'blur');
expect(styleArray[0].borderColor).toBe(mockTheme.error);
expect(styleArray[0].borderWidth).toBe(2);
});
it('forceFocused prop: shows focused styling even without actual focus', () => {
render(<TextInput placeholder="Test" forceFocused />);
const input = screen.getByPlaceholderText('Test');
const styleArray = input.props.style;
expect(styleArray[0].borderColor).toBe(mockTheme.primary);
expect(styleArray[0].borderWidth).toBe(2);
// Blur should not change forceFocused styling
fireEvent(input, 'blur');
expect(styleArray[0].borderColor).toBe(mockTheme.primary);
});
it('merges custom style prop with default styles', () => {
render(<TextInput placeholder="Test" style={{ fontSize: 20 }} />);
const input = screen.getByPlaceholderText('Test');
const styleArray = input.props.style;
// Default textInput style (index 1) should have inherited styles
// Custom style is last (index 2)
expect(styleArray[2]).toEqual({ fontSize: 20 });
});
it('passes through other TextInput props (onChangeText)', () => {
const onChangeText = jest.fn();
render(<TextInput placeholder="Test" onChangeText={onChangeText} />);
const input = screen.getByPlaceholderText('Test');
fireEvent.changeText(input, 'new text');
expect(onChangeText).toHaveBeenCalledWith('new text');
});
});
@@ -0,0 +1,30 @@
jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
jest.mock('@i18n/translations', () => ({
getString: jest.fn(key => key),
}));
jest.mock('@utils/parseChapterNumber', () => ({
parseChapterNumber: jest.fn(() => 1),
}));
jest.mock('@utils/Storages', () => ({
NOVEL_STORAGE: '/mock/storage',
}));
jest.mock('@hooks/persisted/usePlugins');
jest.mock('@hooks/persisted/useTracker');
jest.mock('@hooks/persisted/useDownload');
jest.mock('@hooks/persisted/useUserAgent');
jest.mock('@hooks/persisted/useSettings');
jest.mock('@hooks/persisted/useNovelSettings');
jest.mock('@hooks/persisted/useHistory');
jest.mock('@hooks/persisted/useImport');
jest.mock('@hooks/persisted/useSelfHost');
jest.mock('@hooks/persisted/useTheme');
jest.mock('@hooks/persisted/useTrackedNovel');
jest.mock('@hooks/persisted/useUpdates');
jest.mock('@services/plugin/fetch');
jest.mock('@components/Context/LibraryContext');
+71
View File
@@ -0,0 +1,71 @@
import { useTheme } from '@hooks/persisted';
import { useState } from 'react';
import { StyleSheet, TextInputProps as RNTextInputProps } from 'react-native';
import { TextInput as RNTextInput } from 'react-native-gesture-handler';
interface TextInputProps extends RNTextInputProps {
error?: boolean;
value?: never;
forceFocused?: boolean;
}
const TextInput = ({
onBlur,
onFocus,
error,
forceFocused,
style,
...props
}: TextInputProps) => {
const theme = useTheme();
const [inputFocused, setInputFocused] = useState(false);
const _onFocus: RNTextInputProps['onFocus'] = e => {
setInputFocused(true);
onFocus?.(e);
};
const _onBlur: RNTextInputProps['onBlur'] = e => {
setInputFocused(false);
onBlur?.(e);
};
const isFocused = forceFocused ?? inputFocused;
const borderWidth = isFocused || error ? 2 : 1;
const margin = isFocused || error ? 0 : 1;
return (
<RNTextInput
placeholderTextColor={'grey'}
onFocus={_onFocus}
onBlur={_onBlur}
style={[
{
color: theme.onBackground,
backgroundColor: theme.background,
borderColor: error
? theme.error
: isFocused
? theme.primary
: theme.outline,
borderWidth: borderWidth,
margin: margin,
},
styles.textInput,
style,
]}
{...props}
/>
);
};
export default TextInput;
const styles = StyleSheet.create({
textInput: {
borderRadius: 4,
borderStyle: 'solid',
fontSize: 16,
paddingHorizontal: 16,
paddingVertical: 10,
},
});
+37 -3
View File
@@ -1,5 +1,10 @@
import './mocks';
import { fireEvent, render, screen } from '@testing-library/react-native';
import {
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react-native';
import { Dialog } from '../Dialog';
import ConfirmationDialog from '../ConfirmationDialog/ConfirmationDialog';
@@ -104,7 +109,7 @@ describe('Dialog', () => {
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('runs and dismisses a confirmation action', () => {
it('runs and dismisses a confirmation action', async () => {
const onConfirm = jest.fn();
const onDismiss = jest.fn();
@@ -121,6 +126,35 @@ describe('Dialog', () => {
fireEvent.press(screen.getByText('Delete'));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onDismiss).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1));
});
it('waits for an async confirmation action before dismissing', async () => {
let resolveConfirmation: () => void = () => {};
const onConfirm = jest.fn(
() =>
new Promise<void>(resolve => {
resolveConfirmation = resolve;
}),
);
const onDismiss = jest.fn();
render(
<ConfirmationDialog
title="Delete item?"
confirmLabel="Delete"
visible
onConfirm={onConfirm}
onDismiss={onDismiss}
/>,
);
fireEvent.press(screen.getByText('Delete'));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onDismiss).not.toHaveBeenCalled();
resolveConfirmation();
await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1));
});
});
+2 -3
View File
@@ -6,8 +6,8 @@ import {
type NovelListDataItem,
} from '../NovelList';
jest.mock('@shopify/flash-list', () => ({
FlashList: () => null,
jest.mock('@legendapp/list/react-native', () => ({
LegendList: () => null,
}));
jest.mock('@hooks', () => ({
@@ -59,7 +59,6 @@ describe('NovelList helpers', () => {
'/two',
'__loading-filler-0',
'__loading-filler-1',
'__loading-row',
]);
});
});
+3
View File
@@ -24,6 +24,7 @@ export type {
} from './Dialog';
export { default as ConfirmationDialog } from './ConfirmationDialog/ConfirmationDialog';
export { default as SafeAreaView } from './SafeAreaView/SafeAreaView';
export { default as AppUpdateChecker } from './AppUpdateChecker';
export { SegmentedControl } from './SegmentedControl';
export type {
SegmentedControlOption,
@@ -32,5 +33,7 @@ export type {
export { default as BottomTabBar } from './BottomTabBar';
export { default as TopTabBar } from './TopTabBar/TopTabBar';
export { default as Menu } from './Menu';
export { default as AnimatedIconButton } from './IconButtonV2/AnimatedIconButton';
export { default as TextInput } from './TextInput';
export { default as Slider } from './Slider/Slider';
export type { SliderProps, SliderSize } from './Slider/Slider';
+665
View File
@@ -3,10 +3,18 @@ import { drizzle } from 'drizzle-orm/op-sqlite';
import { migrate } from 'drizzle-orm/op-sqlite/migrator';
import migrations from '../../../drizzle/migrations';
import { schema } from '@database/schema';
import {
createCategoryTriggerQuery,
createNovelTriggerQueryDelete,
createNovelTriggerQueryInsert,
createNovelTriggerQueryUpdate,
} from '@database/queryStrings/triggers';
import {
getPendingMigrations,
repairChapterMigrationSnapshot,
repairInterruptedNovelMigration,
repairInterruptedNovelSnapshot,
repairMigrationHistory,
runDatabaseBootstrap,
} from '@database/db';
@@ -88,6 +96,36 @@ const LEGACY_NOVEL_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS Novel (
totalPages integer DEFAULT 0
)`;
// Pre-drizzle Chapter kept by `past_mandrill` via `IF NOT EXISTS`: it carries
// the FOREIGN KEY that makes the interrupted-migration copy-back fail.
const LEGACY_CHAPTER_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS Chapter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
novelId INTEGER NOT NULL,
path TEXT NOT NULL,
name TEXT NOT NULL,
releaseTime TEXT,
bookmark INTEGER DEFAULT 0,
unread INTEGER DEFAULT 1,
readTime TEXT,
isDownloaded INTEGER DEFAULT 0,
updatedTime TEXT,
chapterNumber REAL NULL,
page TEXT DEFAULT "1",
position INTEGER DEFAULT 0,
progress INTEGER,
UNIQUE(path, novelId),
FOREIGN KEY (novelId) REFERENCES Novel(id) ON DELETE CASCADE
)`;
const LEGACY_NOVEL_CATEGORY_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS NovelCategory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
novelId INTEGER NOT NULL,
categoryId INTEGER NOT NULL,
UNIQUE(novelId, categoryId),
FOREIGN KEY (novelId) REFERENCES Novel(id) ON DELETE CASCADE,
FOREIGN KEY (categoryId) REFERENCES Category(id) ON DELETE CASCADE
)`;
const createSchema = (sqlite: DB, legacyNovel = false) => {
for (const statement of MIGRATION_STATEMENTS) {
sqlite.executeSync(
@@ -98,6 +136,66 @@ const createSchema = (sqlite: DB, legacyNovel = false) => {
}
};
const createLegacySchema = (sqlite: DB) => {
sqlite.executeSync(`CREATE TABLE IF NOT EXISTS Category (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
name text NOT NULL,
sort integer
)`);
sqlite.executeSync(
'CREATE UNIQUE INDEX IF NOT EXISTS category_name_unique ON Category (name)',
);
sqlite.executeSync(
'CREATE INDEX IF NOT EXISTS category_sort_idx ON Category (sort)',
);
sqlite.executeSync(LEGACY_NOVEL_TABLE_STATEMENT);
sqlite.executeSync(
'CREATE UNIQUE INDEX IF NOT EXISTS novel_path_plugin_unique ON Novel (path, pluginId)',
);
sqlite.executeSync(LEGACY_CHAPTER_TABLE_STATEMENT);
sqlite.executeSync(
'CREATE UNIQUE INDEX IF NOT EXISTS chapter_novel_path_unique ON Chapter (novelId, path)',
);
sqlite.executeSync(
'CREATE INDEX IF NOT EXISTS chapterNovelIdIndex ON Chapter (novelId, position, page, id)',
);
sqlite.executeSync(LEGACY_NOVEL_CATEGORY_TABLE_STATEMENT);
sqlite.executeSync(
'CREATE UNIQUE INDEX IF NOT EXISTS novel_category_unique ON NovelCategory (novelId, categoryId)',
);
sqlite.executeSync(`CREATE TABLE IF NOT EXISTS Repository (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
url text NOT NULL
)`);
sqlite.executeSync(
'CREATE UNIQUE INDEX IF NOT EXISTS repository_url_unique ON Repository (url)',
);
};
const recordAppliedMigrations = (sqlite: DB) => {
sqlite.executeSync(`
CREATE TABLE __drizzle_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
hash text NOT NULL,
created_at numeric,
name text,
applied_at text
)
`);
const applied = [
['', 1766417172000, '20251222152612_past_mandrill'],
['', 1781306602000, '20260612232322_normal_saracen'],
['', 1784471667000, '20260719143427_long_moondragon'],
] as const;
for (const [hash, createdAt, name] of applied) {
sqlite.executeSync(
`INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
VALUES (?, ?, ?, datetime('now'))`,
[hash, createdAt, name],
);
}
};
const createExecutor = (sqlite: DB) => ({
executeSync: (sql: string, params?: unknown[]) => {
sqlite.executeSync(sql, params as any[]);
@@ -194,6 +292,43 @@ describe('runDatabaseBootstrap', () => {
});
describe('production migrations', () => {
it('rebuilds novel counters with one scan of the Chapter snapshot', () => {
const sqlite = open({ name: ':memory:' });
try {
createSchema(sqlite);
const statements = migrations.migrations['20260727081855_calm_chimera']
.split('--> statement-breakpoint')
.map((statement: string) => statement.trim())
.filter(Boolean);
const counterStatementIndex = statements.findIndex((statement: string) =>
statement.startsWith('INSERT INTO `__new_Novel`'),
);
expect(counterStatementIndex).toBeGreaterThan(0);
for (const statement of statements.slice(0, counterStatementIndex)) {
sqlite.executeSync(statement);
}
const queryPlan = sqlite
.executeRawSync(
`EXPLAIN QUERY PLAN ${statements[counterStatementIndex]}`,
)
.map(row => String(row[3]));
expect(
queryPlan.filter(detail => detail.includes('SCAN __migration_Chapter')),
).toHaveLength(1);
expect(queryPlan).not.toEqual(
expect.arrayContaining([
expect.stringContaining('CORRELATED SCALAR SUBQUERY'),
]),
);
} finally {
sqlite.close();
}
});
it('can run after test schema exists', async () => {
const sqlite = open({ name: ':memory:' });
(sqlite as any).executeAsync ??= sqlite.execute;
@@ -235,6 +370,10 @@ describe('production migrations', () => {
INSERT INTO NovelCategory (id, novelId, categoryId)
VALUES (1, 1, 1)
`);
sqlite.executeSync(`
INSERT INTO Repository (id, url)
VALUES (1, 'https://example.com/plugins.min.json')
`);
const drizzleDb = drizzle(sqlite, { schema });
await migrate(drizzleDb, getPendingMigrations(sqlite));
@@ -273,6 +412,15 @@ describe('production migrations', () => {
sqlite.executeSync('SELECT id, novelId, categoryId FROM NovelCategory')
.rows,
).toEqual([{ id: 1, novelId: 1, categoryId: 1 }]);
expect(
sqlite.executeSync('SELECT id, url, enabled FROM Repository').rows,
).toEqual([
{
id: 1,
url: 'https://example.com/plugins.min.json',
enabled: 1,
},
]);
} finally {
sqlite.close();
}
@@ -464,6 +612,126 @@ describe('production migrations', () => {
}
});
it('repairs an interrupted Chapter snapshot missing recent columns', async () => {
const sqlite = open({ name: ':memory:' });
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
createSchema(sqlite);
sqlite.executeSync(`
INSERT INTO Category (id, name, sort)
VALUES (1, 'Existing category', 1)
`);
sqlite.executeSync(`
INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (1, '/interrupted', 'legacy-plugin', 'Interrupted novel', 1)
`);
sqlite.executeSync(`
INSERT INTO Chapter (
id,
novelId,
path,
name,
unread,
isDownloaded
)
VALUES (
1,
1,
'/interrupted/chapter-1',
'Chapter 1',
1,
1
)
`);
sqlite.executeSync(`
INSERT INTO NovelCategory (id, novelId, categoryId)
VALUES (1, 1, 1)
`);
sqlite.executeSync(
'CREATE TABLE __migration_Chapter AS SELECT * FROM Chapter',
);
sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text');
sqlite.executeSync('ALTER TABLE Chapter ADD timeSpent integer DEFAULT 0');
sqlite.executeSync(
'CREATE TABLE __migration_Novel AS SELECT * FROM Novel',
);
sqlite.executeSync(
'CREATE TABLE __migration_NovelCategory AS SELECT * FROM NovelCategory',
);
sqlite.executeSync(`
CREATE TABLE __drizzle_migrations (
id INTEGER PRIMARY KEY,
hash text NOT NULL,
created_at numeric,
name text,
applied_at text
)
`);
sqlite.executeSync(`
INSERT INTO __drizzle_migrations (hash, created_at, name)
VALUES ('', 1766417172000, NULL)
`);
repairChapterMigrationSnapshot(sqlite);
repairMigrationHistory(sqlite);
const snapshotColumns = sqlite
.executeRawSync('PRAGMA table_info(__migration_Chapter)')
.map(column => column[1]);
expect(snapshotColumns).toEqual(
expect.arrayContaining(['scanlator', 'timeSpent']),
);
const drizzleDb = drizzle(sqlite, { schema });
await migrate(drizzleDb, getPendingMigrations(sqlite));
expect(
sqlite.executeSync('SELECT id, name, scanlator, timeSpent FROM Chapter')
.rows,
).toEqual([
{
id: 1,
name: 'Chapter 1',
scanlator: null,
timeSpent: 0,
},
]);
} finally {
sqlite.close();
}
});
it('preserves snapshot columns while adding a missing timeSpent column', () => {
const sqlite = open({ name: ':memory:' });
try {
createSchema(sqlite);
sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text');
sqlite.executeSync(`
INSERT INTO Novel (id, path, pluginId, name)
VALUES (1, '/snapshot', 'legacy-plugin', 'Snapshot novel')
`);
sqlite.executeSync(`
INSERT INTO Chapter (id, novelId, path, name, scanlator)
VALUES (1, 1, '/snapshot/chapter-1', 'Chapter 1', 'Group')
`);
sqlite.executeSync(
'CREATE TABLE __migration_Chapter AS SELECT * FROM Chapter',
);
repairChapterMigrationSnapshot(sqlite);
expect(
sqlite.executeSync(
'SELECT id, scanlator, timeSpent FROM __migration_Chapter',
).rows,
).toEqual([{ id: 1, scanlator: 'Group', timeSpent: 0 }]);
} finally {
sqlite.close();
}
});
it('recovers when Chapter columns exist without migration records', async () => {
const sqlite = open({ name: ':memory:' });
(sqlite as any).executeAsync ??= sqlite.execute;
@@ -519,9 +787,406 @@ describe('production migrations', () => {
'20260612232322_normal_saracen',
'20260719143427_long_moondragon',
'20260727081855_calm_chimera',
'20260811071655_parched_human_torch',
]);
} finally {
sqlite.close();
}
});
const calmChimeraStatements = () =>
migrations.migrations['20260727081855_calm_chimera']
.split('--> statement-breakpoint')
.map((statement: string) => statement.trim())
.filter(Boolean);
// Statements 0-3 drop triggers and snapshot Novel. An app killed after them
// (before the Chapter snapshot statement) leaves `__migration_Novel` on disk
// while `Novel` and `Chapter` stay live and keep accepting writes.
const crashAfterNovelSnapshot = (sqlite: DB) => {
for (const statement of calmChimeraStatements().slice(0, 4)) {
sqlite.executeSync(statement);
}
};
const runRecovery = async (sqlite: DB) => {
repairInterruptedNovelMigration(sqlite);
repairChapterMigrationSnapshot(sqlite);
repairMigrationHistory(sqlite);
repairInterruptedNovelSnapshot(sqlite);
const drizzleDb = drizzle(sqlite, { schema });
await migrate(drizzleDb, getPendingMigrations(sqlite));
};
it('recovers when novels were added while the migration was interrupted', async () => {
const sqlite = open({ name: ':memory:' });
sqlite.executeSync('PRAGMA foreign_keys = ON');
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
createLegacySchema(sqlite);
sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text');
sqlite.executeSync('ALTER TABLE Chapter ADD timeSpent integer DEFAULT 0');
for (const novelId of [1, 2]) {
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (${novelId}, '/novel-${novelId}', 'plugin-${novelId}', 'Novel ${novelId}', 1)`,
);
sqlite.executeSync(
`INSERT INTO Chapter (id, novelId, path, name, unread, isDownloaded, readTime, updatedTime)
VALUES (${
(novelId - 1) * 3 + 1
}, ${novelId}, '/novel-${novelId}/chapter-1', 'Chapter 1', 1, 1, '2026-07-26T10:00:00.000Z', '2026-07-26T11:00:00.000Z')`,
);
}
sqlite.executeSync(
`INSERT INTO Category (id, name, sort) VALUES (1, 'Default', 1)`,
);
sqlite.executeSync(
`INSERT INTO NovelCategory (id, novelId, categoryId) VALUES (1, 1, 1)`,
);
recordAppliedMigrations(sqlite);
crashAfterNovelSnapshot(sqlite);
// The app kept running between attempts: a novel and its chapter are
// added to the live tables after the Novel snapshot went stale.
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (3, '/novel-3', 'plugin-3', 'Novel 3', 1)`,
);
sqlite.executeSync(
`INSERT INTO Chapter (id, novelId, path, name, unread, isDownloaded, readTime, updatedTime)
VALUES (7, 3, '/novel-3/chapter-1', 'Chapter 3.1', 1, 0, '2026-07-27T10:00:00.000Z', '2026-07-27T11:00:00.000Z')`,
);
// Legacy Chapter still enforces `FOREIGN KEY (novelId) REFERENCES Novel`,
// so the copy-back fails unless the Novel snapshot is backfilled first.
await runRecovery(sqlite);
expect(
sqlite.executeSync('SELECT id, name FROM Novel ORDER BY id').rows,
).toEqual([
{ id: 1, name: 'Novel 1' },
{ id: 2, name: 'Novel 2' },
{ id: 3, name: 'Novel 3' },
]);
expect(
sqlite.executeSync('SELECT id, novelId, name FROM Chapter ORDER BY id')
.rows,
).toEqual([
{ id: 1, novelId: 1, name: 'Chapter 1' },
{ id: 4, novelId: 2, name: 'Chapter 1' },
{ id: 7, novelId: 3, name: 'Chapter 3.1' },
]);
expect(sqlite.executeRawSync('PRAGMA foreign_key_check;')).toEqual([]);
} finally {
sqlite.close();
}
});
it('preserves novels added while the migration was interrupted (no legacy FK)', async () => {
const sqlite = open({ name: ':memory:' });
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
createSchema(sqlite);
sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text');
sqlite.executeSync('ALTER TABLE Chapter ADD timeSpent integer DEFAULT 0');
for (const novelId of [1, 2]) {
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (${novelId}, '/novel-${novelId}', 'plugin-${novelId}', 'Novel ${novelId}', 1)`,
);
sqlite.executeSync(
`INSERT INTO Chapter (id, novelId, path, name, unread, isDownloaded)
VALUES (${
(novelId - 1) * 3 + 1
}, ${novelId}, '/novel-${novelId}/chapter-1', 'Chapter 1', 1, 1)`,
);
}
recordAppliedMigrations(sqlite);
crashAfterNovelSnapshot(sqlite);
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (3, '/novel-3', 'plugin-3', 'Novel 3', 1)`,
);
sqlite.executeSync(
`INSERT INTO Chapter (id, novelId, path, name, unread, isDownloaded)
VALUES (7, 3, '/novel-3/chapter-1', 'Chapter 3.1', 1, 0)`,
);
await runRecovery(sqlite);
expect(
sqlite.executeSync('SELECT id, name FROM Novel ORDER BY id').rows,
).toEqual([
{ id: 1, name: 'Novel 1' },
{ id: 2, name: 'Novel 2' },
{ id: 3, name: 'Novel 3' },
]);
expect(
sqlite.executeSync('SELECT id, novelId FROM Chapter ORDER BY id').rows,
).toEqual([
{ id: 1, novelId: 1 },
{ id: 4, novelId: 2 },
{ id: 7, novelId: 3 },
]);
} finally {
sqlite.close();
}
});
it('recovers when NovelCategory rows were added while the migration was interrupted', async () => {
const sqlite = open({ name: ':memory:' });
sqlite.executeSync('PRAGMA foreign_keys = ON');
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
createLegacySchema(sqlite);
sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text');
sqlite.executeSync('ALTER TABLE Chapter ADD timeSpent integer DEFAULT 0');
for (const novelId of [1, 2]) {
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (${novelId}, '/novel-${novelId}', 'plugin-${novelId}', 'Novel ${novelId}', 1)`,
);
}
sqlite.executeSync(
`INSERT INTO Category (id, name, sort) VALUES (1, 'Default', 1)`,
);
sqlite.executeSync(
`INSERT INTO NovelCategory (id, novelId, categoryId) VALUES (1, 1, 1)`,
);
recordAppliedMigrations(sqlite);
crashAfterNovelSnapshot(sqlite);
// Only the NovelCategory table gains a row for the new novel; the
// Chapter snapshot stays consistent, so the Chapter copy-back succeeds
// and the NovelCategory copy-back is the one that would violate the FK.
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (3, '/novel-3', 'plugin-3', 'Novel 3', 1)`,
);
sqlite.executeSync(
`INSERT INTO NovelCategory (id, novelId, categoryId) VALUES (2, 3, 1)`,
);
await runRecovery(sqlite);
expect(
sqlite.executeSync('SELECT id, name FROM Novel ORDER BY id').rows,
).toEqual([
{ id: 1, name: 'Novel 1' },
{ id: 2, name: 'Novel 2' },
{ id: 3, name: 'Novel 3' },
]);
expect(
sqlite.executeSync(
'SELECT novelId, categoryId FROM NovelCategory ORDER BY id',
).rows,
).toEqual([
{ novelId: 1, categoryId: 1 },
{ novelId: 3, categoryId: 1 },
]);
} finally {
sqlite.close();
}
});
it('unsticks a device whose previous attempt already crashed the migration', async () => {
const sqlite = open({ name: ':memory:' });
sqlite.executeSync('PRAGMA foreign_keys = ON');
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
createLegacySchema(sqlite);
sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text');
sqlite.executeSync('ALTER TABLE Chapter ADD timeSpent integer DEFAULT 0');
for (const novelId of [1, 2]) {
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (${novelId}, '/novel-${novelId}', 'plugin-${novelId}', 'Novel ${novelId}', 1)`,
);
sqlite.executeSync(
`INSERT INTO Chapter (id, novelId, path, name, unread, isDownloaded, readTime, updatedTime)
VALUES (${
(novelId - 1) * 3 + 1
}, ${novelId}, '/novel-${novelId}/chapter-1', 'Chapter 1', 1, 1, '2026-07-26T10:00:00.000Z', '2026-07-26T11:00:00.000Z')`,
);
}
sqlite.executeSync(
`INSERT INTO Category (id, name, sort) VALUES (1, 'Default', 1)`,
);
sqlite.executeSync(
`INSERT INTO NovelCategory (id, novelId, categoryId) VALUES (1, 1, 1)`,
);
recordAppliedMigrations(sqlite);
crashAfterNovelSnapshot(sqlite);
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (3, '/novel-3', 'plugin-3', 'Novel 3', 1)`,
);
sqlite.executeSync(
`INSERT INTO Chapter (id, novelId, path, name, unread, isDownloaded)
VALUES (7, 3, '/novel-3/chapter-1', 'Chapter 3.1', 1, 0)`,
);
sqlite.executeSync(
`INSERT INTO NovelCategory (id, novelId, categoryId) VALUES (2, 3, 1)`,
);
// A build without transactional migration runs the statements directly
// and crashes at the Chapter copy-back, persisting the partial state.
const statements = calmChimeraStatements();
let crashError: unknown;
let crashStatement: string | undefined;
for (let index = 0; index < 14; index += 1) {
try {
sqlite.executeSync(statements[index]);
} catch (error) {
crashError = error;
crashStatement = statements[index];
break;
}
}
expect(String(crashError)).toMatch(/FOREIGN KEY/i);
expect(crashStatement).toContain('INSERT OR REPLACE INTO `Chapter`');
expect(
sqlite.executeSync('SELECT COUNT(*) AS count FROM Chapter').rows,
).toEqual([{ count: 0 }]);
expect(
sqlite.executeSync('SELECT id FROM Novel ORDER BY id').rows,
).toEqual([{ id: 1 }, { id: 2 }]);
expect(
sqlite.executeSync('SELECT COUNT(*) AS count FROM __migration_Chapter')
.rows,
).toEqual([{ count: 3 }]);
// Every later launch re-runs the migration and re-crashes: the app is
// locked out until the repairs below unstick it.
const lockedDrizzleDb = drizzle(sqlite, { schema });
await expect(
migrate(lockedDrizzleDb, getPendingMigrations(sqlite)),
).rejects.toThrow();
await runRecovery(sqlite);
expect(
sqlite.executeSync('SELECT id, name FROM Novel ORDER BY id').rows,
).toEqual([
{ id: 1, name: 'Novel 1' },
{ id: 2, name: 'Novel 2' },
]);
expect(
sqlite.executeSync('SELECT id, novelId, name FROM Chapter ORDER BY id')
.rows,
).toEqual([
{ id: 1, novelId: 1, name: 'Chapter 1' },
{ id: 4, novelId: 2, name: 'Chapter 1' },
]);
expect(
sqlite.executeSync(
'SELECT novelId, categoryId FROM NovelCategory ORDER BY id',
).rows,
).toEqual([{ novelId: 1, categoryId: 1 }]);
// The exact precondition of the production crash: no restored row may
// reference a novel that the rebuilt `Novel` does not contain.
expect(sqlite.executeRawSync('PRAGMA foreign_key_check;')).toEqual([]);
} finally {
sqlite.close();
}
});
it('runs the full production initialization on a legacy database', async () => {
const sqlite = open({ name: ':memory:' });
sqlite.executeSync('PRAGMA foreign_keys = ON');
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
createLegacySchema(sqlite);
sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text');
sqlite.executeSync('ALTER TABLE Chapter ADD timeSpent integer DEFAULT 0');
for (const novelId of [1, 2]) {
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (${novelId}, '/novel-${novelId}', 'plugin-${novelId}', 'Novel ${novelId}', 1)`,
);
sqlite.executeSync(
`INSERT INTO Chapter (id, novelId, path, name, unread, isDownloaded, readTime, updatedTime)
VALUES (${
(novelId - 1) * 3 + 1
}, ${novelId}, '/novel-${novelId}/chapter-1', 'Chapter 1', 1, 1, '2026-07-26T10:00:00.000Z', '2026-07-26T11:00:00.000Z')`,
);
}
sqlite.executeSync(
`INSERT INTO Category (id, name, sort) VALUES (1, 'Default', 1)`,
);
sqlite.executeSync(
`INSERT INTO NovelCategory (id, novelId, categoryId) VALUES (1, 1, 1)`,
);
recordAppliedMigrations(sqlite);
// A previous release's successful bootstrap left triggers behind.
sqlite.executeSync(createNovelTriggerQueryInsert);
sqlite.executeSync(createNovelTriggerQueryUpdate);
sqlite.executeSync(createNovelTriggerQueryDelete);
sqlite.executeSync(createCategoryTriggerQuery);
crashAfterNovelSnapshot(sqlite);
sqlite.executeSync(
`INSERT INTO Novel (id, path, pluginId, name, inLibrary)
VALUES (3, '/novel-3', 'plugin-3', 'Novel 3', 1)`,
);
sqlite.executeSync(
`INSERT INTO Chapter (id, novelId, path, name, unread, isDownloaded)
VALUES (7, 3, '/novel-3/chapter-1', 'Chapter 3.1', 1, 0)`,
);
sqlite.executeSync(
`INSERT INTO NovelCategory (id, novelId, categoryId) VALUES (2, 3, 1)`,
);
// The exact production sequence: repairs, then migrate, then bootstrap.
await runRecovery(sqlite);
runDatabaseBootstrap(createExecutor(sqlite));
expect(
sqlite.executeSync('SELECT id, name FROM Novel ORDER BY id').rows,
).toEqual([
{ id: 1, name: 'Novel 1' },
{ id: 2, name: 'Novel 2' },
{ id: 3, name: 'Novel 3' },
]);
expect(
sqlite.executeSync('SELECT id, novelId FROM Chapter ORDER BY id').rows,
).toEqual([
{ id: 1, novelId: 1 },
{ id: 4, novelId: 2 },
{ id: 7, novelId: 3 },
]);
expect(sqlite.executeRawSync('PRAGMA foreign_key_check;')).toEqual([]);
const triggerNames = (
sqlite.executeSync(
"SELECT name FROM sqlite_master WHERE type = 'trigger'",
).rows as { name: string }[]
).map(row => row.name);
expect(triggerNames).toEqual(
expect.arrayContaining([
'update_novel_stats',
'update_novel_stats_on_update',
'update_novel_stats_on_delete',
'add_category',
]),
);
expect(
sqlite.executeSync('SELECT id FROM Category ORDER BY id').rows,
).toEqual([{ id: 1 }, { id: 2 }]);
} finally {
sqlite.close();
}
});
});
+5
View File
@@ -28,3 +28,8 @@ export const CHAPTER_FILTER = {
export type ChapterFilterPositiveKey = keyof typeof CHAPTER_FILTER_POSITIVE;
export type ChapterFilterKey = keyof typeof CHAPTER_FILTER;
export type ChapterFilter = (typeof CHAPTER_FILTER)[ChapterFilterKey];
export const BUILT_IN_CATEGORY_IDS = {
default: 1,
local: 2,
} as const;
+142 -3
View File
@@ -16,6 +16,7 @@ import {
createNovelTriggerQueryUpdate,
} from './queryStrings/triggers';
import { useEffect, useReducer } from 'react';
import { getErrorChainMessages } from '@utils/error';
class MyLogger implements Logger {
logQuery(_query: string, _params: unknown[]): void {
@@ -36,11 +37,13 @@ const TIME_SPENT_MIGRATION_CREATED_AT = 1784471667000;
const CHAPTER_COLUMN_MIGRATIONS = [
{
columnName: 'scanlator',
columnDefinition: 'text',
migrationName: SCANLATOR_MIGRATION_NAME,
createdAt: SCANLATOR_MIGRATION_CREATED_AT,
},
{
columnName: 'timeSpent',
columnDefinition: 'integer DEFAULT 0',
migrationName: TIME_SPENT_MIGRATION_NAME,
createdAt: TIME_SPENT_MIGRATION_CREATED_AT,
},
@@ -108,6 +111,128 @@ export const repairInterruptedNovelMigration = (
executor.executeSync("ALTER TABLE '__new_Novel' RENAME TO 'Novel';");
};
/**
* Brings a retained Chapter snapshot up to the current schema before an
* interrupted migration is retried. Older snapshots can predate columns that
* were added to Chapter, so restoring them with the current migration would
* otherwise fail with a column-count mismatch.
*/
export const repairChapterMigrationSnapshot = (executor: MigrationExecutor) => {
const snapshotColumns = executor.executeRawSync(
'PRAGMA table_info(__migration_Chapter);',
);
if (snapshotColumns.length === 0) {
return;
}
const snapshotColumnNames = new Set(snapshotColumns.map(row => row[1]));
for (const migration of CHAPTER_COLUMN_MIGRATIONS) {
if (snapshotColumnNames.has(migration.columnName)) {
continue;
}
executor.executeSync(
`ALTER TABLE '__migration_Chapter' ADD COLUMN '${migration.columnName}' ${migration.columnDefinition};`,
);
}
};
/**
* Backfills novels into a stale `__migration_Novel` snapshot so an interrupted
* migration can be retried without losing novels written while it was down.
*
* The migration snapshots are created with `IF NOT EXISTS`. A build that ran
* the migration outside a transaction and died part-way leaves a stale
* `__migration_Novel` on disk while `Novel`/`Chapter`/`NovelCategory` keep
* accepting writes; later launches re-run the migration, rebuild `Novel` from
* that stale snapshot, and the Chapter/NovelCategory copy-back then violates
* the `FOREIGN KEY (novelId)` that pre-drizzle databases still carry on both
* tables — failing on every launch. Novels written after the snapshot went
* stale are backfilled from the live tables (or, when a crashed attempt
* already destroyed them, their references are pruned) so the migration
* completes and the surviving data is preserved.
*/
export const repairInterruptedNovelSnapshot = (executor: MigrationExecutor) => {
const tableNames = new Set(
executor
.executeRawSync("SELECT name FROM sqlite_master WHERE type = 'table';")
.map(row => row[0]),
);
if (!tableNames.has('Novel') || !tableNames.has('__migration_Novel')) {
return;
}
// What the copy-back will restore: the live tables when a failed attempt was
// rolled back, the retained snapshots when a partial attempt was persisted.
const referencedNovelIds = new Set(
[
'Chapter',
'NovelCategory',
'__migration_Chapter',
'__migration_NovelCategory',
]
.filter(tableName => tableNames.has(tableName))
.flatMap(tableName =>
executor
.executeRawSync(`SELECT DISTINCT novelId FROM \`${tableName}\`;`)
.map(row => row[0]),
),
);
if (referencedNovelIds.size === 0) {
return;
}
const snapshotNovelIds = new Set(
executor
.executeRawSync('SELECT id FROM __migration_Novel;')
.map(row => row[0]),
);
const missingNovelIds = [...referencedNovelIds].filter(
novelId => novelId !== null && !snapshotNovelIds.has(novelId),
);
if (missingNovelIds.length > 0) {
// The stale snapshot can predate the migration's Novel rename, so its
// columns may differ from the live table (e.g. pre-drizzle 13 vs 18).
// Insert through the intersection of both column sets.
const snapshotColumns = executor
.executeRawSync('PRAGMA table_info(__migration_Novel);')
.map(row => row[1]);
const novelColumns = new Set(
executor.executeRawSync('PRAGMA table_info(Novel);').map(row => row[1]),
);
const sharedColumns = snapshotColumns.filter(column =>
novelColumns.has(column),
);
if (sharedColumns.length > 0) {
const columnList = sharedColumns
.map(column => `\`${column}\``)
.join(', ');
executor.executeSync(
`INSERT INTO __migration_Novel (${columnList}) SELECT ${columnList} FROM Novel WHERE id IN (${missingNovelIds.join(
', ',
)});`,
);
}
}
// Novels referenced only by the retained snapshots are unrecoverable (a
// crashed attempt already dropped the live table). Prune their rows so the
// copy-back cannot fail the foreign key on every launch.
for (const tableName of [
'__migration_Chapter',
'__migration_NovelCategory',
]) {
if (!tableNames.has(tableName)) {
continue;
}
executor.executeSync(
`DELETE FROM \`${tableName}\` WHERE novelId NOT IN (SELECT id FROM __migration_Novel);`,
);
}
};
/**
* Repairs migration metadata created by older Drizzle versions and interrupted
* migrations. SQLite cannot add a column conditionally, so an existing column
@@ -232,10 +357,24 @@ export const initializeDatabase = () => {
if (!initialization) {
setPragmas(_db);
repairInterruptedNovelMigration(_db);
repairInterruptedNovelSnapshot(_db);
repairChapterMigrationSnapshot(_db);
repairMigrationHistory(_db);
initialization = migrate(drizzleDb, getPendingMigrations(_db)).then(() => {
runDatabaseBootstrap(_db);
});
initialization = migrate(drizzleDb, getPendingMigrations(_db))
.then(() => {
runDatabaseBootstrap(_db);
})
.catch((error: Error) => {
// DrizzleQueryError keeps the native SQLite message (e.g. "FOREIGN KEY
// constraint failed") on `cause`; without it the reported trace ends at
// "params:" and the real reason is unrecoverable.
console.error(
'Database initialization failed:',
getErrorChainMessages(error).join(' Caused by: '),
error.stack,
);
throw error;
});
}
return initialization;
};
+2 -2
View File
@@ -186,10 +186,10 @@ export const updateCategoryOrderInDb = async (
}
await dbManager.write(async tx => {
for (const category of categories) {
for (const [index, category] of categories.entries()) {
await tx
.update(categorySchema)
.set({ sort: category.sort })
.set({ sort: index + 1 })
.where(eq(categorySchema.id, category.id))
.run();
}
+169 -44
View File
@@ -28,6 +28,18 @@ import NativeFile from '@modules/native-file';
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
import { chapterFilterToSQL, chapterOrderToSQL } from '@database/utils/parser';
import { castInt } from '@database/manager/manager';
import { createNovelTriggerQueryUpdate } from '@database/queryStrings/triggers';
const CHAPTER_ID_BATCH_SIZE = 500;
const chunkChapterIds = (chapterIds: number[]) =>
Array.from(
{ length: Math.ceil(chapterIds.length / CHAPTER_ID_BATCH_SIZE) },
(_, index) =>
chapterIds.slice(
index * CHAPTER_ID_BATCH_SIZE,
(index + 1) * CHAPTER_ID_BATCH_SIZE,
),
);
// #region Mutations
@@ -47,7 +59,7 @@ export const insertChapters = async (
return;
}
const nowSql = sql`datetime('now','localtime')`;
const nowSql = sql`strftime('%Y-%m-%dT%H:%M:%fZ','now')`;
const rows = chapters.map((c, index) => {
let scanlatorStr: string | null = null;
@@ -122,11 +134,13 @@ export const markChaptersRead = async (chapterIds: number[]): Promise<void> => {
return;
}
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: false })
.where(inArray(chapterSchema.id, chapterIds))
.run();
for (const ids of chunkChapterIds(chapterIds)) {
await tx
.update(chapterSchema)
.set({ unread: false })
.where(inArray(chapterSchema.id, ids))
.run();
}
});
};
@@ -147,11 +161,13 @@ export const markChaptersUnread = async (
return;
}
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ unread: true })
.where(inArray(chapterSchema.id, chapterIds))
.run();
for (const ids of chunkChapterIds(chapterIds)) {
await tx
.update(chapterSchema)
.set({ unread: true })
.where(inArray(chapterSchema.id, ids))
.run();
}
});
};
@@ -207,25 +223,26 @@ export const deleteChapter = async (
export const deleteChapters = async (
pluginId: string,
novelId: number,
chapters?: ChapterInfo[],
chapterIds?: number[],
): Promise<void> => {
if (!chapters?.length) {
if (!chapterIds?.length) {
return;
}
const chapterIds = chapters.map(chapter => chapter.id);
await Promise.all(
chapters.map(chapter =>
deleteDownloadedFiles(pluginId, novelId, chapter.id),
),
);
for (const ids of chunkChapterIds(chapterIds)) {
await Promise.all(
ids.map(chapterId => deleteDownloadedFiles(pluginId, novelId, chapterId)),
);
}
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ isDownloaded: false })
.where(inArray(chapterSchema.id, chapterIds))
.run();
for (const ids of chunkChapterIds(chapterIds)) {
await tx
.update(chapterSchema)
.set({ isDownloaded: false })
.where(inArray(chapterSchema.id, ids))
.run();
}
});
};
@@ -245,7 +262,6 @@ export const increaseTimeSpent = async (
});
};
// TODO: Remove the need for the chapters array, as it could lead to not deleting the downloaded files but just marking them as not downloaded
/*
Deletes all downloaded chapters from the database
*/
@@ -255,19 +271,28 @@ export const deleteDownloads = async (
if (!chapters?.length) {
return;
}
chapters.forEach(chapter => {
deleteDownloadedFiles(chapter.pluginId, chapter.novelId, chapter.id);
});
await Promise.all(
chapters.map(chapter =>
deleteDownloadedFiles(chapter.pluginId, chapter.novelId, chapter.id),
),
);
const chapterIds = chapters.map(chapter => chapter.id);
await dbManager.write(async tx => {
await tx.update(chapterSchema).set({ isDownloaded: false }).run();
await tx
.update(chapterSchema)
.set({ isDownloaded: false })
.where(inArray(chapterSchema.id, chapterIds))
.run();
});
};
export const deleteReadChaptersFromDb = async (): Promise<void> => {
const chapters = await getReadDownloadedChapters();
chapters?.forEach(chapter => {
deleteDownloadedFiles(chapter.pluginId, chapter.novelId, chapter.id);
});
await Promise.all(
chapters.map(chapter =>
deleteDownloadedFiles(chapter.pluginId, chapter.novelId, chapter.id),
),
);
const chapterIds = chapters?.map(chapter => chapter.id);
if (chapterIds?.length) {
await dbManager.write(async tx => {
@@ -302,11 +327,13 @@ export const updateChapterProgressByIds = async (
return;
}
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
.set({ progress })
.where(inArray(chapterSchema.id, chapterIds))
.run();
for (const ids of chunkChapterIds(chapterIds)) {
await tx
.update(chapterSchema)
.set({ progress })
.where(inArray(chapterSchema.id, ids))
.run();
}
});
};
@@ -320,6 +347,21 @@ export const bookmarkChapter = async (chapterId: number): Promise<void> => {
});
};
export const bookmarkChapters = async (chapterIds: number[]): Promise<void> => {
if (!chapterIds.length) {
return;
}
await dbManager.write(async tx => {
for (const ids of chunkChapterIds(chapterIds)) {
await tx
.update(chapterSchema)
.set({ bookmark: sql`NOT ${chapterSchema.bookmark}` })
.where(inArray(chapterSchema.id, ids))
.run();
}
});
};
export const markPreviuschaptersRead = async (
chapterId: number,
novelId: number,
@@ -358,7 +400,15 @@ export const markPreviousChaptersUnread = async (
export const clearUpdates = async (): Promise<void> => {
await dbManager.write(async tx => {
// The chapter update trigger recalculates novel aggregates once per row.
// Bypass it for this database-wide operation and update the one affected
// aggregate in bulk instead.
await tx.run(
sql.raw('DROP TRIGGER IF EXISTS update_novel_stats_on_update'),
);
await tx.update(chapterSchema).set({ updatedTime: null }).run();
await tx.update(novelSchema).set({ lastUpdatedAt: null }).run();
await tx.run(sql.raw(createNovelTriggerQueryUpdate));
});
};
@@ -410,6 +460,23 @@ export const getNovelChapters = async (
.all();
};
/**
* Returns every chapter row for a novel.
*
* This is intentionally separate from getNovelChapters, whose default limit is
* used to keep interactive screens responsive. Backup creation must never use
* a paginated UI query because doing so silently produces incomplete backups.
*/
export const getAllNovelChaptersForBackup = async (
novelId: number,
): Promise<ChapterInfo[]> =>
dbManager
.select()
.from(chapterSchema)
.where(eq(chapterSchema.novelId, novelId))
.orderBy(asc(chapterSchema.id))
.all();
export const getNovelChaptersSync = (
novelId: number,
sort?: ChapterOrderKey,
@@ -460,7 +527,8 @@ export const getAllUndownloadedChapters = async (
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.isDownloaded, false),
),
);
)
.orderBy(asc(castInt(chapterSchema.page)), asc(chapterSchema.position));
/**
* @deprecated, use getNovelChapters with whereConditions instead
*/
@@ -477,6 +545,7 @@ export const getAllUndownloadedAndUnreadChapters = async (
eq(chapterSchema.unread, true),
),
)
.orderBy(asc(castInt(chapterSchema.page)), asc(chapterSchema.position))
.all();
export const getChapter = async (chapterId: number) =>
@@ -521,6 +590,50 @@ export const getPageChapters = async (
return query.all();
};
export const getPageChapterIds = async (
novelId: number,
filter?: ChapterFilterKey[],
page?: string,
excludedScanlators?: string[],
): Promise<number[]> => {
const conditions = [
eq(chapterSchema.novelId, novelId),
eq(chapterSchema.page, page || '1'),
chapterFilterToSQL(filter),
scanlatorFilterToSQL(excludedScanlators),
].filter(Boolean) as any[];
const rows = await dbManager
.select({ id: chapterSchema.id })
.from(chapterSchema)
.where(and(...conditions))
.all();
return rows.map(chapter => chapter.id);
};
export const getChaptersByIds = async (
chapterIds: number[],
): Promise<ChapterInfo[]> => {
const chapters = await Promise.all(
chunkChapterIds(chapterIds).map(ids =>
dbManager
.select()
.from(chapterSchema)
.where(inArray(chapterSchema.id, ids))
.all(),
),
);
const chaptersById = new Map(
chapters.flat().map(chapter => [chapter.id, chapter]),
);
return chapterIds.flatMap(chapterId => {
const chapter = chaptersById.get(chapterId);
return chapter ? [chapter] : [];
});
};
export const getChapterCount = async (
novelId: number,
page: string = '1',
@@ -763,6 +876,7 @@ export const getNovelDownloadedChapters = async (
export const getUpdatedOverviewFromDb = async () =>
dbManager
.select({
inLibrary: novelSchema.inLibrary,
novelId: novelSchema.id,
pluginId: novelSchema.pluginId,
novelName: novelSchema.name,
@@ -780,13 +894,13 @@ export const getUpdatedOverviewFromDb = async () =>
.orderBy(desc(sql`update_date`), novelSchema.id)
.all();
export const getDetailedUpdatesFromDb = async (
export const getDetailedUpdatesQuery = (
novelId: number,
onlyDownloadableChapters?: boolean,
updateDate?: string,
limit?: number,
): Promise<Update[]> => {
return dbManager
) =>
dbManager
.select({
...getColumns(chapterSchema),
pluginId: novelSchema.pluginId,
@@ -809,9 +923,20 @@ export const getDetailedUpdatesFromDb = async (
),
)
.orderBy(desc(chapterSchema.updatedTime))
.limit(limit ?? -1)
.all();
};
.limit(limit ?? -1);
export const getDetailedUpdatesFromDb = (
novelId: number,
onlyDownloadableChapters?: boolean,
updateDate?: string,
limit?: number,
): Promise<Update[]> =>
getDetailedUpdatesQuery(
novelId,
onlyDownloadableChapters,
updateDate,
limit,
).all();
export const isChapterDownloaded = (chapterId: number): boolean => {
const result = dbManager.getSync(
+1
View File
@@ -12,6 +12,7 @@ export const getHistoryFromDb = async () => {
return dbManager
.select({
...getColumns(chapterSchema),
inLibrary: novelSchema.inLibrary,
pluginId: novelSchema.pluginId,
novelName: novelSchema.name,
novelPath: novelSchema.path,
+22 -3
View File
@@ -34,7 +34,7 @@ const smartUpdateConditions = ({
/**
* Get library novels with optional filtering and sorting using Drizzle ORM
*/
export const getLibraryNovelsFromDb = (
export const getLibraryNovelsQuery = (
sortOrder?: string,
filter?: string,
searchText?: string,
@@ -61,12 +61,31 @@ export const getLibraryNovelsFromDb = (
.$dynamic();
if (sortOrder) {
query.orderBy(sql.raw(sortOrder));
const dateAwareSortOrder = sortOrder.replace(
/^lastUpdatedAt (ASC|DESC)$/,
'julianday(lastUpdatedAt) $1',
);
query.orderBy(sql.raw(dateAwareSortOrder));
}
return query.all();
return query;
};
export const getLibraryNovelsFromDb = (
sortOrder?: string,
filter?: string,
searchText?: string,
downloadedOnlyMode?: boolean,
excludeLocalNovels?: boolean,
) =>
getLibraryNovelsQuery(
sortOrder,
filter,
searchText,
downloadedOnlyMode,
excludeLocalNovels,
).all();
/**
* Get the novels eligible for a global update in one query.
* An empty include list means all categories, while exclusions always win.
+10 -3
View File
@@ -21,6 +21,7 @@ import {
import type { TransactionParameter } from '@database/manager/manager.d';
import { getLibraryDefaultCategoryId } from '@hooks/persisted/useSettings';
import NativeFile from '@modules/native-file';
import { BUILT_IN_CATEGORY_IDS } from '@database/constants';
const getCategoryForNewNovel = async (tx: TransactionParameter) => {
const preferredCategoryId = getLibraryDefaultCategoryId();
@@ -40,7 +41,7 @@ const getCategoryForNewNovel = async (tx: TransactionParameter) => {
return tx
.select({ id: categorySchema.id })
.from(categorySchema)
.where(eq(categorySchema.sort, 1))
.where(eq(categorySchema.id, BUILT_IN_CATEGORY_IDS.default))
.get();
};
@@ -174,7 +175,7 @@ export const switchNovelToLibraryQuery = async (
.insert(novelCategorySchema)
.values({
novelId: novel.id,
categoryId: 2,
categoryId: BUILT_IN_CATEGORY_IDS.local,
})
.onConflictDoNothing()
.run();
@@ -380,13 +381,19 @@ export const updateNovelCategories = async (
if (!novelIds.length) return;
await dbManager.write(async tx => {
await tx
.update(novelSchema)
.set({ inLibrary: true })
.where(inArray(novelSchema.id, novelIds))
.run();
// Delete existing categories (keeping local category if present)
await tx
.delete(novelCategorySchema)
.where(
and(
inArray(novelCategorySchema.novelId, novelIds),
ne(novelCategorySchema.categoryId, 2),
ne(novelCategorySchema.categoryId, BUILT_IN_CATEGORY_IDS.local),
),
)
.run();
+24
View File
@@ -6,6 +6,17 @@ export const getRepositoriesFromDb = async (): Promise<RepositoryRow[]> => {
return dbManager.select().from(repositorySchema).all();
};
export const getEnabledRepositoriesFromDb = async (): Promise<
RepositoryRow[]
> => {
return dbManager
.select()
.from(repositorySchema)
.where(eq(repositorySchema.enabled, true))
.orderBy(repositorySchema.id)
.all();
};
export const isRepoUrlDuplicated = async (repoUrl: string) => {
const result = await dbManager
.select({ count: repositorySchema.id })
@@ -48,3 +59,16 @@ export const updateRepository = async (
.run();
});
};
export const setRepositoryEnabled = async (
id: number,
enabled: boolean,
): Promise<void> => {
await dbManager.write(async tx => {
await tx
.update(repositorySchema)
.set({ enabled })
.where(eq(repositorySchema.id, id))
.run();
});
};
+95 -159
View File
@@ -1,182 +1,118 @@
import { countBy } from 'lodash-es';
import { eq, count, and, sql } from 'drizzle-orm';
import { eq, count, sql } from 'drizzle-orm';
import { LibraryStats } from '../types';
import { dbManager } from '@database/db';
import { novelSchema, chapterSchema, categorySchema, novelCategorySchema } from '@database/schema';
/**
* Get library statistics (novel count and distinct sources) using Drizzle ORM
*/
export const getLibraryStatsFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
novelsCount: count(),
sourcesCount: sql<number>`COUNT(DISTINCT ${novelSchema.pluginId})`,
})
.from(novelSchema)
.where(eq(novelSchema.inLibrary, true))
.get();
return result ?? { novelsCount: 0, sourcesCount: 0 };
};
/**
* Get total chapters count for all novels in library
*/
export const getChaptersTotalCountFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ chaptersCount: count() })
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(eq(novelSchema.inLibrary, true))
.get();
return result ?? { chaptersCount: 0 };
};
import {
novelSchema,
chapterSchema,
categorySchema,
novelCategorySchema,
} from '@database/schema';
/**
* Get top novels based on time spent
*/
export const getTopNovelsByTimeSpentFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
id: novelSchema.id,
pluginId: novelSchema.pluginId,
cover: novelSchema.cover,
name: novelSchema.name,
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
})
.from(novelSchema)
.innerJoin(chapterSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(eq(novelSchema.inLibrary, true))
.groupBy(novelSchema.id)
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
.limit(10)
.all();
return { topNovelsByTimeSpent: result };
};
/** Get top categories based on time spent */
export const getTopCategoriesByTimeSpentFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
id: categorySchema.id,
name: categorySchema.name,
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`
})
.from(categorySchema)
.innerJoin(novelCategorySchema, eq(categorySchema.id, novelCategorySchema.categoryId))
.innerJoin(novelSchema, eq(novelSchema.id, novelCategorySchema.novelId))
.innerJoin(chapterSchema, eq(novelSchema.id, chapterSchema.novelId))
.groupBy(categorySchema.id)
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
.limit(10)
.all()
return { topCategoriesByTimeSpent: result };
}
/** Sum up the timeSpent for all chapters */
export const getTotalTimeSpentFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ totalTimeSpent: sql<number>`COALESCE(SUM(${chapterSchema.timeSpent}), 0)` })
.from(chapterSchema)
.get();
return { totalTimeSpent: result?.totalTimeSpent ?? 0 };
}
/**
* Get total read chapters count for all novels in library
*/
export const getChaptersReadCountFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ chaptersRead: count() })
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(
and(eq(novelSchema.inLibrary, true), eq(chapterSchema.unread, false)),
)
.get();
return result ?? { chaptersRead: 0 };
};
/**
* Get total unread chapters count for all novels in library
*/
export const getChaptersUnreadCountFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ chaptersUnread: count() })
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(and(eq(novelSchema.inLibrary, true), eq(chapterSchema.unread, true)))
.get();
return result ?? { chaptersUnread: 0 };
};
/**
* Get total downloaded chapters count for all novels in library
*/
export const getChaptersDownloadedCountFromDb =
export const getTopNovelsByTimeSpentFromDb =
async (): Promise<LibraryStats> => {
const result = await dbManager
.select({ chaptersDownloaded: count() })
.from(chapterSchema)
.innerJoin(novelSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(
and(
eq(novelSchema.inLibrary, true),
eq(chapterSchema.isDownloaded, true),
),
)
.get();
.select({
id: novelSchema.id,
pluginId: novelSchema.pluginId,
cover: novelSchema.cover,
name: novelSchema.name,
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
})
.from(novelSchema)
.innerJoin(chapterSchema, eq(chapterSchema.novelId, novelSchema.id))
.where(eq(novelSchema.inLibrary, true))
.groupBy(novelSchema.id)
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
.limit(10)
.all();
return result ?? { chaptersDownloaded: 0 };
return { topNovelsByTimeSpent: result };
};
/**
* Get genre distribution for all novels in library
*/
export const getNovelGenresFromDb = async (): Promise<LibraryStats> => {
/** Get top categories based on time spent */
export const getTopCategoriesByTimeSpentFromDb =
async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
id: categorySchema.id,
name: categorySchema.name,
timeSpent: sql<number>`SUM(${chapterSchema.timeSpent})`,
})
.from(categorySchema)
.innerJoin(
novelCategorySchema,
eq(categorySchema.id, novelCategorySchema.categoryId),
)
.innerJoin(novelSchema, eq(novelSchema.id, novelCategorySchema.novelId))
.innerJoin(chapterSchema, eq(novelSchema.id, chapterSchema.novelId))
.groupBy(categorySchema.id)
.orderBy(sql`SUM(${chapterSchema.timeSpent}) DESC`)
.having(sql`SUM(${chapterSchema.timeSpent}) > 0`)
.limit(10)
.all();
return { topCategoriesByTimeSpent: result };
};
export interface NovelWithGenres {
id: number;
name: string;
path: string;
cover: string | null;
pluginId: string;
genres: string | null;
status: string | null;
}
export const getNovelsWithGenresFromDb = async (): Promise<
NovelWithGenres[]
> => {
const res = await dbManager
.select({ genres: novelSchema.genres })
.select({
id: novelSchema.id,
path: novelSchema.path,
name: novelSchema.name,
cover: novelSchema.cover,
pluginId: novelSchema.pluginId,
genres: novelSchema.genres,
status: novelSchema.status,
})
.from(novelSchema)
.where(eq(novelSchema.inLibrary, true))
.all();
const genres: string[] = [];
res.forEach(item => {
const novelGenres = item.genres?.split(/\s*,\s*/);
if (novelGenres?.length) {
genres.push(...novelGenres);
}
});
return { genres: countBy(genres) };
return res;
};
/**
* Get status distribution for all novels in library
* Get aggregate library stats from Novel table pre-computed columns.
* Single query replaces: getLibraryStatsFromDb, getChaptersTotalCountFromDb,
* getChaptersReadCountFromDb, getChaptersUnreadCountFromDb,
* getChaptersDownloadedCountFromDb, getTotalTimeSpentFromDb.
*/
export const getNovelStatusFromDb = async (): Promise<LibraryStats> => {
const res = await dbManager
.select({ status: novelSchema.status })
export const getAggregateStatsFromDb = async (): Promise<LibraryStats> => {
const result = await dbManager
.select({
novelsCount: count(),
sourcesCount: sql<number>`COUNT(DISTINCT ${novelSchema.pluginId})`,
chaptersCount: sql<number>`COALESCE(SUM(${novelSchema.totalChapters}), 0)`,
chaptersDownloaded: sql<number>`COALESCE(SUM(${novelSchema.chaptersDownloaded}), 0)`,
chaptersUnread: sql<number>`COALESCE(SUM(${novelSchema.chaptersUnread}), 0)`,
totalTimeSpent: sql<number>`COALESCE((SELECT SUM(${chapterSchema.timeSpent}) FROM ${chapterSchema} ch INNER JOIN ${novelSchema} n ON ch.novelId = n.id WHERE n.inLibrary = true), 0)`,
})
.from(novelSchema)
.where(eq(novelSchema.inLibrary, true))
.all();
.get();
const statusList: string[] = [];
res.forEach(item => {
const novelStatus = item.status?.split(/\s*,\s*/);
if (novelStatus?.length) {
statusList.push(...novelStatus);
}
});
return { status: countBy(statusList) };
return {
novelsCount: result?.novelsCount ?? 0,
sourcesCount: result?.sourcesCount ?? 0,
chaptersCount: result?.chaptersCount ?? 0,
chaptersRead: (result?.chaptersCount ?? 0) - (result?.chaptersUnread ?? 0),
chaptersUnread: result?.chaptersUnread ?? 0,
chaptersDownloaded: result?.chaptersDownloaded ?? 0,
totalTimeSpent: result?.totalTimeSpent ?? 0,
};
};
@@ -212,7 +212,7 @@ describe('CategoryQueries', () => {
});
describe('updateCategoryOrderInDb', () => {
it('should update sort order for multiple categories', async () => {
it('should persist categories using one-based display order', async () => {
const testDb = getTestDb();
const cat1 = await insertTestCategory(testDb, {
name: 'Cat 1',
@@ -231,8 +231,8 @@ describe('CategoryQueries', () => {
const categories = await getCategoriesFromDb();
const updated1 = categories.find(c => c.id === cat1);
const updated2 = categories.find(c => c.id === cat2);
expect(updated1?.sort).toBe(30);
expect(updated2?.sort).toBe(40);
expect(updated1?.sort).toBe(1);
expect(updated2?.sort).toBe(2);
});
it('should handle empty array', async () => {
@@ -7,6 +7,8 @@
import './mockDb';
import { setupTestDatabase, getTestDb, teardownTestDatabase } from './setup';
import { insertTestNovel, insertTestChapter, clearAllTables } from './testData';
import { chapterSchema } from '@database/schema';
import { eq } from 'drizzle-orm';
import {
markChapterRead,
@@ -16,12 +18,14 @@ import {
markAllChaptersRead,
markAllChaptersUnread,
getNovelChapters,
getAllNovelChaptersForBackup,
getUnreadNovelChapters,
getAllUndownloadedChapters,
getAllUndownloadedAndUnreadChapters,
getChapter,
getCustomPages,
getPageChapters,
getPageChapterIds,
getChapterCount,
getChapterCountSync,
getPageChaptersBatched,
@@ -33,6 +37,7 @@ import {
getDetailedUpdatesFromDb,
isChapterDownloaded,
bookmarkChapter,
bookmarkChapters,
insertChapters,
deleteChapter,
deleteChapters,
@@ -110,6 +115,25 @@ describe('ChapterQueries', () => {
});
});
describe('bookmarkChapters', () => {
it('should toggle multiple chapters by id', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
const chapterId1 = await insertTestChapter(testDb, novelId, {
bookmark: false,
});
const chapterId2 = await insertTestChapter(testDb, novelId, {
bookmark: true,
});
await bookmarkChapters([chapterId1, chapterId2]);
const chapters = await getNovelChapters(novelId);
expect(chapters.find(c => c.id === chapterId1)?.bookmark).toBe(true);
expect(chapters.find(c => c.id === chapterId2)?.bookmark).toBe(false);
});
});
describe('getNovelChapters', () => {
it('should return all chapters for a novel', async () => {
const testDb = getTestDb();
@@ -126,6 +150,43 @@ describe('ChapterQueries', () => {
});
});
describe('getAllNovelChaptersForBackup', () => {
it('returns chapters beyond the 1000-row UI query limit', async () => {
const testDb = getTestDb();
// No Novel row is needed for this selector test. Keeping the id orphaned
// also avoids running aggregate-stat triggers 1001 times during setup.
const novelId = 123456;
const values = Array.from(
{ length: 1001 },
(_, position) =>
`(${novelId}, '/chapter/${position + 1}', ` +
`'Chapter ${position + 1}', ${position + 1}, '1', ${position})`,
).join(',');
testDb.sqlite.executeSync(
`INSERT INTO Chapter
(novelId, path, name, chapterNumber, page, position)
VALUES ${values}`,
);
expect(await getNovelChapters(novelId)).toHaveLength(1000);
const chapterIds = await getPageChapterIds(novelId, [], '1');
expect(chapterIds).toHaveLength(1001);
await markChaptersRead(chapterIds);
expect(
(await getPageChapters(novelId, undefined, [], '1')).every(
chapter => !chapter.unread,
),
).toBe(true);
const backupChapters = await getAllNovelChaptersForBackup(novelId);
expect(backupChapters).toHaveLength(1001);
expect(backupChapters.at(-1)?.name).toBe('Chapter 1001');
});
});
describe('markChaptersRead', () => {
it('should mark multiple chapters as read', async () => {
const testDb = getTestDb();
@@ -479,10 +540,7 @@ describe('ChapterQueries', () => {
const chapter2b = chapters.find(c => c.id === chapterId2);
expect(chapter1b?.isDownloaded).toBe(true);
expect(chapter2b?.isDownloaded).toBe(true);
await deleteChapters('test-plugin', novelId, [
{ id: chapterId1 } as any,
{ id: chapterId2 } as any,
]);
await deleteChapters('test-plugin', novelId, [chapterId1, chapterId2]);
const updatedChapters = await getNovelChapters(novelId);
const chapter1a = updatedChapters.find(c => c.id === chapterId1);
@@ -509,9 +567,14 @@ describe('ChapterQueries', () => {
await insertTestChapter(testDb, novelId, {
isDownloaded: true,
});
const untouchedChapterId = await insertTestChapter(testDb, novelId, {
isDownloaded: true,
});
const chapters = await getNovelChapters(novelId);
const downloadedChapters = chapters.filter(c => c.isDownloaded);
const downloadedChapters = chapters.filter(
c => c.isDownloaded && c.id !== untouchedChapterId,
);
await deleteDownloads(
downloadedChapters.map(c => ({
id: c.id,
@@ -521,7 +584,14 @@ describe('ChapterQueries', () => {
);
const updatedChapters = await getNovelChapters(novelId);
expect(updatedChapters.every(c => c.isDownloaded === false)).toBe(true);
expect(
updatedChapters
.filter(c => c.id !== untouchedChapterId)
.every(c => c.isDownloaded === false),
).toBe(true);
expect(
updatedChapters.find(c => c.id === untouchedChapterId)?.isDownloaded,
).toBe(true);
});
});
@@ -649,10 +719,10 @@ describe('ChapterQueries', () => {
});
describe('clearUpdates', () => {
it('should clear all update timestamps', async () => {
it('should clear update timestamps and preserve the update trigger', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, {
const chapterId = await insertTestChapter(testDb, novelId, {
updatedTime: '2024-01-01',
});
await insertTestChapter(testDb, novelId, {
@@ -663,6 +733,25 @@ describe('ChapterQueries', () => {
const chapters = await getNovelChapters(novelId);
expect(chapters.every(c => c.updatedTime === null)).toBe(true);
expect(
testDb.sqlite.executeSync(
'SELECT lastUpdatedAt FROM Novel WHERE id = ?',
[novelId],
).rows[0]?.lastUpdatedAt,
).toBeNull();
await testDb.drizzleDb
.update(chapterSchema)
.set({ updatedTime: '2024-02-01' })
.where(eq(chapterSchema.id, chapterId))
.run();
expect(
testDb.sqlite.executeSync(
'SELECT lastUpdatedAt FROM Novel WHERE id = ?',
[novelId],
).rows[0]?.lastUpdatedAt,
).toBe('2024-02-01');
});
});
@@ -729,6 +818,18 @@ describe('ChapterQueries', () => {
expect(result).toHaveLength(2);
expect(result.every(c => c.isDownloaded === false)).toBe(true);
});
it('returns chapters in reading order', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { position: 3 });
await insertTestChapter(testDb, novelId, { position: 1 });
await insertTestChapter(testDb, novelId, { position: 2 });
const result = await getAllUndownloadedChapters(novelId);
expect(result.map(chapter => chapter.position)).toEqual([1, 2, 3]);
});
});
describe('getAllUndownloadedAndUnreadChapters', () => {
@@ -754,6 +855,17 @@ describe('ChapterQueries', () => {
expect(result[0].isDownloaded).toBe(false);
expect(result[0].unread).toBe(true);
});
it('returns unread chapters in reading order', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { position: 5, unread: true });
await insertTestChapter(testDb, novelId, { position: 2, unread: true });
const result = await getAllUndownloadedAndUnreadChapters(novelId);
expect(result.map(chapter => chapter.position)).toEqual([2, 5]);
});
});
describe('getChapter', () => {
@@ -1235,6 +1347,7 @@ describe('ChapterQueries', () => {
const result = await getUpdatedOverviewFromDb();
expect(result.length).toBeGreaterThanOrEqual(1);
expect(result[0]?.inLibrary).toBe(true);
});
});
@@ -45,6 +45,7 @@ describe('HistoryQueries', () => {
const novelIds = result.map(h => h.novelId);
expect(novelIds).toContain(novelId1);
expect(novelIds).toContain(novelId2);
expect(result.every(item => item.inLibrary)).toBe(true);
});
it('should return empty array when no history exists', async () => {
@@ -52,6 +52,40 @@ describe('LibraryQueries', () => {
expect(novels.map(n => n.name)).toEqual(['Novel B', 'Novel A']);
});
it('should sort mixed timestamp formats by their actual date', async () => {
await insertTestNovel(testDb, {
inLibrary: true,
name: 'ISO timestamp',
lastUpdatedAt: '2026-07-28T09:00:00.000Z',
});
await insertTestNovel(testDb, {
inLibrary: true,
name: 'SQLite timestamp',
lastUpdatedAt: '2026-07-28 10:00:00',
});
const novels = await getLibraryNovelsFromDb('lastUpdatedAt DESC');
expect(novels.map(n => n.name)).toEqual([
'SQLite timestamp',
'ISO timestamp',
]);
});
it('should update lastUpdatedAt across mixed timestamp formats', async () => {
const novelId = await insertTestNovel(testDb, {
inLibrary: true,
lastUpdatedAt: '2026-07-28T09:00:00.000Z',
});
await insertTestChapter(testDb, novelId, {
updatedTime: '2026-07-28 10:00:00',
});
const novel = await getLibraryNovelsFromDb();
expect(novel[0].lastUpdatedAt).toBe('2026-07-28 10:00:00');
});
it('should filter novels by raw SQL filter', async () => {
await insertTestNovel(testDb, {
inLibrary: true,
@@ -12,8 +12,13 @@ import {
insertTestCategory,
clearAllTables,
} from './testData';
import { categorySchema, novelCategorySchema } from '@database/schema';
import { eq, sql } from 'drizzle-orm';
import {
categorySchema,
novelCategorySchema,
novelSchema,
} from '@database/schema';
import { eq } from 'drizzle-orm';
import { BUILT_IN_CATEGORY_IDS } from '@database/constants';
import {
getAllNovels,
@@ -178,7 +183,7 @@ describe('NovelQueries', () => {
expect(Boolean(novel?.inLibrary)).toBe(false);
});
it('should assign default category when adding to library', async () => {
it('should fall back to the built-in default category by ID', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, {
inLibrary: false,
@@ -186,12 +191,16 @@ describe('NovelQueries', () => {
pluginId: 'test-plugin',
});
// Get default category (sort = 1)
const defaultCategory = await testDb.drizzleDb
.select()
.from(categorySchema)
.where(sql`${categorySchema.sort} = 1`)
.get();
await testDb.drizzleDb
.update(categorySchema)
.set({ sort: 0 })
.where(eq(categorySchema.id, BUILT_IN_CATEGORY_IDS.default))
.run();
await testDb.drizzleDb
.update(categorySchema)
.set({ sort: 1 })
.where(eq(categorySchema.id, BUILT_IN_CATEGORY_IDS.local))
.run();
await switchNovelToLibraryQuery('/test/novel', 'test-plugin');
@@ -202,10 +211,17 @@ describe('NovelQueries', () => {
.all();
expect(associations.length).toBeGreaterThan(0);
expect(
associations.some(a => a.categoryId === (defaultCategory?.id ?? -1)),
).toBe(!!defaultCategory);
associations.some(
association =>
association.categoryId === BUILT_IN_CATEGORY_IDS.default,
),
).toBe(true);
expect(
associations.some(
association => association.categoryId === BUILT_IN_CATEGORY_IDS.local,
),
).toBe(false);
});
it('should assign the user-selected default category', async () => {
@@ -257,7 +273,10 @@ describe('NovelQueries', () => {
.all();
expect(
associations.some(association => association.categoryId === 1),
associations.some(
association =>
association.categoryId === BUILT_IN_CATEGORY_IDS.default,
),
).toBe(true);
});
});
@@ -455,6 +474,30 @@ describe('NovelQueries', () => {
});
describe('updateNovelCategories', () => {
it('should add a novel to the library with only the selected categories', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: false });
const categoryId = await insertTestCategory(testDb, {
name: 'Selected Category',
});
await updateNovelCategories([novelId], [categoryId]);
const novel = await testDb.drizzleDb
.select()
.from(novelSchema)
.where(eq(novelSchema.id, novelId))
.get();
const associations = await testDb.drizzleDb
.select()
.from(novelCategorySchema)
.where(eq(novelCategorySchema.novelId, novelId))
.all();
expect(Boolean(novel?.inLibrary)).toBe(true);
expect(associations.map(item => item.categoryId)).toEqual([categoryId]);
});
it('should update categories for multiple novels', async () => {
const testDb = getTestDb();
const novelId1 = await insertTestNovel(testDb, { inLibrary: true });
@@ -10,9 +10,11 @@ import { insertTestRepository, clearAllTables } from './testData';
import {
getRepositoriesFromDb,
getEnabledRepositoriesFromDb,
isRepoUrlDuplicated,
createRepository,
deleteRepositoryById,
setRepositoryEnabled,
updateRepository,
} from '../RepositoryQueries';
@@ -57,6 +59,30 @@ describe('RepositoryQueries', () => {
});
});
describe('getEnabledRepositoriesFromDb', () => {
it('should exclude disabled repositories', async () => {
const testDb = getTestDb();
await insertTestRepository(testDb, {
url: 'https://enabled.example.com',
});
await insertTestRepository(testDb, {
url: 'https://disabled.example.com',
enabled: false,
});
await insertTestRepository(testDb, {
url: 'https://second-enabled.example.com',
});
const result = await getEnabledRepositoriesFromDb();
expect(result.map(repository => repository.url)).toEqual([
'https://enabled.example.com',
'https://second-enabled.example.com',
]);
});
});
describe('isRepoUrlDuplicated', () => {
it('should return true when URL already exists', async () => {
const testDb = getTestDb();
@@ -95,6 +121,7 @@ describe('RepositoryQueries', () => {
expect(result).toBeDefined();
expect(result.url).toBe('https://example.com/repo');
expect(result.enabled).toBe(true);
expect(result.id).toBeGreaterThan(0);
});
@@ -172,4 +199,19 @@ describe('RepositoryQueries', () => {
).resolves.not.toThrow();
});
});
describe('setRepositoryEnabled', () => {
it('should update the enabled state without changing the URL', async () => {
const testDb = getTestDb();
const url = 'https://example.com/repo';
const id = await insertTestRepository(testDb, { url });
await setRepositoryEnabled(id, false);
const repositories = await getRepositoriesFromDb();
expect(repositories).toEqual([
expect.objectContaining({ id, url, enabled: false }),
]);
});
});
});
@@ -6,17 +6,16 @@
import './mockDb';
import { setupTestDatabase, getTestDb, teardownTestDatabase } from './setup';
import { insertTestNovel, insertTestChapter, clearAllTables, insertTestNovelCategory, insertTestCategory } from './testData';
import {
insertTestNovel,
insertTestChapter,
clearAllTables,
insertTestNovelCategory,
insertTestCategory,
} from './testData';
import {
getLibraryStatsFromDb,
getChaptersTotalCountFromDb,
getChaptersReadCountFromDb,
getChaptersUnreadCountFromDb,
getChaptersDownloadedCountFromDb,
getNovelGenresFromDb,
getNovelStatusFromDb,
getTotalTimeSpentFromDb,
getAggregateStatsFromDb,
getTopCategoriesByTimeSpentFromDb,
getTopNovelsByTimeSpentFromDb,
} from '../StatsQueries';
@@ -31,41 +30,68 @@ describe('StatsQueries', () => {
teardownTestDatabase();
});
describe('getLibraryStatsFromDb', () => {
it('should return correct novel and source counts', async () => {
describe('getAggregateStatsFromDb', () => {
it('should return counts, totals, and chapter stats from Novel pre-computed columns', async () => {
const testDb = getTestDb();
// Insert novels from different sources
await insertTestNovel(testDb, {
const novelId1 = await insertTestNovel(testDb, {
inLibrary: true,
pluginId: 'source1',
totalChapters: 10,
chaptersDownloaded: 3,
chaptersUnread: 2,
});
await insertTestNovel(testDb, {
inLibrary: true,
pluginId: 'source1',
});
await insertTestNovel(testDb, {
const novelId2 = await insertTestNovel(testDb, {
inLibrary: true,
pluginId: 'source2',
});
await insertTestNovel(testDb, {
inLibrary: false, // Not in library
totalChapters: 5,
chaptersDownloaded: 1,
chaptersUnread: 5,
});
const result = await getLibraryStatsFromDb();
// Add chapters with timeSpent to test totalTimeSpent
await insertTestChapter(testDb, novelId1, { timeSpent: 100 });
await insertTestChapter(testDb, novelId1, { timeSpent: 200 });
await insertTestChapter(testDb, novelId2, { timeSpent: 300 });
expect(result.novelsCount).toBe(3);
// Novel not in library should be ignored
const nonLibNovelId = await insertTestNovel(testDb, {
inLibrary: false,
pluginId: 'source3',
totalChapters: 99,
chaptersDownloaded: 99,
chaptersUnread: 99,
});
await insertTestChapter(testDb, nonLibNovelId, { timeSpent: 999 });
const result = await getAggregateStatsFromDb();
// Triggers auto-increment totalChapters (1 per chapter insert) and chaptersUnread (1 per unread chapter insert)
// novelId1: totalChapters 10 -> 12 (2 chapters inserted), chaptersUnread 2 -> 4
// novelId2: totalChapters 5 -> 6 (1 chapter inserted), chaptersUnread 5 -> 6
expect(result.novelsCount).toBe(2);
expect(result.sourcesCount).toBe(2);
expect(result.chaptersCount).toBe(18); // (10+2) + (5+1) = 18
expect(result.chaptersDownloaded).toBe(4); // 3 + 1, trigger doesn't add (default isDownloaded=false)
expect(result.chaptersUnread).toBe(10); // (2+2) + (5+1) = 10
expect(result.chaptersRead).toBe(8); // 18 - 10 = 8
expect(result.totalTimeSpent).toBe(600); // 100 + 200 + 300
});
it('should return zero counts when library is empty', async () => {
const result = await getLibraryStatsFromDb();
it('should return zeros when library is empty', async () => {
const result = await getAggregateStatsFromDb();
expect(result.novelsCount).toBe(0);
expect(result.sourcesCount).toBe(0);
expect(result.chaptersCount).toBe(0);
expect(result.chaptersDownloaded).toBe(0);
expect(result.chaptersUnread).toBe(0);
expect(result.chaptersRead).toBe(0);
expect(result.totalTimeSpent).toBe(0);
});
it('should only count novels in library', async () => {
it('should only count in-library novels', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true });
@@ -73,259 +99,11 @@ describe('StatsQueries', () => {
await insertTestNovel(testDb, { inLibrary: false });
await insertTestNovel(testDb, { inLibrary: false });
const result = await getLibraryStatsFromDb();
const result = await getAggregateStatsFromDb();
expect(result.novelsCount).toBe(2);
});
});
describe('getChaptersTotalCountFromDb', () => {
it('should return correct total chapter count', async () => {
const testDb = getTestDb();
const novelId1 = await insertTestNovel(testDb, { inLibrary: true });
const novelId2 = await insertTestNovel(testDb, { inLibrary: true });
await insertTestNovel(testDb, { inLibrary: false }); // Not in library
await insertTestChapter(testDb, novelId1);
await insertTestChapter(testDb, novelId1);
await insertTestChapter(testDb, novelId2);
await insertTestChapter(testDb, novelId2);
await insertTestChapter(testDb, novelId2);
const result = await getChaptersTotalCountFromDb();
expect(result.chaptersCount).toBe(5);
});
it('should return zero when no chapters in library novels', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true });
const result = await getChaptersTotalCountFromDb();
expect(result.chaptersCount).toBe(0);
});
it('should only count chapters from library novels', async () => {
const testDb = getTestDb();
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
const nonLibraryNovelId = await insertTestNovel(testDb, {
inLibrary: false,
});
await insertTestChapter(testDb, libraryNovelId);
await insertTestChapter(testDb, libraryNovelId);
await insertTestChapter(testDb, nonLibraryNovelId);
const result = await getChaptersTotalCountFromDb();
expect(result.chaptersCount).toBe(2);
});
});
describe('getChaptersReadCountFromDb', () => {
it('should return correct count of read chapters', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { unread: false }); // Read
await insertTestChapter(testDb, novelId, { unread: false }); // Read
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
const result = await getChaptersReadCountFromDb();
expect(result.chaptersRead).toBe(2);
});
it('should return zero when no chapters are read', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { unread: true });
const result = await getChaptersReadCountFromDb();
expect(result.chaptersRead).toBe(0);
});
it('should only count chapters from library novels', async () => {
const testDb = getTestDb();
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
const nonLibraryNovelId = await insertTestNovel(testDb, {
inLibrary: false,
});
await insertTestChapter(testDb, libraryNovelId, { unread: false });
await insertTestChapter(testDb, nonLibraryNovelId, { unread: false });
const result = await getChaptersReadCountFromDb();
expect(result.chaptersRead).toBe(1);
});
});
describe('getChaptersUnreadCountFromDb', () => {
it('should return correct count of unread chapters', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
await insertTestChapter(testDb, novelId, { unread: true }); // Unread
await insertTestChapter(testDb, novelId, { unread: false }); // Read
const result = await getChaptersUnreadCountFromDb();
expect(result.chaptersUnread).toBe(2);
});
it('should return zero when all chapters are read', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { unread: false });
const result = await getChaptersUnreadCountFromDb();
expect(result.chaptersUnread).toBe(0);
});
});
describe('getChaptersDownloadedCountFromDb', () => {
it('should return correct count of downloaded chapters', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { isDownloaded: true });
await insertTestChapter(testDb, novelId, { isDownloaded: true });
await insertTestChapter(testDb, novelId, { isDownloaded: false });
const result = await getChaptersDownloadedCountFromDb();
expect(result.chaptersDownloaded).toBe(2);
});
it('should return zero when no chapters are downloaded', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { isDownloaded: false });
const result = await getChaptersDownloadedCountFromDb();
expect(result.chaptersDownloaded).toBe(0);
});
});
describe('getNovelGenresFromDb', () => {
it('should return correct genre distribution', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, {
inLibrary: true,
genres: 'Fantasy, Adventure',
});
await insertTestNovel(testDb, {
inLibrary: true,
genres: 'Fantasy, Romance',
});
await insertTestNovel(testDb, {
inLibrary: true,
genres: 'Sci-Fi',
});
await insertTestNovel(testDb, {
inLibrary: false,
genres: 'Horror', // Not in library
});
const result = await getNovelGenresFromDb();
expect(result.genres).toEqual({
Fantasy: 2,
Adventure: 1,
Romance: 1,
'Sci-Fi': 1,
});
});
it('should return empty genres when no novels in library', async () => {
const result = await getNovelGenresFromDb();
expect(result.genres).toEqual({});
});
it('should handle novels with null genres', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true, genres: null });
await insertTestNovel(testDb, { inLibrary: true, genres: 'Fantasy' });
const result = await getNovelGenresFromDb();
expect(result.genres).toEqual({ Fantasy: 1 });
});
it('should handle genres with extra whitespace', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, {
inLibrary: true,
genres: 'Fantasy, Adventure, Action',
});
const result = await getNovelGenresFromDb();
expect(result.genres).toHaveProperty('Fantasy');
expect(result.genres).toHaveProperty('Adventure');
expect(result.genres).toHaveProperty('Action');
});
});
describe('getNovelStatusFromDb', () => {
it('should return correct status distribution', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
await insertTestNovel(testDb, { inLibrary: true, status: 'Completed' });
await insertTestNovel(testDb, { inLibrary: true, status: 'Hiatus' });
await insertTestNovel(testDb, {
inLibrary: false,
status: 'Dropped', // Not in library
});
const result = await getNovelStatusFromDb();
expect(result.status).toEqual({
Ongoing: 2,
Completed: 1,
Hiatus: 1,
});
});
it('should return empty status when no novels in library', async () => {
const result = await getNovelStatusFromDb();
expect(result.status).toEqual({});
});
it('should handle novels with null status', async () => {
const testDb = getTestDb();
await insertTestNovel(testDb, { inLibrary: true, status: null });
await insertTestNovel(testDb, { inLibrary: true, status: 'Ongoing' });
const result = await getNovelStatusFromDb();
expect(result.status).toEqual({ Ongoing: 1 });
});
});
describe('getTopNovelsByTimeSpentFromDb', () => {
it('should return top novels ordered by total time spent descending', async () => {
const testDb = getTestDb();
@@ -362,7 +140,9 @@ describe('StatsQueries', () => {
const testDb = getTestDb();
const libraryNovelId = await insertTestNovel(testDb, { inLibrary: true });
const nonLibraryNovelId = await insertTestNovel(testDb, { inLibrary: false });
const nonLibraryNovelId = await insertTestNovel(testDb, {
inLibrary: false,
});
await insertTestChapter(testDb, libraryNovelId, { timeSpent: 150 });
await insertTestChapter(testDb, nonLibraryNovelId, { timeSpent: 999 });
@@ -464,26 +244,4 @@ describe('StatsQueries', () => {
expect(result.topCategoriesByTimeSpent).toEqual([]);
});
});
describe('getTotalTimeSpentFromDb', () => {
it('should sum timeSpent across all chapters', async () => {
const testDb = getTestDb();
const novelId = await insertTestNovel(testDb, { inLibrary: true });
await insertTestChapter(testDb, novelId, { timeSpent: 120 });
await insertTestChapter(testDb, novelId, { timeSpent: 180 });
await insertTestChapter(testDb, novelId, { timeSpent: 50 });
const result = await getTotalTimeSpentFromDb();
expect(result.totalTimeSpent).toBe(350);
});
it('should return 0 when there are no chapters or total is null/zero', async () => {
const result = await getTotalTimeSpentFromDb();
expect(result.totalTimeSpent).toBe(0);
});
});
});
+1
View File
@@ -81,6 +81,7 @@ export const testModules = {
'clearUpdates',
'getCustomPages',
'getNovelChapters',
'getAllNovelChaptersForBackup',
'getUnreadNovelChapters',
'getAllUndownloadedChapters',
'getAllUndownloadedAndUnreadChapters',
+2 -1
View File
@@ -91,7 +91,7 @@ export async function insertTestChapter(
position: 0,
progress: null,
scanlator: null,
timeSpent: 0,
timeSpent: 0,
...data,
novelId,
};
@@ -137,6 +137,7 @@ export async function insertTestRepository(
const { drizzleDb } = testDb;
const repoData: RepositoryInsert = {
url: data.url ?? `https://test-repo-${Date.now()}.example.com`,
enabled: data.enabled ?? true,
};
const result = await drizzleDb
+2 -1
View File
@@ -78,7 +78,8 @@ const MIGRATION_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS novel_category_unique ON NovelCategory (novelId, categoryId)`,
`CREATE TABLE IF NOT EXISTS Repository (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
url text NOT NULL
url text NOT NULL,
enabled integer DEFAULT true NOT NULL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS repository_url_unique ON Repository (url)`,
];
+18 -3
View File
@@ -8,7 +8,10 @@ BEGIN
chaptersUnread = chaptersUnread + CASE WHEN NEW.unread = 1 THEN 1 ELSE 0 END,
lastUpdatedAt = CASE
WHEN NEW.updatedTime IS NOT NULL
AND (lastUpdatedAt IS NULL OR NEW.updatedTime > lastUpdatedAt)
AND (
lastUpdatedAt IS NULL
OR julianday(NEW.updatedTime) > julianday(lastUpdatedAt)
)
THEN NEW.updatedTime
ELSE lastUpdatedAt
END
@@ -24,7 +27,13 @@ BEGIN
chaptersDownloaded = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id AND Chapter.isDownloaded = 1),
chaptersUnread = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id AND Chapter.unread = 1),
lastReadAt = (SELECT MAX(readTime) FROM Chapter WHERE Chapter.novelId = Novel.id),
lastUpdatedAt = (SELECT MAX(updatedTime) FROM Chapter WHERE Chapter.novelId = Novel.id)
lastUpdatedAt = (
SELECT updatedTime
FROM Chapter
WHERE Chapter.novelId = Novel.id AND updatedTime IS NOT NULL
ORDER BY julianday(updatedTime) DESC
LIMIT 1
)
WHERE id = NEW.novelId;
END;
`;
@@ -37,7 +46,13 @@ BEGIN
chaptersUnread = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id AND Chapter.unread = 1),
totalChapters = (SELECT COUNT(*) FROM Chapter WHERE Chapter.novelId = Novel.id),
lastReadAt = (SELECT MAX(readTime) FROM Chapter WHERE Chapter.novelId = Novel.id),
lastUpdatedAt = (SELECT MAX(updatedTime) FROM Chapter WHERE Chapter.novelId = Novel.id)
lastUpdatedAt = (
SELECT updatedTime
FROM Chapter
WHERE Chapter.novelId = Novel.id AND updatedTime IS NOT NULL
ORDER BY julianday(updatedTime) DESC
LIMIT 1
)
WHERE id = OLD.novelId;
END;
`;
+1
View File
@@ -10,6 +10,7 @@ export const repository = sqliteTable(
{
id: integer('id').primaryKey({ autoIncrement: true }),
url: text('url').notNull(),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
},
table => [uniqueIndex('repository_url_unique').on(table.url)],
);
+3
View File
@@ -51,6 +51,7 @@ export interface DownloadedChapter extends ChapterInfo {
}
export interface History extends ChapterInfo {
inLibrary: boolean | null;
pluginId: string;
novelName: string;
novelPath: string;
@@ -67,6 +68,7 @@ export interface Update extends ChapterInfo {
}
export interface UpdateOverview {
inLibrary: boolean | null;
novelId: number;
pluginId: string;
novelName: string;
@@ -126,4 +128,5 @@ export interface BackupCategory extends Category {
export interface Repository {
id: number;
url: string;
enabled: boolean;
}
@@ -0,0 +1,72 @@
import { act, renderHook } from '@testing-library/react-native';
import { Keyboard } from 'react-native';
import { useKeyboardHeight } from '../useKeyboardHeight';
describe('useKeyboardHeight', () => {
let showCallback: Function;
let hideCallback: Function;
let mockAddListener: jest.Mock;
const mockShowRemove = jest.fn();
const mockHideRemove = jest.fn();
beforeAll(() => {
mockAddListener = jest.fn((event: string, cb: Function) => {
if (event === 'keyboardDidShow') showCallback = cb;
if (event === 'keyboardDidHide') hideCallback = cb;
return event === 'keyboardDidShow'
? { remove: mockShowRemove }
: { remove: mockHideRemove };
});
jest.spyOn(Keyboard, 'addListener').mockImplementation(mockAddListener);
});
afterAll(() => {
jest.restoreAllMocks();
});
beforeEach(() => {
showCallback = () => { };
hideCallback = () => { };
mockShowRemove.mockClear();
mockHideRemove.mockClear();
mockAddListener.mockClear();
});
it('returns 0 initially', () => {
const { result } = renderHook(() => useKeyboardHeight());
expect(result.current).toBe(0);
});
it('after keyboardDidShow with endCoordinates.height = 350, returns 350', () => {
const { result } = renderHook(() => useKeyboardHeight());
act(() => {
showCallback({ endCoordinates: { height: 350 } });
});
expect(result.current).toBe(350);
});
it('after keyboardDidHide, returns 0', () => {
const { result } = renderHook(() => useKeyboardHeight());
act(() => {
showCallback({ endCoordinates: { height: 350 } });
});
expect(result.current).toBe(350);
act(() => {
hideCallback();
});
expect(result.current).toBe(0);
});
it('cleanup: on unmount, calls remove() on both listeners', () => {
const { unmount } = renderHook(() => useKeyboardHeight());
unmount();
expect(mockShowRemove).toHaveBeenCalledTimes(1);
expect(mockHideRemove).toHaveBeenCalledTimes(1);
});
});
+8 -3
View File
@@ -7,8 +7,10 @@ import {
useTheme,
} from '../persisted';
import Color from 'color';
import * as NavigationBar from 'expo-navigation-bar';
import { setStatusBarColor } from '@theme/utils/setBarColor';
import {
setNavigationBarTransparent,
setStatusBarColor,
} from '@theme/utils/setBarColor';
import { SystemBars } from 'react-native-edge-to-edge';
const useFullscreenMode = () => {
@@ -23,6 +25,7 @@ const useFullscreenMode = () => {
SystemBars.setHidden(true);
} else {
setStatusBarColor(Color(backgroundColor));
setNavigationBarTransparent(Color(backgroundColor).isDark());
}
}, [backgroundColor, fullScreenMode]);
@@ -37,6 +40,7 @@ const useFullscreenMode = () => {
*/
setStatusBarColor(theme);
setNavigationBarTransparent(theme.isDark);
}
}, [fullScreenMode, theme]);
@@ -51,8 +55,9 @@ const useFullscreenMode = () => {
useEffect(() => {
const unsubscribe = addListener('beforeRemove', () => {
StatusBar.setHidden(false);
NavigationBar.setVisibilityAsync('visible');
SystemBars.setHidden(false);
setStatusBarColor(theme);
setNavigationBarTransparent(theme.isDark);
});
return unsubscribe;
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { Keyboard, KeyboardEvent } from 'react-native';
export const useKeyboardHeight = () => {
const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
function onKeyboardDidShow(e: KeyboardEvent) {
setKeyboardHeight(e.endCoordinates.height);
}
function onKeyboardDidHide() {
setKeyboardHeight(0);
}
const showSubscription = Keyboard.addListener(
'keyboardDidShow',
onKeyboardDidShow,
);
const hideSubscription = Keyboard.addListener(
'keyboardDidHide',
onKeyboardDidHide,
);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
return keyboardHeight;
};
@@ -20,6 +20,7 @@ export const initialAppSettings = {
smartUpdateSkipUnstarted: false,
smartUpdateSkipWithUnread: false,
automaticLibraryUpdateIntervalHours: 0,
automaticBackupIntervalHours: 0,
updateLibraryOnLaunch: false,
downloadNewChapters: false,
refreshNovelMetadata: false,
@@ -0,0 +1,76 @@
import { renderHook, waitFor } from '@testing-library/react-native';
import useImport from '../useImport';
import { useLibraryContext } from '@components/Context/LibraryContext';
import { QueuedBackgroundTask } from '@services/backgroundTasks';
let mockQueue: QueuedBackgroundTask[] = [];
jest.mock('react-native-mmkv', () => ({
useMMKVObject: () => [mockQueue],
}));
jest.mock('@components/Context/LibraryContext', () => ({
useLibraryContext: jest.fn(),
}));
jest.mock('@services/backgroundTasks', () => ({
BACKGROUND_TASKS_STORE_KEY: 'tasks',
backgroundTasks: {
enqueue: jest.fn(),
resumeAll: jest.fn(),
pauseAll: jest.fn(),
cancelByType: jest.fn(),
},
}));
const mockUseLibraryContext = useLibraryContext as jest.MockedFunction<
typeof useLibraryContext
>;
const importTask = (id: string, progress = 0): QueuedBackgroundTask => ({
id,
task: { name: 'IMPORT_EPUB', data: { files: [] } },
state: 'running',
meta: {
name: 'Import',
isRunning: true,
progress,
progressText: undefined,
},
});
describe('useImport', () => {
const refetchLibrary = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
mockQueue = [];
refetchLibrary.mockClear();
mockUseLibraryContext.mockReturnValue({
refetchLibrary,
} as unknown as ReturnType<typeof useLibraryContext>);
});
it('ignores import progress updates and refetches when an import finishes', async () => {
mockQueue = [importTask('import-1')];
const { rerender } = renderHook(useImport);
mockQueue = [importTask('import-1', 0.5)];
rerender({});
expect(refetchLibrary).not.toHaveBeenCalled();
mockQueue = [];
rerender({});
await waitFor(() => expect(refetchLibrary).toHaveBeenCalledTimes(1));
});
it('does not refetch when an import is enqueued', () => {
const { rerender } = renderHook(useImport);
mockQueue = [importTask('import-1')];
rerender({});
expect(refetchLibrary).not.toHaveBeenCalled();
});
});
@@ -4,6 +4,7 @@ import {
filterAvailablePlugins,
filterInstalledPlugins,
getLastUsedPluginId,
reconcileInstalledPluginUpdates,
} from '../pluginSelectors';
const createPlugin = (id: string, name: string, lang: string): PluginItem => ({
@@ -57,4 +58,41 @@ describe('plugin selectors', () => {
expect(getLastUsedPluginId(englishPlugin)).toBe(englishPlugin.id);
expect(getLastUsedPluginId(undefined)).toBeUndefined();
});
it('clears stale update flags when an installed plugin is no longer available', () => {
const installedPlugin = { ...englishPlugin, hasUpdate: true };
expect(
reconcileInstalledPluginUpdates([installedPlugin], [], true),
).toEqual([{ ...installedPlugin, hasUpdate: false }]);
});
it('preserves update flags after an ordinary repository fetch failure', () => {
const installedPlugin = { ...englishPlugin, hasUpdate: true };
expect(reconcileInstalledPluginUpdates([installedPlugin], [])[0]).toBe(
installedPlugin,
);
});
it('uses metadata from the selected repository when an update is available', () => {
const installedPlugin = { ...englishPlugin, version: '1.0.0' };
const availablePlugin = {
...englishPlugin,
version: '2.0.0',
iconUrl: 'https://enabled.example.com/icon.png',
url: 'https://enabled.example.com/plugin.js',
};
expect(
reconcileInstalledPluginUpdates([installedPlugin], [availablePlugin]),
).toEqual([
{
...installedPlugin,
hasUpdate: true,
iconUrl: availablePlugin.iconUrl,
url: availablePlugin.url,
},
]);
});
});
+31
View File
@@ -1,4 +1,5 @@
import type { PluginItem } from '@plugins/types';
import { newer } from '@utils/compareVersion';
export const getLastUsedPluginId = (storedValue: unknown) => {
if (typeof storedValue === 'string') return storedValue;
@@ -40,3 +41,33 @@ export const filterAvailablePlugins = (
firstPlugin.name.localeCompare(secondPlugin.name),
);
};
export const reconcileInstalledPluginUpdates = (
installedPlugins: readonly PluginItem[],
availablePlugins: readonly PluginItem[],
clearUnavailableUpdates = false,
) => {
const availablePluginsById = new Map(
availablePlugins.map(plugin => [plugin.id, plugin]),
);
return installedPlugins.map(installedPlugin => {
const availablePlugin = availablePluginsById.get(installedPlugin.id);
if (
!availablePlugin ||
!newer(availablePlugin.version, installedPlugin.version)
) {
return clearUnavailableUpdates && installedPlugin.hasUpdate
? { ...installedPlugin, hasUpdate: false }
: installedPlugin;
}
return {
...installedPlugin,
hasUpdate: true,
iconUrl: availablePlugin.iconUrl,
url: availablePlugin.url,
};
});
};
+8 -2
View File
@@ -20,7 +20,10 @@ export default function useDownload() {
const downloadQueue = useMemo(
() => queue?.filter(t => t.task?.name === 'DOWNLOAD_CHAPTER') || [],
[queue],
) as { task: DownloadChapterTask; meta: BackgroundTaskMetadata }[];
) as (QueuedBackgroundTask & {
task: DownloadChapterTask;
meta: BackgroundTaskMetadata;
})[];
const downloadingChapterIds = useMemo(
() =>
@@ -52,7 +55,10 @@ export default function useDownload() {
chapters: [{ chapterId: chapter.id, chapterName: chapter.name }],
},
});
const downloadChapters = (novel: NovelInfo, chapters: ChapterInfo[]) =>
const downloadChapters = (
novel: NovelInfo,
chapters: Pick<ChapterInfo, 'id' | 'name'>[],
) =>
backgroundTasks.enqueue({
name: 'DOWNLOAD_CHAPTER',
data: {

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