Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb1a5d9e82 | |||
| 7e20f7dccb | |||
| 0fab0143b1 | |||
| 5a0717d2c7 | |||
| 09652f9962 | |||
| eef58f212f | |||
| 523aa90c19 | |||
| eb310f7821 | |||
| af2a224781 | |||
| 990cd4f2eb | |||
| 3ac611f633 | |||
| 909504a721 | |||
| 63349de1bb | |||
| c6679b7f41 | |||
| 15560b67b1 | |||
| 7883b28cd9 | |||
| 64707409bb | |||
| 7f1f76408b | |||
| 51560195ba | |||
| 084dcccabc | |||
| 3ad6e372fd | |||
| 179feb56ee | |||
| 67e01bc2df | |||
| 3ece098b94 | |||
| 586e085141 | |||
| e0c89cdd9f | |||
| 3bf025108a | |||
| f69e5d6a7e | |||
| a727c229ca | |||
| 57eca11a9e | |||
| 197d8670f8 | |||
| 675f19ef90 | |||
| 8a12529ba8 | |||
| c3482a8510 | |||
| e4246dee59 | |||
| 23f9b183be | |||
| 1eb8c587c7 | |||
| c3b75ebeb2 | |||
| 91358ad3dd | |||
| 13885320ae | |||
| b9d1abcf27 | |||
| c3260e8e01 | |||
| c0877a9b06 | |||
| 96fb52df17 | |||
| 76106317b6 | |||
| e75f4fba5d | |||
| 4dc933ee5a | |||
| bfb12b04aa | |||
| b7639ab55a | |||
| ecc5b84a64 | |||
| eb12bdfd62 | |||
| 9612bf6f89 | |||
| 0ed7d878b4 | |||
| 5f45e4c7f2 | |||
| ca24671a2f | |||
| ee3a4f20bc | |||
| 0c3542836c | |||
| f71c9c8594 | |||
| f53546cc01 | |||
| 594b078fad | |||
| ac1b2f5ce4 | |||
| 648d21062f | |||
| 89d715ce6b | |||
| bd527a02ef | |||
| 11117c1b54 | |||
| ba25d5b8fe | |||
| 31cb4b994a | |||
| 72ef618af1 | |||
| 936fd223bb | |||
| 6d26438d2d | |||
| 5eb5a55c4c | |||
| 6cd86d1422 | |||
| 9783c4d5e3 |
@@ -45,7 +45,7 @@ body:
|
||||
label: LNReader version
|
||||
description: You can find your LNReader version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "2.1.0"
|
||||
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.0](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
|
||||
|
||||
@@ -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.0](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
|
||||
|
||||
@@ -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,7 +146,16 @@ 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
|
||||
with:
|
||||
name: ${{ env.PRERELEASE_ARTIFACT_NAME }}
|
||||
@@ -156,3 +163,46 @@ jobs:
|
||||
if-no-files-found: error
|
||||
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 }}
|
||||
run: |
|
||||
short_sha="${GITHUB_SHA::8}"
|
||||
message="[${GITHUB_REPOSITORY}] New nightly build published: \`${short_sha}\`"
|
||||
|
||||
payload=$(jq -n \
|
||||
--arg title "$message" \
|
||||
--arg url "$ARTIFACT_URL" \
|
||||
'{
|
||||
username: "GitHub",
|
||||
avatar_url: "https://github.com/github.png",
|
||||
embeds: [{
|
||||
author: {
|
||||
name: "github-actions[bot]",
|
||||
icon_url: "https://github.com/github.png"
|
||||
},
|
||||
title: $title,
|
||||
url: $url,
|
||||
color: 3092790
|
||||
}]
|
||||
}')
|
||||
|
||||
curl --fail-with-body \
|
||||
--request POST \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "$payload" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
pnpm run type-check
|
||||
|
||||
@@ -1,5 +1,132 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Repository Conventions
|
||||
Guidance for AI coding agents working in this repository.
|
||||
|
||||
- Do not add or modify tests unless explicitly requested.
|
||||
## 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).
|
||||
|
||||
@@ -19,9 +19,33 @@ import Main from './src/navigators/Main';
|
||||
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
|
||||
import { useInitDatabase } from '@database/db';
|
||||
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();
|
||||
@@ -41,6 +65,7 @@ const ThemedPaperProvider = ({ children }: PropsWithChildren) => {
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
useRozeniteSqlitePlugin({ adapters: sqliteAdapters });
|
||||
const { success: databaseReady, error: databaseError } = useInitDatabase();
|
||||
const { ready: servicesReady, error: servicesError } =
|
||||
useInitializeAppServices(Boolean(databaseReady));
|
||||
@@ -67,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -35,8 +35,8 @@
|
||||
<a href="https://github.com/lnreader/lnreader/releases/latest">
|
||||
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/lnreader/lnreader?label=Stable&labelColor=0d7377&color=084c4e&style=flat">
|
||||
</a>
|
||||
<a href="https://github.com/lnreader/lnreader/releases/latest">
|
||||
<img alt="GitHub release (latest SemVer)" src="https://img.shields.io/github/v/release/lnreader/lnreader?include_prereleases&sort=semver&label=Beta&labelColor=3d3d5c&color=2a2a47&style=flat">
|
||||
<a href="https://github.com/lnreader/lnreader/actions/workflows/build.yml">
|
||||
<img alt="Build Preview" src="https://github.com/lnreader/lnreader/actions/workflows/build.yml/badge.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"expo": {
|
||||
"version": "2.1.0",
|
||||
"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": 4196352,
|
||||
"versionCode": 4196355,
|
||||
"package": "com.rajarsheechatterjee.LNReader",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/native/ic_launcher_foreground.png",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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':
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
DROP TRIGGER IF EXISTS `update_novel_stats`;--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS `update_novel_stats_on_update`;--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS `update_novel_stats_on_delete`;--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `__migration_Novel` AS SELECT * FROM `Novel`;--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `__migration_Chapter` AS SELECT * FROM `Chapter`;--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS `__migration_NovelCategory` AS SELECT * FROM `NovelCategory`;--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS `__new_Novel`;--> statement-breakpoint
|
||||
CREATE TABLE `__new_Novel` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`pluginId` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`cover` text,
|
||||
`summary` text,
|
||||
`author` text,
|
||||
`artist` text,
|
||||
`status` text DEFAULT 'Unknown',
|
||||
`genres` text,
|
||||
`inLibrary` integer DEFAULT false,
|
||||
`isLocal` integer DEFAULT false,
|
||||
`totalPages` integer DEFAULT 0,
|
||||
`chaptersDownloaded` integer DEFAULT 0,
|
||||
`chaptersUnread` integer DEFAULT 0,
|
||||
`totalChapters` integer DEFAULT 0,
|
||||
`lastReadAt` text,
|
||||
`lastUpdatedAt` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_Novel` (
|
||||
`id`,
|
||||
`path`,
|
||||
`pluginId`,
|
||||
`name`,
|
||||
`cover`,
|
||||
`summary`,
|
||||
`author`,
|
||||
`artist`,
|
||||
`status`,
|
||||
`genres`,
|
||||
`inLibrary`,
|
||||
`isLocal`,
|
||||
`totalPages`,
|
||||
`chaptersDownloaded`,
|
||||
`chaptersUnread`,
|
||||
`totalChapters`,
|
||||
`lastReadAt`,
|
||||
`lastUpdatedAt`
|
||||
)
|
||||
SELECT
|
||||
`__migration_Novel`.`id`,
|
||||
`__migration_Novel`.`path`,
|
||||
`__migration_Novel`.`pluginId`,
|
||||
`__migration_Novel`.`name`,
|
||||
`__migration_Novel`.`cover`,
|
||||
`__migration_Novel`.`summary`,
|
||||
`__migration_Novel`.`author`,
|
||||
`__migration_Novel`.`artist`,
|
||||
`__migration_Novel`.`status`,
|
||||
`__migration_Novel`.`genres`,
|
||||
`__migration_Novel`.`inLibrary`,
|
||||
`__migration_Novel`.`isLocal`,
|
||||
`__migration_Novel`.`totalPages`,
|
||||
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` (
|
||||
`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
|
||||
DROP TABLE `__migration_NovelCategory`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `novel_path_plugin_unique` ON `Novel` (`path`,`pluginId`);--> statement-breakpoint
|
||||
CREATE INDEX `NovelIndex` ON `Novel` (`pluginId`,`path`,`id`,`inLibrary`);
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -3,11 +3,15 @@
|
||||
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: {
|
||||
"20251222152612_past_mandrill": m0000,
|
||||
"20260612232322_normal_saracen": m0001,
|
||||
"20260719143427_long_moondragon": m0002
|
||||
}
|
||||
}
|
||||
'20251222152612_past_mandrill': m0000,
|
||||
'20260612232322_normal_saracen': m0001,
|
||||
'20260719143427_long_moondragon': m0002,
|
||||
'20260727081855_calm_chimera': m0003,
|
||||
'20260811071655_parched_human_torch': m0004,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+8
-2
@@ -1,3 +1,6 @@
|
||||
const { withRozenite } = require('@rozenite/metro');
|
||||
const { withRozeniteExpoAtlasPlugin } = require('@rozenite/expo-atlas-plugin');
|
||||
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -44,7 +47,7 @@ config.server.enhanceMiddleware = metroMiddleware => {
|
||||
response.setHeader(
|
||||
'Content-Type',
|
||||
readerAssetContentTypes[path.extname(assetPath)] ||
|
||||
'application/octet-stream',
|
||||
'application/octet-stream',
|
||||
);
|
||||
|
||||
if (request.method === 'HEAD') {
|
||||
@@ -55,4 +58,7 @@ config.server.enhanceMiddleware = metroMiddleware => {
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
module.exports = withRozenite(config, {
|
||||
enabled: process.env.WITH_ROZENITE === 'true',
|
||||
enhanceMetroConfig: metroConfig => withRozeniteExpoAtlasPlugin(metroConfig),
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -25,8 +25,28 @@ interface BackgroundTaskDao {
|
||||
@Query("UPDATE background_tasks SET state = :state, updatedAt = :updatedAt WHERE id = :id AND state = 'running'")
|
||||
suspend fun finishRunning(id: String, state: String, updatedAt: Long)
|
||||
|
||||
@Query("UPDATE background_tasks SET state = :state, attempt = attempt + 1, updatedAt = :updatedAt WHERE id = :id")
|
||||
suspend fun markRunning(id: String, state: String, updatedAt: Long)
|
||||
@Query(
|
||||
"""
|
||||
UPDATE background_tasks
|
||||
SET state = :state, attempt = attempt + 1, updatedAt = :updatedAt
|
||||
WHERE id = :id
|
||||
AND state = 'queued'
|
||||
AND (
|
||||
type != 'DOWNLOAD_CHAPTER'
|
||||
OR (
|
||||
SELECT COUNT(*)
|
||||
FROM background_tasks
|
||||
WHERE type = 'DOWNLOAD_CHAPTER' AND state = 'running'
|
||||
) < :maxConcurrentDownloads
|
||||
)
|
||||
""",
|
||||
)
|
||||
suspend fun tryMarkRunning(
|
||||
id: String,
|
||||
state: String,
|
||||
updatedAt: Long,
|
||||
maxConcurrentDownloads: Int,
|
||||
): Int
|
||||
|
||||
@Query("UPDATE background_tasks SET progress = :progress, progressText = :progressText, updatedAt = :updatedAt WHERE id = :id")
|
||||
suspend fun updateProgress(id: String, progress: Double?, progressText: String?, updatedAt: Long)
|
||||
@@ -36,4 +56,4 @@ interface BackgroundTaskDao {
|
||||
|
||||
@Query("UPDATE background_tasks SET workId = :workId, updatedAt = :updatedAt WHERE id = :id")
|
||||
suspend fun assignWork(id: String, workId: String, updatedAt: Long)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -2,10 +2,12 @@ package expo.modules.nativebackgroundtasks
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(entities = [BackgroundTaskEntity::class], version = 1, exportSchema = false)
|
||||
@Database(entities = [BackgroundTaskEntity::class], version = 2, exportSchema = false)
|
||||
abstract class BackgroundTaskDatabase : RoomDatabase() {
|
||||
abstract fun tasks(): BackgroundTaskDao
|
||||
|
||||
@@ -18,7 +20,18 @@ abstract class BackgroundTaskDatabase : RoomDatabase() {
|
||||
context.applicationContext,
|
||||
BackgroundTaskDatabase::class.java,
|
||||
"lnreader-background-tasks.db",
|
||||
).build().also { instance = it }
|
||||
).addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE background_tasks ADD COLUMN queueName TEXT NOT NULL " +
|
||||
"DEFAULT 'lnreader-background-task-queue'",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ data class BackgroundTaskEntity(
|
||||
val payload: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val queueName: String,
|
||||
val state: String,
|
||||
val progress: Double?,
|
||||
val progressText: String?,
|
||||
@@ -29,4 +30,4 @@ object BackgroundTaskState {
|
||||
const val CANCELLED = "cancelled"
|
||||
|
||||
val active = listOf(QUEUED, RUNNING, PAUSED)
|
||||
}
|
||||
}
|
||||
|
||||
+66
-2
@@ -1,16 +1,18 @@
|
||||
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 {
|
||||
private const val QUEUE_NAME = "lnreader-background-task-queue"
|
||||
const val TASK_ID = "taskId"
|
||||
|
||||
suspend fun enqueue(context: Context, taskId: String): UUID {
|
||||
@@ -28,13 +30,17 @@ object BackgroundTaskScheduler {
|
||||
BackgroundTaskDatabase.get(context).tasks()
|
||||
.assignWork(taskId, request.id.toString(), System.currentTimeMillis())
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
QUEUE_NAME,
|
||||
task.queueName,
|
||||
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
request,
|
||||
)
|
||||
return request.id
|
||||
}
|
||||
|
||||
suspend fun cancel(context: Context, taskId: String) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(taskId).await()
|
||||
}
|
||||
|
||||
suspend fun enqueueLibraryUpdate(
|
||||
context: Context,
|
||||
title: String,
|
||||
@@ -52,6 +58,63 @@ object BackgroundTaskScheduler {
|
||||
payload = """{"name":"$LIBRARY_UPDATE_TASK_TYPE"}""",
|
||||
title = title,
|
||||
description = description,
|
||||
queueName = "lnreader-background-task:task:$LIBRARY_UPDATE_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)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -76,4 +139,5 @@ object BackgroundTaskScheduler {
|
||||
)
|
||||
|
||||
private const val LIBRARY_UPDATE_TASK_TYPE = "UPDATE_LIBRARY"
|
||||
private const val LOCAL_BACKUP_TASK_TYPE = "LOCAL_BACKUP"
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-13
@@ -21,21 +21,30 @@ class LNReaderTaskWorker(
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
dao.markRunning(taskId, BackgroundTaskState.RUNNING, System.currentTimeMillis())
|
||||
val claimed = dao.tryMarkRunning(
|
||||
taskId,
|
||||
BackgroundTaskState.RUNNING,
|
||||
System.currentTimeMillis(),
|
||||
MAX_CONCURRENT_DOWNLOADS,
|
||||
)
|
||||
if (claimed == 0) {
|
||||
return if (dao.get(taskId)?.state == BackgroundTaskState.QUEUED) {
|
||||
Result.retry()
|
||||
} else {
|
||||
Result.success()
|
||||
}
|
||||
}
|
||||
val runningTask = dao.get(taskId) ?: return Result.failure()
|
||||
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
|
||||
@@ -48,7 +57,9 @@ class LNReaderTaskWorker(
|
||||
return Result.success()
|
||||
}
|
||||
dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, System.currentTimeMillis())
|
||||
dao.get(taskId)?.let { TaskNotificationFactory.update(applicationContext, it) }
|
||||
dao.get(taskId)?.let {
|
||||
TaskNotificationFactory.postTerminal(applicationContext, it)
|
||||
}
|
||||
Result.success()
|
||||
}
|
||||
is TaskExecutionResult.Failure -> {
|
||||
@@ -61,7 +72,9 @@ class LNReaderTaskWorker(
|
||||
Result.retry()
|
||||
} else {
|
||||
dao.updateState(taskId, BackgroundTaskState.FAILED, System.currentTimeMillis())
|
||||
dao.get(taskId)?.let { TaskNotificationFactory.update(applicationContext, it) }
|
||||
dao.get(taskId)?.let {
|
||||
TaskNotificationFactory.postTerminal(applicationContext, it)
|
||||
}
|
||||
Result.success()
|
||||
}
|
||||
}
|
||||
@@ -72,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)
|
||||
}
|
||||
@@ -86,4 +111,8 @@ class LNReaderTaskWorker(
|
||||
ForegroundInfo(id, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_CONCURRENT_DOWNLOADS = 3
|
||||
}
|
||||
}
|
||||
|
||||
+44
-22
@@ -27,7 +27,7 @@ class NativeBackgroundTasksModule : Module() {
|
||||
reactContextRef = null
|
||||
}
|
||||
|
||||
AsyncFunction("enqueue") { type: String, payload: String, title: String, description: String, allowsDuplicates: Boolean ->
|
||||
AsyncFunction("enqueue") { type: String, payload: String, title: String, description: String, allowsDuplicates: Boolean, queueName: String ->
|
||||
runBlocking(Dispatchers.IO) {
|
||||
if (!allowsDuplicates) {
|
||||
dao.getActiveByType(type)?.let { return@runBlocking it.id }
|
||||
@@ -39,6 +39,7 @@ class NativeBackgroundTasksModule : Module() {
|
||||
payload = payload,
|
||||
title = title,
|
||||
description = description,
|
||||
queueName = queueName,
|
||||
state = BackgroundTaskState.QUEUED,
|
||||
progress = null,
|
||||
progressText = null,
|
||||
@@ -56,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,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)
|
||||
}
|
||||
@@ -130,10 +121,12 @@ class NativeBackgroundTasksModule : Module() {
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("complete") { taskId: String ->
|
||||
AsyncFunction("complete") { taskId: String, completionText: String ->
|
||||
runBlocking(Dispatchers.IO) {
|
||||
dao.updateCheckpoint(taskId, null, System.currentTimeMillis())
|
||||
dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, System.currentTimeMillis())
|
||||
val now = System.currentTimeMillis()
|
||||
dao.updateCheckpoint(taskId, null, now)
|
||||
dao.updateProgress(taskId, null, completionText, now)
|
||||
dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, now)
|
||||
TaskExecutionRegistry.complete(taskId, TaskExecutionResult.Success)
|
||||
}
|
||||
}
|
||||
@@ -165,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
|
||||
|
||||
+2
-1
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-5
@@ -15,6 +15,7 @@ object TaskNotificationFactory {
|
||||
const val ACTION_RESUME = "expo.modules.nativebackgroundtasks.RESUME"
|
||||
const val ACTION_CANCEL = "expo.modules.nativebackgroundtasks.CANCEL"
|
||||
const val EXTRA_TASK_ID = "taskId"
|
||||
private const val TERMINAL_NOTIFICATION_MASK = 0x40000000
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
@@ -33,6 +34,9 @@ object TaskNotificationFactory {
|
||||
|
||||
fun notificationId(taskId: String): Int = taskId.hashCode().and(Int.MAX_VALUE).coerceAtLeast(1)
|
||||
|
||||
fun terminalNotificationId(taskId: String): Int =
|
||||
notificationId(taskId).xor(TERMINAL_NOTIFICATION_MASK).coerceAtLeast(1)
|
||||
|
||||
fun build(context: Context, task: BackgroundTaskEntity): Notification {
|
||||
ensureChannel(context)
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
@@ -66,7 +70,13 @@ object TaskNotificationFactory {
|
||||
.setContentText(contentText)
|
||||
.setContentIntent(contentIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setCategory(
|
||||
if (task.state in listOf(BackgroundTaskState.SUCCEEDED, BackgroundTaskState.FAILED)) {
|
||||
NotificationCompat.CATEGORY_STATUS
|
||||
} else {
|
||||
NotificationCompat.CATEGORY_PROGRESS
|
||||
},
|
||||
)
|
||||
.setOngoing(task.state == BackgroundTaskState.RUNNING || task.state == BackgroundTaskState.QUEUED)
|
||||
.setAutoCancel(task.state == BackgroundTaskState.SUCCEEDED || task.state == BackgroundTaskState.FAILED)
|
||||
.setOnlyAlertOnce(true)
|
||||
@@ -78,15 +88,22 @@ object TaskNotificationFactory {
|
||||
|
||||
val progress = task.progress
|
||||
if (task.state == BackgroundTaskState.SUCCEEDED) {
|
||||
builder.setContentText("Completed").setProgress(0, 0, false)
|
||||
builder.setContentText(contentText).setProgress(0, 0, false)
|
||||
} else if (task.state == BackgroundTaskState.FAILED) {
|
||||
builder.setContentText("Failed: $contentText").setProgress(0, 0, false)
|
||||
builder.setContentText(contentText).setProgress(0, 0, false)
|
||||
} else if (progress == null) {
|
||||
builder.setProgress(100, 0, true)
|
||||
} else {
|
||||
builder.setProgress(100, (progress.coerceIn(0.0, 1.0) * 100).toInt(), false)
|
||||
}
|
||||
|
||||
if (
|
||||
task.state in listOf(BackgroundTaskState.SUCCEEDED, BackgroundTaskState.FAILED) &&
|
||||
contentText.isNotBlank()
|
||||
) {
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(contentText))
|
||||
}
|
||||
|
||||
if (
|
||||
task.type == "UPDATE_LIBRARY" &&
|
||||
task.state in listOf(BackgroundTaskState.QUEUED, BackgroundTaskState.RUNNING, BackgroundTaskState.PAUSED) &&
|
||||
@@ -107,9 +124,17 @@ object TaskNotificationFactory {
|
||||
.notify(notificationId(task.id), build(context, task))
|
||||
}
|
||||
|
||||
fun dismiss(context: Context, taskId: String) {
|
||||
fun postTerminal(context: Context, task: BackgroundTaskEntity) {
|
||||
require(task.state in listOf(BackgroundTaskState.SUCCEEDED, BackgroundTaskState.FAILED))
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.cancel(notificationId(taskId))
|
||||
.notify(terminalNotificationId(task.id), build(context, task))
|
||||
}
|
||||
|
||||
fun dismiss(context: Context, taskId: String) {
|
||||
context.getSystemService(NotificationManager::class.java).apply {
|
||||
cancel(notificationId(taskId))
|
||||
cancel(terminalNotificationId(taskId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionIntent(
|
||||
|
||||
@@ -22,8 +22,10 @@ type NativeBackgroundTasksModule = {
|
||||
title: string,
|
||||
description: string,
|
||||
allowsDuplicates: boolean,
|
||||
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>;
|
||||
@@ -33,7 +35,7 @@ type NativeBackgroundTasksModule = {
|
||||
progressText: string,
|
||||
): Promise<void>;
|
||||
updateCheckpoint(taskId: string, checkpoint: string): Promise<void>;
|
||||
complete(taskId: string): Promise<void>;
|
||||
complete(taskId: string, completionText: string): Promise<void>;
|
||||
fail(taskId: string, error: string, shouldRetry: boolean): Promise<void>;
|
||||
scheduleLibraryUpdates(
|
||||
intervalHours: number,
|
||||
@@ -41,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>(
|
||||
|
||||
+263
-8
@@ -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 ?? ""
|
||||
|
||||
@@ -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');
|
||||
|
||||
+7
-9
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
+116
-8
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lnreader",
|
||||
"version": "2.1.0",
|
||||
"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"
|
||||
@@ -108,15 +105,20 @@
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@eslint/eslintrc": "^3.3.6",
|
||||
"@rozenite/expo-atlas-plugin": "^1.13.0",
|
||||
"@rozenite/metro": "^1.13.0",
|
||||
"@rozenite/sqlite-plugin": "^1.13.0",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/color": "^4.2.1",
|
||||
"@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",
|
||||
@@ -137,6 +139,11 @@
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"expo": {
|
||||
"autolinking": {
|
||||
"exclude": [
|
||||
"@expo/dom-webview"
|
||||
]
|
||||
},
|
||||
"doctor": {
|
||||
"reactNativeDirectoryCheck": {
|
||||
"exclude": [
|
||||
@@ -147,7 +154,6 @@
|
||||
"nitro-epub",
|
||||
"native-background-tasks",
|
||||
"@react-native-vector-icons/material-design-icons",
|
||||
"react-native-saf-x",
|
||||
"lottie-ios"
|
||||
]
|
||||
}
|
||||
@@ -165,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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Generated
+1697
-748
File diff suppressed because it is too large
Load Diff
@@ -169,12 +169,14 @@ const releaseDate = args['release-date'] || formatUtcDate(new Date());
|
||||
const nodeEnv =
|
||||
args['node-env'] ||
|
||||
(buildType.toLowerCase().includes('release') ? 'production' : 'development');
|
||||
const rozeniteEnabled = nodeEnv !== 'production';
|
||||
|
||||
const generatedEnvContent = [
|
||||
formatEnvEntry('BUILD_TYPE', buildType),
|
||||
formatEnvEntry('GIT_HASH', gitHash),
|
||||
formatEnvEntry('RELEASE_DATE', releaseDate),
|
||||
formatEnvEntry('NODE_ENV', nodeEnv),
|
||||
formatEnvEntry('WITH_ROZENITE', rozeniteEnabled),
|
||||
formatEnvEntry('MYANIMELIST_CLIENT_ID', myanimelistClientId),
|
||||
formatEnvEntry('ANILIST_CLIENT_ID', anilistClientId),
|
||||
'',
|
||||
@@ -243,6 +245,7 @@ try {
|
||||
GIT_HASH: gitHash,
|
||||
RELEASE_DATE: releaseDate,
|
||||
NODE_ENV: nodeEnv,
|
||||
WITH_ROZENITE: rozeniteEnabled ? 'enabled' : 'disabled',
|
||||
MYANIMELIST_CLIENT_ID: myanimelistClientId
|
||||
? 'configured'
|
||||
: 'not configured',
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppUpdateChecker';
|
||||
+69
-55
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -53,7 +53,7 @@ function CustomBottomTabBar({
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.surface2 || theme.surface,
|
||||
paddingBottom: insets?.bottom || 0,
|
||||
paddingBottom: 16 + (insets?.bottom || 0),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -148,7 +148,6 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
position: 'relative',
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Checkbox as PaperCheckbox } from 'react-native-paper';
|
||||
@@ -20,6 +21,8 @@ interface CheckboxProps {
|
||||
theme: ThemeColors;
|
||||
viewStyle?: StyleProp<ViewStyle>;
|
||||
labelStyle?: StyleProp<TextStyle>;
|
||||
description?: string;
|
||||
descriptionStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
export const Checkbox: React.FC<CheckboxProps> = ({
|
||||
@@ -30,8 +33,17 @@ export const Checkbox: React.FC<CheckboxProps> = ({
|
||||
onPress,
|
||||
viewStyle,
|
||||
labelStyle,
|
||||
description,
|
||||
descriptionStyle,
|
||||
}) => (
|
||||
<Pressable
|
||||
accessibilityHint={description}
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{
|
||||
checked: status === 'indeterminate' ? 'mixed' : status,
|
||||
disabled,
|
||||
}}
|
||||
android_ripple={{ color: theme.rippleColor }}
|
||||
style={[styles.pressable, viewStyle]}
|
||||
onPress={onPress}
|
||||
@@ -53,9 +65,28 @@ export const Checkbox: React.FC<CheckboxProps> = ({
|
||||
uncheckedColor={theme.onSurfaceVariant}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text style={[styles.defaultLabel, { color: theme.onSurface }, labelStyle]}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.textContainer}>
|
||||
<Text
|
||||
style={[styles.defaultLabel, { color: theme.onSurface }, labelStyle]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
color: disabled
|
||||
? theme.onSurfaceDisabled
|
||||
: theme.onSurfaceVariant,
|
||||
},
|
||||
descriptionStyle,
|
||||
]}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -85,7 +116,13 @@ export const SortItem = ({ label, status, onPress, theme }: SortItemProps) => (
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
defaultLabel: {
|
||||
marginStart: 12,
|
||||
flexShrink: 1,
|
||||
},
|
||||
description: {
|
||||
flexShrink: 1,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginTop: 2,
|
||||
},
|
||||
icon: {
|
||||
alignSelf: 'center',
|
||||
@@ -99,4 +136,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 6,
|
||||
},
|
||||
sortItem: { paddingVertical: 16, paddingStart: 64 },
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
marginStart: 12,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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]}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react-native';
|
||||
|
||||
import { Checkbox } from '../Checkbox/Checkbox';
|
||||
import type { ThemeColors } from '../../theme/types';
|
||||
|
||||
const mockTheme = {
|
||||
onSurface: '#111111',
|
||||
onSurfaceDisabled: '#777777',
|
||||
onSurfaceVariant: '#555555',
|
||||
primary: '#6200ee',
|
||||
rippleColor: '#eeeeee',
|
||||
} as ThemeColors;
|
||||
|
||||
describe('Checkbox', () => {
|
||||
it('renders an accessible description below the label', () => {
|
||||
render(
|
||||
<Checkbox
|
||||
description="Include app preferences"
|
||||
label="Settings"
|
||||
onPress={() => {}}
|
||||
status
|
||||
theme={mockTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'Settings' });
|
||||
|
||||
expect(checkbox.props.accessibilityState).toEqual({
|
||||
checked: true,
|
||||
disabled: undefined,
|
||||
});
|
||||
expect(checkbox.props.accessibilityHint).toBe('Include app preferences');
|
||||
expect(screen.getByText('Include app preferences')).toBeOnTheScreen();
|
||||
});
|
||||
|
||||
it('centers the checkbox against the label and description block', () => {
|
||||
render(
|
||||
<Checkbox
|
||||
description="Include app preferences"
|
||||
label="Settings"
|
||||
status={false}
|
||||
theme={mockTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: 'Settings' })).toHaveStyle({
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
+199
-8
@@ -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 {
|
||||
@@ -30,6 +31,23 @@ const INITIAL_MIGRATION_NAME = '20251222152612_past_mandrill';
|
||||
const INITIAL_MIGRATION_CREATED_AT = 1766417172000;
|
||||
const SCANLATOR_MIGRATION_NAME = '20260612232322_normal_saracen';
|
||||
const SCANLATOR_MIGRATION_CREATED_AT = 1781306602000;
|
||||
const TIME_SPENT_MIGRATION_NAME = '20260719143427_long_moondragon';
|
||||
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,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Raw SQLite database instance
|
||||
@@ -60,6 +78,161 @@ type MigrationExecutor = {
|
||||
executeSync: (sql: string) => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recovers the one interruption point where the previous repair migration
|
||||
* could have dropped Novel before renaming its populated replacement.
|
||||
*/
|
||||
export const repairInterruptedNovelMigration = (
|
||||
executor: MigrationExecutor,
|
||||
) => {
|
||||
const tableNames = new Set(
|
||||
executor
|
||||
.executeRawSync("SELECT name FROM sqlite_master WHERE type = 'table';")
|
||||
.map(row => row[0]),
|
||||
);
|
||||
const hasNovel = tableNames.has('Novel');
|
||||
const hasNovelSnapshot = tableNames.has('__migration_Novel');
|
||||
const hasNewNovel = tableNames.has('__new_Novel');
|
||||
const hasMigrationState =
|
||||
hasNovelSnapshot ||
|
||||
hasNewNovel ||
|
||||
tableNames.has('__migration_Chapter') ||
|
||||
tableNames.has('__migration_NovelCategory');
|
||||
|
||||
if (!hasMigrationState || hasNovel || hasNovelSnapshot) {
|
||||
return;
|
||||
}
|
||||
if (!hasNewNovel) {
|
||||
throw new Error(
|
||||
'Cannot recover interrupted Novel migration: no source table remains',
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -94,17 +267,20 @@ export const repairMigrationHistory = (executor: MigrationExecutor) => {
|
||||
`);
|
||||
|
||||
const chapterColumns = executor.executeRawSync('PRAGMA table_info(Chapter);');
|
||||
const hasScanlator = chapterColumns.some(row => row[1] === 'scanlator');
|
||||
const chapterColumnNames = new Set(chapterColumns.map(row => row[1]));
|
||||
|
||||
if (hasScanlator) {
|
||||
for (const migration of CHAPTER_COLUMN_MIGRATIONS) {
|
||||
if (!chapterColumnNames.has(migration.columnName)) {
|
||||
continue;
|
||||
}
|
||||
executor.executeSync(`
|
||||
INSERT INTO __drizzle_migrations
|
||||
(hash, created_at, name, applied_at)
|
||||
SELECT '', ${SCANLATOR_MIGRATION_CREATED_AT},
|
||||
'${SCANLATOR_MIGRATION_NAME}', datetime('now')
|
||||
SELECT '', ${migration.createdAt},
|
||||
'${migration.migrationName}', datetime('now')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM __drizzle_migrations
|
||||
WHERE name = '${SCANLATOR_MIGRATION_NAME}'
|
||||
WHERE name = '${migration.migrationName}'
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -180,10 +356,25 @@ let initialization: Promise<void> | undefined;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ export const testModules = {
|
||||
'clearUpdates',
|
||||
'getCustomPages',
|
||||
'getNovelChapters',
|
||||
'getAllNovelChaptersForBackup',
|
||||
'getUnreadNovelChapters',
|
||||
'getAllUndownloadedChapters',
|
||||
'getAllUndownloadedAndUnreadChapters',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)`,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user