Compare commits

...

36 Commits

Author SHA1 Message Date
Huang Xin aa3cd2a0c5 txt: trim punctuations in parsed author names (#1707)
* txt: trim punctuations in parsed author names

* release: version 0.9.68
2025-07-29 20:50:21 +02:00
Huang Xin c581f8ab47 txt: more robust TXT parsing (#1706) 2025-07-29 20:06:55 +02:00
Huang Xin 63ed763499 api: handle downloading books of sanitized titles (#1705) 2025-07-29 18:23:19 +08:00
Huang Xin 213fc0d21e api: handle more string sanitization (#1702) 2025-07-28 19:12:42 +02:00
Huang Xin 9bc1494582 api: sanitize string before saving into db (#1701) 2025-07-28 18:44:26 +02:00
Huang Xin 33b2ba1609 chore: add legal links in about window and user page (#1700) 2025-07-28 18:19:45 +02:00
Huang Xin b9add62ba7 translation: skip translating pre, code and math tags, closes #1693 (#1698) 2025-07-28 09:18:19 +02:00
Huang Xin 23fa2dc8b2 css: fix hard-coded font color, closes #1695 (#1697) 2025-07-28 08:36:10 +02:00
Huang Xin 67ac09a290 premium: increased cloud sync storage for premium users (#1696) 2025-07-28 08:25:22 +02:00
Huang Xin f10c14ae96 api: batch updating daily usage key in KV (#1694) 2025-07-28 06:25:47 +02:00
Huang Xin 438d3ae0c2 config: default to open file with new window (#1691) 2025-07-27 19:12:19 +02:00
Huang Xin 842062fe44 perf: multi-part download with range access (#1690) 2025-07-27 18:56:41 +02:00
Huang Xin d01897d62a fix: load backup library data if main library data is unavailable, closes #1672 (#1689) 2025-07-27 16:48:58 +02:00
Huang Xin 6eeb8e7580 layout: fix insets for double borders and add book spine decorator (#1688) 2025-07-27 14:36:55 +02:00
Huang Xin 9cebadba57 font: fix broken links for online CJK fonts (#1687) 2025-07-27 08:25:25 +02:00
Huang Xin e5ec9abca3 chore: suppress warnings from old objc crate (#1686) 2025-07-27 08:03:45 +02:00
Huang Xin 5e7f09d5c8 perf: eliminate redundant re-renders of book cover components (#1685) 2025-07-27 07:02:05 +02:00
Huang Xin ffa193d116 fix: importing books from url and set downloaded timestamp for reimported books (#1684) 2025-07-26 14:34:51 +02:00
Huang Xin 686f8988bc api: use node api endpoint for iap verifying (#1683) 2025-07-26 13:34:36 +02:00
Huang Xin 8d5c98004b api: ensure proper string decoded on edge runtimes (#1680) 2025-07-25 21:13:01 +02:00
Huang Xin 214411919f iap: use sandbox environment for testflight (#1679) 2025-07-25 19:49:53 +02:00
Huang Xin 42c2825e90 feat: enable IAP for upgrading to Readest Premium on iOS (#1678) 2025-07-25 19:08:53 +02:00
Huang Xin 8974a87168 feat: add IAP server api (#1676) 2025-07-25 14:44:56 +02:00
Huang Xin f3e9983742 feat: add IAP in native bridge plugin for iOS (#1673) 2025-07-24 14:54:17 +02:00
初五 beaf034035 docs: fix outdated repository links and spelling error (#1669)
* docs: update outdated repository links in CONTRIBUTING.md

- Update issue tracker link from chrox/readest to readest/readest    
- Update new issue link from chrox/readest to readest/readest

* fix: correct spelling of "quota" in release notes

- Fix typo "quata" -> "quota" in 0.9.30 release notes
2025-07-23 20:00:55 +02:00
Huang Xin a5f18ab024 release: version 0.9.67 (#1664) 2025-07-22 16:11:00 +02:00
Huang Xin d1f5934947 library: various fixes on metadata editor and bookshelf (#1663)
* i18n: update translations

* library: various fixes on metadata editor and bookshelf

* chore: enable parallel web builds checking
2025-07-22 16:04:44 +02:00
Huang Xin f03d6f6861 feat: search in book format, group names and descriptions (#1662) 2025-07-22 11:40:14 +02:00
Huang Xin 20523b7eaf PDF: Support custom background theming for PDF files, closes #1649 (#1661) 2025-07-22 11:34:38 +02:00
Huang Xin f31c9b2a98 layout: fix hardcoded image layout in fixed layout documents (#1660) 2025-07-22 10:40:21 +02:00
Huang Xin 93071e727f linux: fixed multiple instances created in OAuth, closes #1654 (#1659) 2025-07-22 09:00:19 +02:00
Huang Xin 2d12210606 css: multiply img color in light mode when overriding book color (#1656)
This fixed image color issue for EPUBs in #1649.
2025-07-21 18:17:16 +02:00
Huang Xin 88ccc528d4 layout: hover header to show traffic light window control on macOS, closes #1645 (#1653) 2025-07-21 17:16:58 +02:00
Yurii Kostyukov 9d3fc07865 feat: Added Yandex Translator (#1652) 2025-07-21 17:13:13 +02:00
Huang Xin 6d465a40fb fix: don't use comma as separator when parsing filenames, closes #1622 (#1650) 2025-07-21 15:32:10 +02:00
Huang Xin 01c7e4c2e2 chore: fixed failed AppImage builds for Linux 2025-07-21 20:39:57 +08:00
119 changed files with 4990 additions and 986 deletions
+13 -1
View File
@@ -32,6 +32,11 @@ jobs:
build_web_app:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- platform: 'web'
- platform: 'tauri'
steps:
- uses: actions/checkout@v4
with:
@@ -54,6 +59,13 @@ jobs:
pnpm install && pnpm setup-pdfjs
- name: build the web App
if: matrix.config.platform == 'web'
working-directory: apps/readest-app
run: |
pnpm build-check
pnpm build-web && pnpm check:all
- name: build the Tauri App
if: matrix.config.platform == 'tauri'
working-directory: apps/readest-app
run: |
pnpm build && pnpm check:all
+2 -2
View File
@@ -3,14 +3,14 @@
When contributing to `Readest`, whether on GitHub or in other community spaces:
- Be respectful, civil, and open-minded.
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/readest/readest/issues) for known issues or fixes.
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
## How to Contribute
### Prerequisites
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/readest/readest/issues/new/choose) describing the problem you would like to solve.
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
Generated
+138 -99
View File
@@ -14,6 +14,7 @@ version = "0.2.2"
dependencies = [
"block",
"cocoa",
"futures",
"futures-util",
"log",
"objc",
@@ -28,7 +29,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build 2.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"tauri-build",
"tauri-plugin-cli",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
@@ -249,7 +250,7 @@ dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.1",
"rand 0.9.2",
"raw-window-handle",
"serde",
"serde_repr",
@@ -1507,6 +1508,15 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "file-id"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bc904b9bbefcadbd8e3a9fb0d464a9b979de6324c03b3c663e8994f46a5be36"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "filetime"
version = "0.2.25"
@@ -1608,6 +1618,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
@@ -2447,6 +2466,26 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.9.1",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -2613,6 +2652,26 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -2850,6 +2909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
@@ -2948,6 +3008,46 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "notify"
version = "8.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3163f59cd3fa0e9ef8c32f242966a7b9994fd7378366099593e0e73077cd8c97"
dependencies = [
"bitflags 2.9.1",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-full"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2d88b1a7538054351c8258338df7c931a590513fb3745e8c15eb9ff4199b8d1"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
dependencies = [
"serde",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -3881,7 +3981,7 @@ dependencies = [
"bitflags 2.9.1",
"lazy_static",
"num-traits",
"rand 0.9.1",
"rand 0.9.2",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
@@ -4001,7 +4101,7 @@ dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.1",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -4075,9 +4175,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
@@ -4186,9 +4286,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.5.13"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec"
dependencies = [
"bitflags 2.9.1",
]
@@ -5242,7 +5342,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.6.2"
version = "2.7.0"
dependencies = [
"anyhow",
"bytes",
@@ -5282,11 +5382,11 @@ dependencies = [
"specta",
"swift-rs",
"tauri",
"tauri-build 2.3.0",
"tauri-build",
"tauri-macros",
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils 2.5.0",
"tauri-utils",
"thiserror 2.0.12",
"tokio",
"tracing",
@@ -5302,7 +5402,7 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.3.0"
version = "2.3.1"
dependencies = [
"anyhow",
"cargo_toml",
@@ -5316,29 +5416,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-codegen",
"tauri-utils 2.5.0",
"tauri-winres",
"toml 0.8.23",
"walkdir",
]
[[package]]
name = "tauri-build"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"glob",
"heck 0.5.0",
"json-patch",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"tauri-utils 2.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
"tauri-utils",
"tauri-winres",
"toml 0.8.23",
"walkdir",
@@ -5346,7 +5424,7 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.3.0"
version = "2.3.1"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -5361,7 +5439,7 @@ dependencies = [
"serde_json",
"sha2",
"syn 2.0.104",
"tauri-utils 2.5.0",
"tauri-utils",
"thiserror 2.0.12",
"time",
"url",
@@ -5371,21 +5449,21 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.3.1"
version = "2.3.2"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.104",
"tauri-codegen",
"tauri-utils 2.5.0",
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.3.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9a0bd00bf1930ad1a604d08b0eb6b2a9c1822686d65d7f4731a7723b8901d3"
checksum = "5bd5c1e56990c70a906ef67a9851bbdba9136d26075ee9a2b19c8b46986b3e02"
dependencies = [
"anyhow",
"glob",
@@ -5393,7 +5471,7 @@ dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils 2.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
"tauri-utils",
"toml 0.8.23",
"walkdir",
]
@@ -5415,7 +5493,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.0"
version = "2.4.1"
dependencies = [
"dunce",
"rust-ini",
@@ -5423,7 +5501,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils 2.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
"tauri-utils",
"thiserror 2.0.12",
"tracing",
"url",
@@ -5433,9 +5511,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aefb14219b492afb30b12647b5b1247cadd2c0603467310c36e0f7ae1698c28"
version = "2.3.1"
dependencies = [
"log",
"raw-window-handle",
@@ -5451,13 +5527,13 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c341290d31991dbca38b31d412c73dfbdb070bb11536784f19dd2211d13b778f"
version = "2.4.1"
dependencies = [
"anyhow",
"dunce",
"glob",
"notify",
"notify-debouncer-full",
"percent-encoding",
"schemars 0.8.22",
"serde",
@@ -5465,7 +5541,7 @@ dependencies = [
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils 2.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
"tauri-utils",
"thiserror 2.0.12",
"toml 0.8.23",
"url",
@@ -5487,9 +5563,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-http"
version = "2.5.0"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c1a38da944b357ffa23bafd563b1579f18e6fbd118fcd84769406d35dcc5c7"
checksum = "fcde333d97e565a7765aad82f32d8672458f7bd77b6ee653830d5dded9d7b5c2"
dependencies = [
"bytes",
"cookie_store",
@@ -5654,9 +5730,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b441b6d5d1a194e9fee0b358fe0d602ded845d0f580e1f8c8ef78ebc3c8b225d"
checksum = "e2e5df105284154e5ec277e1b42d022cb2ccd0da08a6bc10eea70eb2a60c9475"
dependencies = [
"serde",
"serde_json",
@@ -5701,9 +5777,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-window-state"
version = "2.3.0"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a3d22b21b9cec73601b512a868f7c74f93c044d44fd6ca1c84e9d6afb6b1559"
checksum = "2d5f6fe3291bfa609c7e0b0ee3bedac294d94c7018934086ce782c1d0f2a468e"
dependencies = [
"bitflags 2.9.1",
"log",
@@ -5716,7 +5792,7 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.7.0"
version = "2.7.1"
dependencies = [
"cookie",
"dpi",
@@ -5728,7 +5804,7 @@ dependencies = [
"raw-window-handle",
"serde",
"serde_json",
"tauri-utils 2.5.0",
"tauri-utils",
"thiserror 2.0.12",
"url",
"windows",
@@ -5736,7 +5812,7 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.7.1"
version = "2.7.2"
dependencies = [
"gtk",
"http",
@@ -5751,7 +5827,7 @@ dependencies = [
"softbuffer",
"tao",
"tauri-runtime",
"tauri-utils 2.5.0",
"tauri-utils",
"tracing",
"url",
"webkit2gtk",
@@ -5762,7 +5838,7 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.5.0"
version = "2.6.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -5801,43 +5877,6 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-utils"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41743bbbeb96c3a100d234e5a0b60a46d5aa068f266160862c7afdbf828ca02e"
dependencies = [
"anyhow",
"cargo_metadata",
"ctor",
"dunce",
"glob",
"html5ever",
"http",
"infer",
"json-patch",
"kuchikiki",
"log",
"memchr",
"phf 0.11.3",
"proc-macro2",
"quote",
"regex",
"schemars 0.8.22",
"semver",
"serde",
"serde-untagged",
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.12",
"toml 0.8.23",
"url",
"urlpattern",
"uuid",
"walkdir",
]
[[package]]
name = "tauri-winres"
version = "0.3.1"
+16 -5
View File
@@ -2,6 +2,10 @@
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
"packages/tauri/crates/tauri-utils",
"packages/tauri/crates/tauri-build",
"packages/tauri-plugins/plugins/fs",
"packages/tauri-plugins/plugins/dialog",
"packages/tauri-plugins/plugins/deep-link",
]
resolver = "2"
@@ -10,12 +14,15 @@ resolver = "2"
serde = { version = "1", features = ["derive"] }
tracing = "0.1"
log = "0.4"
tauri = { version = "2.6", default-features = false }
tauri-build = "2.3"
tauri-plugin = "2.3"
tauri-utils = "2.5"
tauri = { version = "2", default-features = false }
tauri-build = "2"
tauri-plugin = "2"
tauri-utils = "2"
schemars = "0.8"
serde_json = "1"
thiserror = "2"
glob = "0.3"
dunce = "1"
url = "2"
[workspace.package]
@@ -29,4 +36,8 @@ rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
tauri-utils = { path = "packages/tauri/crates/tauri-utils" }
tauri-build = { path = "packages/tauri/crates/tauri-build" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
tauri-plugin-dialog = { path = "packages/tauri-plugins/plugins/dialog" }
tauri-plugin-deep-link = { path = "packages/tauri-plugins/plugins/deep-link" }
+1
View File
@@ -57,6 +57,7 @@
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
| **Translate with Yandex** | Instantly translate text or books using Yandex Translate. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
+15 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.66",
"version": "0.9.68",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -11,6 +11,7 @@
"start-web": "dotenv -e .env.web -- next start",
"i18n:extract": "i18next-scanner",
"lint": "next lint",
"test": "dotenv -e .env -e .env.test.local vitest",
"tauri": "tauri",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
@@ -32,7 +33,7 @@
"release-ios-appstore": "dotenv -e .env.ios-appstore.local -- bash scripts/release-ios-appstore.sh",
"release-google-play": "dotenv -e .env.google-play.local -- bash scripts/release-google-play.sh",
"config-wrangler": "sed -i \"s/\\${TRANSLATIONS_KV_ID}/$TRANSLATIONS_KV_ID/g\" wrangler.toml",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview",
"preview": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare preview --ip 0.0.0.0",
"deploy": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
@@ -45,7 +46,7 @@
"@aws-sdk/s3-request-presigner": "^3.735.0",
"@ducanh2912/next-pwa": "^10.2.9",
"@fabianlars/tauri-plugin-oauth": "2",
"@opennextjs/cloudflare": "^1.3.1",
"@opennextjs/cloudflare": "^1.6.1",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.4.0",
"@supabase/auth-ui-react": "^0.4.7",
@@ -65,6 +66,7 @@
"@tauri-apps/plugin-shell": "~2.3.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"@zip.js/zip.js": "^2.7.53",
"app-store-server-api": "^0.17.1",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
"cors": "^2.8.5",
@@ -93,21 +95,25 @@
"semver": "^7.7.1",
"stripe": "^18.2.1",
"tinycolor2": "^1.6.0",
"zod": "^4.0.8",
"zustand": "5.0.6"
},
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.6.2",
"@tauri-apps/cli": "2.7.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
"@types/node": "^22.15.31",
"@types/react": "18.3.12",
"@types/react-color": "^3.0.13",
"@types/react-dom": "18.3.1",
"@types/react-window": "^1.8.8",
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.24",
@@ -115,6 +121,7 @@
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
"i18next-scanner": "^4.6.0",
"jsdom": "^26.1.0",
"mkdirp": "^3.0.1",
"node-env-run": "^4.0.2",
"postcss": "^8.4.49",
@@ -123,6 +130,8 @@
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"wrangler": "^4.21.0"
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"wrangler": "^4.26.0"
}
}
@@ -372,7 +372,6 @@
"Upgrade to Plus or Pro": "الترقية إلى Plus أو Pro",
"Current Plan": "الخطة الحالية",
"Plan Limits": "حدود الخطة",
"Failed to manage subscription. Please try again later.": "فشل في إدارة الاشتراك. يرجى المحاولة مرة أخرى لاحقًا.",
"Processing your payment...": "جارٍ معالجة الدفع...",
"Please wait while we confirm your subscription.": "يرجى الانتظار بينما نقوم بتأكيد اشتراكك.",
"Payment Processing": "معالجة الدفع",
@@ -415,7 +414,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "استمع بلا حدود وحوّل النص لصوت بسهولة.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "استمتع بترجمة موسعة وخيارات متقدمة.",
"DeepL Pro Access": "وصول DeepL Pro",
"2 GB Cloud Sync Storage": "2 جيجابايت تخزين سحابي",
"Enjoy faster responses and dedicated assistance whenever you need help.": "احصل على دعم سريع ومخصص عند الحاجة.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "تم استهلاك حصة الترجمة اليومية. قم بالترقية للاستمرار.",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "ترجم أي نص فورًا بقوة Google أو Azure أو DeepL—افهم المحتوى بأي لغة.",
@@ -426,9 +424,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "استخدم أدوات الذكاء الاصطناعي القوية للقراءة الذكية والترجمة واكتشاف المحتوى.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "ترجم حتى 100 ألف حرف يوميًا بأدق محرك ترجمة متاح.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "ترجم حتى 500 ألف حرف يوميًا بأدق محرك ترجمة متاح.",
"10 GB Cloud Sync Storage": "10 جيجابايت تخزين سحابي",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 2 جيجابايت تخزين سحابي آمن.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 10 جيجابايت تخزين سحابي آمن.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 5 جيجابايت تخزين سحابي آمن.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "احفظ واوصل لمجموعة قراءتك كاملة بـ 20 جيجابايت تخزين سحابي آمن.",
"Deleted cloud backup of the book: {{title}}": "تم حذف النسخة الاحتياطية السحابية للكتاب: {{title}}",
"Failed to delete cloud backup of the book": "فشل في حذف النسخة الاحتياطية السحابية للكتاب",
"Are you sure to delete the cloud backup of the selected book?": "هل أنت متأكد من حذف النسخة الاحتياطية السحابية للكتاب المحدد؟",
@@ -467,5 +464,17 @@
"Fiction, Science, History": "خيال، علوم، تاريخ",
"Select Cover Image": "اختر صورة الغلاف",
"Open Book in New Window": "فتح الكتاب في نافذة جديدة",
"Voices for {{lang}}": "الأصوات لـ {{lang}}"
"Voices for {{lang}}": "الأصوات لـ {{lang}}",
"Yandex Translate": "ترجمة Yandex",
"YYYY or YYYY-MM-DD": "YYYY أو YYYY-MM-DD",
"Restore Purchase": "استعادة الشراء",
"No purchases found to restore.": "لا توجد مشتريات لاستعادتها.",
"Failed to restore purchases.": "فشل في استعادة المشتريات.",
"Failed to manage subscription.": "فشل في إدارة الاشتراك.",
"Failed to load subscription plans.": "فشل في تحميل خطط الاشتراك.",
"year": "سنة",
"Failed to create checkout session": "فشل في إنشاء جلسة الدفع",
"Storage": "التخزين",
"Terms of Service": "شروط الخدمة",
"Privacy Policy": "سياسة الخصوصية"
}
@@ -356,7 +356,6 @@
"Upgrade to Plus or Pro": "Upgrade auf Plus oder Pro",
"Current Plan": "Aktueller Tarif",
"Plan Limits": "Tariflimits",
"Failed to manage subscription. Please try again later.": "Verwaltung des Abos fehlgeschlagen. Bitte versuchen Sie es später erneut.",
"Processing your payment...": "Zahlung wird verarbeitet...",
"Please wait while we confirm your subscription.": "Bitte warten Sie, während wir Ihr Abo bestätigen.",
"Payment Processing": "Zahlung wird verarbeitet",
@@ -398,7 +397,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Beliebig viel Text in Audio umwandeln.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Erweitere Übersetzungen und tägliches Kontingent.",
"DeepL Pro Access": "DeepL Pro-Zugang",
"2 GB Cloud Sync Storage": "2 GB Cloud-Speicher",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Erhalte schnellen, persönlichen Support.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Tageslimit erreicht. Upgrade nötig, um weiter zu übersetzen.",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "Personalisieren Sie jedes Detail mit anpassbaren Schriften, Layouts, Themes und erweiterten Anzeigeeinstellungen für das perfekte Leseerlebnis.",
@@ -410,9 +408,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Nutzen Sie leistungsstarke KI-Tools für intelligenteres Lesen, Übersetzen und Content-Discovery.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Übersetzen Sie täglich bis zu 100.000 Zeichen mit der genauesten verfügbaren Übersetzungsmaschine.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Übersetzen Sie täglich bis zu 500.000 Zeichen mit der genauesten verfügbaren Übersetzungsmaschine.",
"10 GB Cloud Sync Storage": "10 GB Cloud-Sync-Speicher",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 2 GB Cloud-Speicher zu.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 10 GB Cloud-Speicher zu.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 5 GB Cloud-Speicher zu.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Speichern und greifen Sie sicher auf Ihre gesamte Lesekollektion mit bis zu 20 GB Cloud-Speicher zu.",
"Deleted cloud backup of the book: {{title}}": "Gelöschte Cloud-Sicherung des Buches: {{title}}",
"Failed to delete cloud backup of the book": "Fehler beim Löschen der Cloud-Sicherung des Buches",
"Are you sure to delete the cloud backup of the selected book?": "Sind Sie sicher, dass Sie die Cloud-Sicherung des ausgewählten Buches löschen möchten?",
@@ -451,5 +448,17 @@
"Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte",
"Select Cover Image": "Coverbild auswählen",
"Open Book in New Window": "Buch in neuem Fenster öffnen",
"Voices for {{lang}}": "Stimmen für {{lang}}"
"Voices for {{lang}}": "Stimmen für {{lang}}",
"Yandex Translate": "Yandex Übersetzer",
"YYYY or YYYY-MM-DD": "YYYY oder YYYY-MM-DD",
"Restore Purchase": "Kauf wiederherstellen",
"No purchases found to restore.": "Keine Käufe zum Wiederherstellen gefunden.",
"Failed to restore purchases.": "Wiederherstellung der Käufe fehlgeschlagen.",
"Failed to manage subscription.": "Verwaltung des Abonnements fehlgeschlagen.",
"Failed to load subscription plans.": "Laden der Abonnementpläne fehlgeschlagen.",
"year": "Jahr",
"Failed to create checkout session": "Fehler beim Erstellen der Checkout-Sitzung",
"Storage": "Speicher",
"Terms of Service": "Allgemeine Geschäftsbedingungen",
"Privacy Policy": "Datenschutzbestimmungen"
}
@@ -357,7 +357,6 @@
"Upgrade to Plus or Pro": "Αναβάθμιση σε Plus ή Pro",
"Current Plan": "Τρέχον πρόγραμμα",
"Plan Limits": "Όρια προγράμματος",
"Failed to manage subscription. Please try again later.": "Αποτυχία διαχείρισης της συνδρομής. Παρακαλώ δοκιμάστε ξανά αργότερα.",
"Processing your payment...": "Επεξεργασία πληρωμής...",
"Please wait while we confirm your subscription.": "Παρακαλώ περιμένετε όσο επιβεβαιώνουμε τη συνδρομή σας.",
"Payment Processing": "Επεξεργασία πληρωμής",
@@ -400,7 +399,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Απεριόριστη μετατροπή κειμένου σε ήχο.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Περισσότερες μεταφράσεις και δυνατότητες.",
"DeepL Pro Access": "Πρόσβαση στο DeepL Pro",
"2 GB Cloud Sync Storage": "2 GB αποθήκευση στο cloud",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Γρήγορη και άμεση υποστήριξη.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Έχετε φτάσει το ημερήσιο όριο. Κάντε αναβάθμιση για περισσότερες μεταφράσεις.",
"Includes All Plus Plan Benefits": "Περιλαμβάνει Όλα τα Οφέλη του Plus Πλάνου",
@@ -410,9 +408,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Αξιοποιήστε ισχυρά εργαλεία AI για εξυπνότερη ανάγνωση, μετάφραση και ανακάλυψη περιεχομένου.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Μεταφράστε έως 100.000 χαρακτήρες ημερησίως με τη πιο ακριβή μηχανή μετάφρασης.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Μεταφράστε έως 500.000 χαρακτήρες ημερησίως με τη πιο ακριβή μηχανή μετάφρασης.",
"10 GB Cloud Sync Storage": "10 GB Αποθήκευση Cloud Sync",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 2 GB ασφαλή cloud αποθήκευση.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 10 GB ασφαλή cloud αποθήκευση.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 5 GB ασφαλή cloud αποθήκευση.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Αποθηκεύστε και αποκτήστε πρόσβαση στη συλλογή ανάγνωσης με έως 20 GB ασφαλή cloud αποθήκευση.",
"Deleted cloud backup of the book: {{title}}": "Διαγράφηκε το αντίγραφο ασφαλείας του βιβλίου: {{title}}",
"Failed to delete cloud backup of the book": "Αποτυχία διαγραφής του αντιγράφου ασφαλείας του βιβλίου",
"Are you sure to delete the cloud backup of the selected book?": "Είστε σίγουροι ότι θέλετε να διαγράψετε το αντίγραφο ασφαλείας του επιλεγμένου βιβλίου;",
@@ -451,5 +448,17 @@
"Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία",
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου",
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο",
"Voices for {{lang}}": "Φωνές για {{lang}}"
"Voices for {{lang}}": "Φωνές για {{lang}}",
"Yandex Translate": "Μετάφραση Yandex",
"YYYY or YYYY-MM-DD": "YYYY ή YYYY-MM-DD",
"Restore Purchase": "Επαναφορά αγοράς",
"No purchases found to restore.": "Δεν βρέθηκαν αγορές για επαναφορά.",
"Failed to restore purchases.": "Αποτυχία επαναφοράς αγορών.",
"Failed to manage subscription.": "Αποτυχία διαχείρισης συνδρομής.",
"Failed to load subscription plans.": "Αποτυχία φόρτωσης σχεδίων συνδρομής.",
"year": "έτος",
"Failed to create checkout session": "Αποτυχία δημιουργίας συνεδρίας πληρωμής",
"Storage": "Αποθήκευση",
"Terms of Service": "Όροι χρήσης",
"Privacy Policy": "Πολιτική απορρήτου"
}
@@ -361,7 +361,6 @@
"Upgrade to Plus or Pro": "Actualizar a Plus o Pro",
"Current Plan": "Plan actual",
"Plan Limits": "Límites del plan",
"Failed to manage subscription. Please try again later.": "No se pudo gestionar la suscripción. Por favor, inténtalo de nuevo más tarde.",
"Processing your payment...": "Procesando tu pago...",
"Please wait while we confirm your subscription.": "Espera mientras confirmamos tu suscripción.",
"Payment Processing": "Procesando pago",
@@ -404,7 +403,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Convierte texto en audio sin límites.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Más traducciones y funciones avanzadas.",
"DeepL Pro Access": "Acceso a DeepL Pro",
"2 GB Cloud Sync Storage": "2 GB de almacenamiento en la nube",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Soporte rápido y prioritario.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Has alcanzado tu límite diario. Mejora tu plan para más traducciones.",
"Includes All Plus Plan Benefits": "Incluye Todos los Beneficios del Plan Plus",
@@ -414,9 +412,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Aprovecha herramientas de IA potentes para lectura inteligente, traducción y descubrimiento de contenido.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Traduce hasta 100,000 caracteres diarios con el motor de traducción más preciso disponible.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Traduce hasta 500,000 caracteres diarios con el motor de traducción más preciso disponible.",
"10 GB Cloud Sync Storage": "10 GB de Almacenamiento en la Nube",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 2 GB de almacenamiento en la nube.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 10 GB de almacenamiento en la nube.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 5 GB de almacenamiento en la nube.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Almacena y accede de forma segura a toda tu colección de lectura con hasta 20 GB de almacenamiento en la nube.",
"Deleted cloud backup of the book: {{title}}": "Respaldo en la nube del libro eliminado: {{title}}",
"Failed to delete cloud backup of the book": "Fallo al eliminar el respaldo en la nube del libro",
"Are you sure to delete the cloud backup of the selected book?": "¿Estás seguro de que deseas eliminar el respaldo en la nube del libro seleccionado?",
@@ -455,5 +452,17 @@
"Fiction, Science, History": "Ficción, Ciencia, Historia",
"Select Cover Image": "Seleccionar imagen de portada",
"Open Book in New Window": "Abre el libro en una nueva ventana",
"Voices for {{lang}}": "Voces para {{lang}}"
"Voices for {{lang}}": "Voces para {{lang}}",
"Yandex Translate": "Yandex Translate",
"YYYY or YYYY-MM-DD": "YYYY o YYYY-MM-DD",
"Restore Purchase": "Restaurar compra",
"No purchases found to restore.": "No se encontraron compras para restaurar.",
"Failed to restore purchases.": "Error al restaurar compras.",
"Failed to manage subscription.": "Error al gestionar la suscripción.",
"Failed to load subscription plans.": "Error al cargar los planes de suscripción.",
"year": "año",
"Failed to create checkout session": "Error al crear la sesión de pago",
"Storage": "Almacenamiento",
"Terms of Service": "Condiciones del servicio",
"Privacy Policy": "Política de privacidad"
}
@@ -360,7 +360,6 @@
"Upgrade to Plus or Pro": "Passer à Plus ou Pro",
"Current Plan": "Abonnement actuel",
"Plan Limits": "Limites de labonnement",
"Failed to manage subscription. Please try again later.": "Échec de la gestion de labonnement. Veuillez réessayer plus tard.",
"Processing your payment...": "Traitement de votre paiement...",
"Please wait while we confirm your subscription.": "Veuillez patienter pendant la confirmation de votre abonnement.",
"Payment Processing": "Traitement du paiement",
@@ -403,7 +402,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Convertissez le texte en audio sans limite.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Plus de traductions et doptions avancées.",
"DeepL Pro Access": "Accès DeepL Pro",
"2 GB Cloud Sync Storage": "2 Go de stockage cloud",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Support prioritaire et réponses rapides.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Limite quotidienne atteinte. Passez à un plan supérieur pour continuer.",
"Includes All Plus Plan Benefits": "Inclut Tous les Avantages du Plan Plus",
@@ -413,9 +411,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Exploitez des outils IA puissants pour une lecture intelligente, traduction et découverte de contenu.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Traduisez jusqu'à 100 000 caractères par jour avec le moteur de traduction le plus précis.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Traduisez jusqu'à 500 000 caractères par jour avec le moteur de traduction le plus précis.",
"10 GB Cloud Sync Storage": "10 GB de Stockage Cloud Sync",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 2 GB de stockage cloud.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 10 GB de stockage cloud.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 5 GB de stockage cloud.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Stockez et accédez en sécurité à votre collection de lecture avec jusqu'à 20 GB de stockage cloud.",
"Deleted cloud backup of the book: {{title}}": "Suppression de la sauvegarde cloud du livre : {{title}}",
"Failed to delete cloud backup of the book": "Échec de la suppression de la sauvegarde cloud du livre",
"Are you sure to delete the cloud backup of the selected book?": "Êtes-vous sûr de vouloir supprimer la sauvegarde cloud du livre sélectionné ?",
@@ -455,5 +452,17 @@
"Fiction, Science, History": "Fiction, Science, Histoire",
"Select Cover Image": "Sélectionner l'image de couverture",
"Open Book in New Window": "Ouvrir dans une nouvelle fenêtre",
"Voices for {{lang}}": "Voix pour {{lang}}"
"Voices for {{lang}}": "Voix pour {{lang}}",
"Yandex Translate": "Yandex Traduction",
"YYYY or YYYY-MM-DD": "YYYY ou YYYY-MM-DD",
"Restore Purchase": "Restaurer l'achat",
"No purchases found to restore.": "Aucun achat trouvé à restaurer.",
"Failed to restore purchases.": "Échec de la restauration des achats.",
"Failed to manage subscription.": "Échec de la gestion de l'abonnement.",
"Failed to load subscription plans.": "Échec du chargement des plans d'abonnement.",
"year": "année",
"Failed to create checkout session": "Échec de la création de la session de paiement",
"Storage": "Stockage",
"Terms of Service": "Conditions d'utilisation",
"Privacy Policy": "Politique de confidentialité"
}
@@ -357,7 +357,6 @@
"Upgrade to Plus or Pro": "Plus या Pro में अपग्रेड करें",
"Current Plan": "वर्तमान योजना",
"Plan Limits": "योजना की सीमाएँ",
"Failed to manage subscription. Please try again later.": "सदस्यता प्रबंधित करने में विफल। कृपया बाद में पुनः प्रयास करें।",
"Processing your payment...": "आपका भुगतान संसाधित हो रहा है...",
"Please wait while we confirm your subscription.": "कृपया प्रतीक्षा करें, हम आपकी सदस्यता की पुष्टि कर रहे हैं।",
"Payment Processing": "भुगतान संसाधित हो रहा है",
@@ -400,7 +399,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "जितना चाहें उतना ऑडियो में सुनें।",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "अधिक अनुवाद और उन्नत विकल्प पाएं।",
"DeepL Pro Access": "DeepL प्रो एक्सेस",
"2 GB Cloud Sync Storage": "2 जीबी क्लाउड स्टोरेज",
"Enjoy faster responses and dedicated assistance whenever you need help.": "प्राथमिक सहायता पाएं।",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "दैनिक सीमा पूरी हुई। योजना बढ़ाएं।",
"Includes All Plus Plan Benefits": "सभी प्लस प्लान लाभ शामिल",
@@ -410,9 +408,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "स्मार्ट पढ़ने, अनुवाद और कंटेंट खोजने के लिए शक्तिशाली AI उपकरण का उपयोग करें।",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "सबसे सटीक अनुवाद इंजन के साथ दैनिक 1 लाख तक अक्षर अनुवाद करें।",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "सबसे सटीक अनुवाद इंजन के साथ दैनिक 5 लाख तक अक्षर अनुवाद करें।",
"10 GB Cloud Sync Storage": "10 GB क्लाउड सिंक स्टोरेज",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "2 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "10 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "5 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "20 GB सुरक्षित क्लाउड स्टोरेज के साथ अपना संपूर्ण पठन संग्रह संग्रहीत करें।",
"Deleted cloud backup of the book: {{title}}": "पुस्तक का क्लाउड बैकअप हटाया गया: {{title}}",
"Failed to delete cloud backup of the book": "पुस्तक का क्लाउड बैकअप हटाने में विफल",
"Are you sure to delete the cloud backup of the selected book?": "क्या आप सुनिश्चित हैं कि आप चयनित पुस्तक का क्लाउड बैकअप हटाना चाहते हैं?",
@@ -451,5 +448,17 @@
"Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास",
"Select Cover Image": "कवर इमेज चुनें",
"Open Book in New Window": "नई विंडो में पुस्तक खोलें",
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें"
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें",
"Yandex Translate": "यांडेक्स अनुवाद",
"YYYY or YYYY-MM-DD": "YYYY या YYYY-MM-DD",
"Restore Purchase": "खरीदारी पुनर्स्थापित करें",
"No purchases found to restore.": "पुनर्स्थापित करने के लिए कोई खरीदारी नहीं मिली।",
"Failed to restore purchases.": "खरीदारी पुनर्स्थापित करने में विफल।",
"Failed to manage subscription.": "सदस्यता प्रबंधित करने में विफल।",
"Failed to load subscription plans.": "सदस्यता योजनाएँ लोड करने में विफल।",
"year": "वर्ष",
"Failed to create checkout session": "चेकआउट सत्र बनाने में विफल",
"Storage": "भंडारण",
"Terms of Service": "सेवा की शर्तें",
"Privacy Policy": "गोपनीयता नीति"
}
@@ -353,7 +353,6 @@
"Upgrade to Plus or Pro": "Tingkatkan ke Plus atau Pro",
"Current Plan": "Paket Saat Ini",
"Plan Limits": "Batas Paket",
"Failed to manage subscription. Please try again later.": "Gagal mengelola langganan. Silakan coba lagi nanti.",
"Processing your payment...": "Memproses pembayaran Anda...",
"Please wait while we confirm your subscription.": "Mohon tunggu saat kami mengonfirmasi langganan Anda.",
"Payment Processing": "Memproses Pembayaran",
@@ -378,7 +377,7 @@
"More AI Translations": "Lebih Banyak Terjemahan AI",
"Complete Your Subscription": "Selesaikan Langganan Anda",
"{{percentage}}% of Cloud Sync Space Used.": "{{percentage}}% ruang sinkronisasi cloud terpakai",
"Cloud Sync Storage": "Ruang cloud",
"Cloud Sync Storage": "Penyimpanan Sinkronisasi Cloud",
"Disable": "Nonaktifkan",
"Enable": "Aktifkan",
"Upgrade to Readest Premium": "Tingkatkan ke Readest Premium",
@@ -395,7 +394,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Dengarkan sebanyak yang Anda mau tanpa batas.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Dapatkan lebih banyak kuota dan fitur terjemahan lanjutan.",
"DeepL Pro Access": "Akses DeepL Pro",
"2 GB Cloud Sync Storage": "Penyimpanan Sinkronisasi Cloud 2 GB",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Nikmati dukungan prioritas kapan saja.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Kuota harian habis. Tingkatkan paket Anda untuk lanjut.",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Terjemahkan teks apa pun secara instan dengan kekuatan Google, Azure, atau DeepL—pahami konten dalam bahasa apa pun.",
@@ -406,9 +404,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Manfaatkan alat AI canggih untuk membaca cerdas, terjemahan, dan penemuan konten.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Terjemahkan hingga 100.000 karakter harian dengan mesin terjemahan paling akurat.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Terjemahkan hingga 500.000 karakter harian dengan mesin terjemahan paling akurat.",
"10 GB Cloud Sync Storage": "Penyimpanan Cloud Sync 10 GB",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 2 GB.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 10 GB.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 5 GB.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Simpan dan akses koleksi bacaan lengkap dengan aman menggunakan penyimpanan cloud 20 GB.",
"Deleted cloud backup of the book: {{title}}": "Cadangan cloud buku dihapus: {{title}}",
"Failed to delete cloud backup of the book": "Cadangan cloud buku gagal dihapus",
"Are you sure to delete the cloud backup of the selected book?": "Apakah Anda yakin ingin menghapus cadangan cloud dari buku yang dipilih?",
@@ -447,5 +444,17 @@
"Fiction, Science, History": "Fiksi, Sains, Sejarah",
"Select Cover Image": "Pilihan Gambar Sampul",
"Open Book in New Window": "Buka Buku di Jendela Baru",
"Voices for {{lang}}": "Suara untuk {{lang}}"
"Voices for {{lang}}": "Suara untuk {{lang}}",
"Yandex Translate": "Yandex Translate",
"YYYY or YYYY-MM-DD": "YYYY atau YYYY-MM-DD",
"Restore Purchase": "Pulihkan Pembelian",
"No purchases found to restore.": "Tidak ada pembelian yang ditemukan untuk dipulihkan.",
"Failed to restore purchases.": "Gagal memulihkan pembelian.",
"Failed to manage subscription.": "Gagal mengelola langganan.",
"Failed to load subscription plans.": "Gagal memuat rencana langganan.",
"year": "tahun",
"Failed to create checkout session": "Gagal membuat sesi checkout",
"Storage": "Penyimpanan",
"Terms of Service": "Ketentuan Layanan",
"Privacy Policy": "Kebijakan Privasi"
}
@@ -361,7 +361,6 @@
"Upgrade to Plus or Pro": "Aggiorna a Plus o Pro",
"Current Plan": "Piano Attuale",
"Plan Limits": "Limiti del Piano",
"Failed to manage subscription. Please try again later.": "Impossibile gestire l'abbonamento. Riprova più tardi.",
"Processing your payment...": "Elaborazione del pagamento...",
"Please wait while we confirm your subscription.": "Attendi mentre confermiamo il tuo abbonamento.",
"Payment Processing": "Elaborazione Pagamento",
@@ -403,7 +402,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Ascolta senza limiti quanto vuoi.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Sblocca traduzioni avanzate e più utilizzo giornaliero.",
"DeepL Pro Access": "Accesso DeepL Pro",
"2 GB Cloud Sync Storage": "2 GB di Archiviazione Cloud",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Ricevi supporto prioritario quando serve.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Quota giornaliera esaurita. Passa a un piano superiore per continuare.",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Traduci qualsiasi testo istantaneamente con la potenza di Google, Azure o DeepL—comprendi contenuti in qualsiasi lingua.",
@@ -414,9 +412,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Sfrutta potenti strumenti AI per lettura intelligente, traduzione e scoperta di contenuti.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Traduci fino a 100.000 caratteri al giorno con il motore di traduzione più accurato.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Traduci fino a 500.000 caratteri al giorno con il motore di traduzione più accurato.",
"10 GB Cloud Sync Storage": "Archiviazione Cloud Sync da 10 GB",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 2 GB di storage cloud.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 10 GB di storage cloud.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 5 GB di storage cloud.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Archivia e accedi in sicurezza alla tua intera collezione di lettura con fino a 20 GB di storage cloud.",
"Deleted cloud backup of the book: {{title}}": "Backup cloud del libro eliminato: {{title}}",
"Failed to delete cloud backup of the book": "Impossibile eliminare il backup cloud del libro",
"Are you sure to delete the cloud backup of the selected book?": "Sei sicuro di voler eliminare il backup cloud del libro selezionato?",
@@ -455,5 +452,17 @@
"Fiction, Science, History": "Narrativa, Scienza, Storia",
"Select Cover Image": "Seleziona immagine di copertina",
"Open Book in New Window": "Apri libro in una nuova finestra",
"Voices for {{lang}}": "Voci per {{lang}}"
"Voices for {{lang}}": "Voci per {{lang}}",
"Yandex Translate": "Yandex Translate",
"YYYY or YYYY-MM-DD": "AAAA o AAAA-MM-GG",
"Restore Purchase": "Ripristina acquisto",
"No purchases found to restore.": "Nessun acquisto trovato da ripristinare.",
"Failed to restore purchases.": "Impossibile ripristinare gli acquisti.",
"Failed to manage subscription.": "Impossibile gestire l'abbonamento.",
"Failed to load subscription plans.": "Impossibile caricare i piani di abbonamento.",
"year": "anno",
"Failed to create checkout session": "Impossibile creare la sessione di checkout",
"Storage": "Archiviazione",
"Terms of Service": "Termini di servizio",
"Privacy Policy": "Informativa sulla privacy"
}
@@ -353,7 +353,6 @@
"Upgrade to Plus or Pro": "Plus または Pro にアップグレード",
"Current Plan": "現在のプラン",
"Plan Limits": "プランの上限",
"Failed to manage subscription. Please try again later.": "サブスクリプションの管理に失敗しました。後でもう一度お試しください。",
"Processing your payment...": "お支払いを処理中...",
"Please wait while we confirm your subscription.": "サブスクリプションを確認しています。しばらくお待ちください。",
"Payment Processing": "支払いを処理中",
@@ -396,7 +395,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "制限なくテキストを音声化。",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "高度な翻訳機能を利用可能に。",
"DeepL Pro Access": "DeepL Pro利用",
"2 GB Cloud Sync Storage": "2GBクラウド保存",
"Enjoy faster responses and dedicated assistance whenever you need help.": "優先サポートを利用可能。",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "翻訳上限に達しました。アップグレードで継続可能です。",
"Includes All Plus Plan Benefits": "Plusプランの全特典を含む",
@@ -406,9 +404,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "スマートな読書、翻訳、コンテンツ発見のための強力なAIツールを活用。",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "最も正確な翻訳エンジンで1日最大10万文字を翻訳。",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "最も正確な翻訳エンジンで1日最大50万文字を翻訳。",
"10 GB Cloud Sync Storage": "10GBクラウド同期ストレージ",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "最大2GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "最大10GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "最大5GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "最大20GBの安全なクラウドストレージで読書コレクション全体を保存・アクセス。",
"Deleted cloud backup of the book: {{title}}": "書籍のクラウドバックアップを削除しました:{{title}}",
"Failed to delete cloud backup of the book": "書籍のクラウドバックアップを削除できませんでした",
"Are you sure to delete the cloud backup of the selected book?": "選択した書籍のクラウドバックアップを削除してもよろしいですか?",
@@ -447,5 +444,17 @@
"Fiction, Science, History": "小説、科学、歴史",
"Select Cover Image": "表紙画像を選択",
"Open Book in New Window": "新しいウィンドウで本を開く",
"Voices for {{lang}}": "{{lang}}の音声"
"Voices for {{lang}}": "{{lang}}の音声",
"Yandex Translate": "Yandex翻訳",
"YYYY or YYYY-MM-DD": "YYYYまたはYYYY-MM-DD",
"Restore Purchase": "購入を復元",
"No purchases found to restore.": "復元する購入が見つかりません。",
"Failed to restore purchases.": "購入の復元に失敗しました。",
"Failed to manage subscription.": "サブスクリプションの管理に失敗しました。",
"Failed to load subscription plans.": "サブスクリプションプランの読み込みに失敗しました。",
"year": "年",
"Failed to create checkout session": "チェックアウトセッションの作成に失敗しました",
"Storage": "ストレージ",
"Terms of Service": "利用規約",
"Privacy Policy": "プライバシーポリシー"
}
@@ -353,7 +353,6 @@
"Upgrade to Plus or Pro": "Plus 또는 Pro로 업그레이드",
"Current Plan": "현재 요금제",
"Plan Limits": "요금제 한도",
"Failed to manage subscription. Please try again later.": "구독 관리에 실패했습니다. 나중에 다시 시도해주세요.",
"Processing your payment...": "결제를 처리 중입니다...",
"Please wait while we confirm your subscription.": "구독을 확인하는 중입니다. 잠시만 기다려주세요.",
"Payment Processing": "결제 처리 중",
@@ -396,7 +395,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "제한 없이 텍스트를 음성으로 변환하세요.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "고급 번역 기능을 더 많이 이용하세요.",
"DeepL Pro Access": "DeepL Pro 이용",
"2 GB Cloud Sync Storage": "2GB 클라우드 저장소",
"Enjoy faster responses and dedicated assistance whenever you need help.": "우선 지원을 이용하세요.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "일일 번역 한도에 도달했습니다. 업그레이드하여 계속 사용하세요.",
"Includes All Plus Plan Benefits": "모든 플러스 플랜 혜택 포함",
@@ -406,9 +404,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "스마트한 독서, 번역, 콘텐츠 발견을 위한 강력한 AI 도구를 활용하세요.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "가장 정확한 번역 엔진으로 매일 최대 10만 자를 번역하세요.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "가장 정확한 번역 엔진으로 매일 최대 50만 자를 번역하세요.",
"10 GB Cloud Sync Storage": "10GB 클라우드 동기화 저장소",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "최대 2GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "최대 10GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "최대 5GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "최대 20GB의 안전한 클라우드 저장소로 전체 독서 컬렉션을 저장하고 액세스하세요.",
"Deleted cloud backup of the book: {{title}}": "책의 클라우드 백업이 삭제됨: {{title}}",
"Failed to delete cloud backup of the book": "책의 클라우드 백업을 삭제하지 못했습니다.",
"Are you sure to delete the cloud backup of the selected book?": "선택한 책의 클라우드 백업을 삭제하시겠습니까?",
@@ -447,5 +444,17 @@
"Fiction, Science, History": "소설, 과학, 역사",
"Select Cover Image": "표지 이미지 선택",
"Open Book in New Window": "새 창에서 책 열기",
"Voices for {{lang}}": "{{lang}}의 음성"
"Voices for {{lang}}": "{{lang}}의 음성",
"Yandex Translate": "Yandex 번역",
"YYYY or YYYY-MM-DD": "YYYY 또는 YYYY-MM-DD",
"Restore Purchase": "구매 복원",
"No purchases found to restore.": "복원할 구매가 없습니다.",
"Failed to restore purchases.": "구매 복원에 실패했습니다.",
"Failed to manage subscription.": "구독 관리에 실패했습니다.",
"Failed to load subscription plans.": "구독 플랜 로드에 실패했습니다.",
"year": "년",
"Failed to create checkout session": "체크아웃 세션 생성 실패",
"Storage": "저장소",
"Terms of Service": "서비스 약관",
"Privacy Policy": "개인정보 보호정책"
}
@@ -357,7 +357,6 @@
"Upgrade to Plus or Pro": "Upgrade naar Plus of Pro",
"Current Plan": "Huidig abonnement",
"Plan Limits": "Abonnementslimieten",
"Failed to manage subscription. Please try again later.": "Abonnement beheren is mislukt. Probeer het later opnieuw.",
"Processing your payment...": "Je betaling wordt verwerkt...",
"Please wait while we confirm your subscription.": "Even geduld terwijl we je abonnement bevestigen.",
"Payment Processing": "Betaling verwerken",
@@ -400,7 +399,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Luister zonder limieten—zet zoveel tekst als je wilt om in meeslepende audio.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Ontsluit verbeterde vertaalfuncties met meer dagelijks gebruik en geavanceerde opties.",
"DeepL Pro Access": "DeepL Pro Toegang",
"2 GB Cloud Sync Storage": "2 GB Cloud Sync Opslag",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Geniet van snellere reacties en toegewijde hulp wanneer je hulp nodig hebt.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Dagelijks vertaalquotum bereikt. Upgrade je abonnement om AI vertalingen te blijven gebruiken.",
"Includes All Plus Plan Benefits": "Inclusief Alle Plus Plan Voordelen",
@@ -410,9 +408,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Benut krachtige AI tools voor slimmer lezen, vertalen en content ontdekking.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Vertaal dagelijks tot 100.000 tekens met de meest nauwkeurige vertaalengine.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Vertaal dagelijks tot 500.000 tekens met de meest nauwkeurige vertaalengine.",
"10 GB Cloud Sync Storage": "10 GB Cloud Sync Opslag",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 2 GB cloudopslag.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 10 GB cloudopslag.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 5 GB cloudopslag.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Sla je hele leesverzameling veilig op en benader deze met tot 20 GB cloudopslag.",
"Deleted cloud backup of the book: {{title}}": "Verwijderde cloudback-up van het boek: {{title}}",
"Failed to delete cloud backup of the book": "Verwijderen van cloudback-up van het boek is mislukt.",
"Are you sure to delete the cloud backup of the selected book?": "Weet je zeker dat je de cloudback-up van het geselecteerde boek wilt verwijderen?",
@@ -451,5 +448,17 @@
"Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis",
"Select Cover Image": "Selecteer omslagafbeelding",
"Open Book in New Window": "Open boek in nieuw venster",
"Voices for {{lang}}": "Stemmen voor {{lang}}"
"Voices for {{lang}}": "Stemmen voor {{lang}}",
"Yandex Translate": "Yandex Vertalen",
"YYYY or YYYY-MM-DD": "YYYY of YYYY-MM-DD",
"Restore Purchase": "Herstel aankoop",
"No purchases found to restore.": "Geen aankopen gevonden om te herstellen.",
"Failed to restore purchases.": "Herstellen van aankopen is mislukt.",
"Failed to manage subscription.": "Beheren van abonnement is mislukt.",
"Failed to load subscription plans.": "Laden van abonnement plannen is mislukt.",
"year": "jaar",
"Failed to create checkout session": "Kon de afreken sessie niet aanmaken",
"Storage": "Opslag",
"Terms of Service": "Gebruiksvoorwaarden",
"Privacy Policy": "Privacybeleid"
}
@@ -365,7 +365,6 @@
"Upgrade to Plus or Pro": "Uaktualnij do Plus lub Pro",
"Current Plan": "Obecny plan",
"Plan Limits": "Limity planu",
"Failed to manage subscription. Please try again later.": "Nie udało się zarządzać subskrypcją. Spróbuj ponownie później.",
"Processing your payment...": "Przetwarzanie płatności...",
"Please wait while we confirm your subscription.": "Proszę czekać, potwierdzamy subskrypcję.",
"Payment Processing": "Przetwarzanie płatności",
@@ -408,7 +407,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Słuchaj bez ograniczeń—przekształć tyle tekstu, ile chcesz, w wciągające audio.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Odblokuj ulepszone możliwości tłumaczenia z większym dziennym użyciem i zaawansowanymi opcjami.",
"DeepL Pro Access": "Dostęp do DeepL Pro",
"2 GB Cloud Sync Storage": "2 GB Pamięci Cloud Sync",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Ciesz się szybszymi odpowiedziami i dedykowaną pomocą, kiedy tylko potrzebujesz wsparcia.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Osiągnięto dzienny limit tłumaczeń. Uaktualnij swój plan, aby kontynuować korzystanie z tłumaczeń AI.",
"Includes All Plus Plan Benefits": "Zawiera Wszystkie Korzyści Planu Plus",
@@ -418,9 +416,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Wykorzystuj potężne narzędzia AI do inteligentnego czytania, tłumaczenia i odkrywania treści.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Tłumacz do 100 000 znaków dziennie z najdokładniejszą dostępną maszyną tłumaczeniową.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Tłumacz do 500 000 znaków dziennie z najdokładniejszą dostępną maszyną tłumaczeniową.",
"10 GB Cloud Sync Storage": "10 GB Pamięci Cloud Sync",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 2 GB pamięci w chmurze.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 10 GB pamięci w chmurze.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 5 GB pamięci w chmurze.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Bezpiecznie przechowuj i uzyskuj dostęp do całej kolekcji czytelniczej z do 20 GB pamięci w chmurze.",
"Deleted cloud backup of the book: {{title}}": "Usunięto kopię zapasową książki w chmurze: {{title}}",
"Failed to delete cloud backup of the book": "Nie udało się usunąć kopii zapasowej książki w chmurze.",
"Are you sure to delete the cloud backup of the selected book?": "Czy na pewno chcesz usunąć kopię zapasową chmury wybranej książki?",
@@ -459,5 +456,17 @@
"Fiction, Science, History": "Beletrystyka, Nauka, Historia",
"Select Cover Image": "Zaznacz obraz okładki",
"Open Book in New Window": "Otwórz książkę w nowym oknie",
"Voices for {{lang}}": "{{lang}} Głosy"
"Voices for {{lang}}": "{{lang}} Głosy",
"Yandex Translate": "Yandex Tłumacz",
"YYYY or YYYY-MM-DD": "YYYY lub YYYY-MM-DD",
"Restore Purchase": "Przywróć zakup",
"No purchases found to restore.": "Nie znaleziono zakupów do przywrócenia.",
"Failed to restore purchases.": "Nie udało się przywrócić zakupów.",
"Failed to manage subscription.": "Nie udało się zarządzać subskrypcją.",
"Failed to load subscription plans.": "Nie udało się załadować planów subskrypcyjnych.",
"year": "rok",
"Failed to create checkout session": "Nie udało się utworzyć sesji zakupu",
"Storage": "Przechowywanie",
"Terms of Service": "Warunki korzystania z usługi",
"Privacy Policy": "Polityka prywatności"
}
@@ -361,7 +361,6 @@
"Upgrade to Plus or Pro": "Atualizar para Plus ou Pro",
"Current Plan": "Plano atual",
"Plan Limits": "Limites do plano",
"Failed to manage subscription. Please try again later.": "Não foi possível gerenciar a assinatura. Tente novamente mais tarde.",
"Processing your payment...": "Processando seu pagamento...",
"Please wait while we confirm your subscription.": "Por favor, aguarde enquanto confirmamos sua assinatura.",
"Payment Processing": "Processando pagamento",
@@ -404,7 +403,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Ouça sem limites—converta tanto texto quanto quiser em áudio envolvente.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Desbloqueie recursos aprimorados de tradução com mais uso diário e opções avançadas.",
"DeepL Pro Access": "Acesso ao DeepL Pro",
"2 GB Cloud Sync Storage": "2 GB de Armazenamento na Nuvem",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Desfrute de respostas mais rápidas e assistência dedicada sempre que precisar de ajuda.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Cota diária de tradução atingida. Atualize seu plano para continuar usando traduções por IA.",
"Includes All Plus Plan Benefits": "Inclui Todos os Benefícios do Plano Plus",
@@ -414,9 +412,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Aproveite ferramentas de IA poderosas para leitura inteligente, tradução e descoberta de conteúdo.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Traduza até 100.000 caracteres diariamente com o motor de tradução mais preciso disponível.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Traduza até 500.000 caracteres diariamente com o motor de tradução mais preciso disponível.",
"10 GB Cloud Sync Storage": "10 GB de Armazenamento na Nuvem",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 2 GB de armazenamento na nuvem.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 10 GB de armazenamento na nuvem.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 5 GB de armazenamento na nuvem.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Armazene e acesse com segurança sua coleção completa de leitura com até 20 GB de armazenamento na nuvem.",
"Deleted cloud backup of the book: {{title}}": "Backup na nuvem do livro excluído: {{title}}",
"Failed to delete cloud backup of the book": "Falha ao excluir o backup na nuvem do livro.",
"Are you sure to delete the cloud backup of the selected book?": "Tem certeza de que deseja excluir o backup na nuvem do livro selecionado?",
@@ -455,5 +452,17 @@
"Fiction, Science, History": "Ficção, Ciência, História",
"Select Cover Image": "Selecionar imagem da capa",
"Open Book in New Window": "Abrir livro em nova janela",
"Voices for {{lang}}": "Vozes para {{lang}}"
"Voices for {{lang}}": "Vozes para {{lang}}",
"Yandex Translate": "Yandex Translate",
"YYYY or YYYY-MM-DD": "YYYY ou YYYY-MM-DD",
"Restore Purchase": "Restaurar Compra",
"No purchases found to restore.": "Nenhuma compra encontrada para restaurar.",
"Failed to restore purchases.": "Falha ao restaurar compras.",
"Failed to manage subscription.": "Falha ao gerenciar assinatura.",
"Failed to load subscription plans.": "Falha ao carregar planos de assinatura.",
"year": "ano",
"Failed to create checkout session": "Falha ao criar sessão de checkout",
"Storage": "Armazenamento",
"Terms of Service": "Termos de Serviço",
"Privacy Policy": "Política de Privacidade"
}
@@ -365,7 +365,6 @@
"Upgrade to Plus or Pro": "Обновить до Plus или Pro",
"Current Plan": "Текущий план",
"Plan Limits": "Лимиты плана",
"Failed to manage subscription. Please try again later.": "Не удалось управлять подпиской. Пожалуйста, попробуйте позже.",
"Processing your payment...": "Обработка платежа...",
"Please wait while we confirm your subscription.": "Пожалуйста, подождите, пока мы подтверждаем вашу подписку.",
"Payment Processing": "Обработка платежа",
@@ -408,7 +407,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Слушайте без ограничений—преобразуйте столько текста, сколько хотите, в захватывающее аудио.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Разблокируйте улучшенные возможности перевода с большим ежедневным использованием и расширенными опциями.",
"DeepL Pro Access": "Доступ к DeepL Pro",
"2 GB Cloud Sync Storage": "2 ГБ Облачного Хранилища",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Наслаждайтесь более быстрыми ответами и персональной помощью, когда вам нужна поддержка.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Достигнута дневная квота переводов. Обновите план, чтобы продолжить использовать переводы ИИ.",
"Includes All Plus Plan Benefits": "Включает Все Преимущества Plus Плана",
@@ -418,9 +416,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Используйте мощные ИИ инструменты для умного чтения, перевода и поиска контента.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Переводите до 100 000 символов ежедневно с самым точным доступным движком перевода.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Переводите до 500 000 символов ежедневно с самым точным доступным движком перевода.",
"10 GB Cloud Sync Storage": "10 ГБ Облачного Хранилища",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 2 ГБ облачного хранилища.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 10 ГБ облачного хранилища.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 5 ГБ облачного хранилища.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Безопасно храните и получайте доступ ко всей коллекции чтения с до 20 ГБ облачного хранилища.",
"Deleted cloud backup of the book: {{title}}": "Удалено облачное резервное копирование книги: {{title}}",
"Failed to delete cloud backup of the book": "Не удалось удалить облачное резервное копирование книги.",
"Are you sure to delete the cloud backup of the selected book?": "Вы уверены, что хотите удалить облачное резервное копирование выбранной книги?",
@@ -459,5 +456,17 @@
"Fiction, Science, History": "Художественная литература, Наука, История",
"Select Cover Image": "Выбрать изображение обложки",
"Open Book in New Window": "Открыть книгу в новом окне",
"Voices for {{lang}}": "Голоса для {{lang}}"
"Voices for {{lang}}": "Голоса для {{lang}}",
"Yandex Translate": "Yandex Translate",
"YYYY or YYYY-MM-DD": "YYYY или YYYY-MM-DD",
"Restore Purchase": "Восстановить покупку",
"No purchases found to restore.": "Не найдено покупок для восстановления.",
"Failed to restore purchases.": "Не удалось восстановить покупки.",
"Failed to manage subscription.": "Не удалось управлять подпиской.",
"Failed to load subscription plans.": "Не удалось загрузить планы подписки.",
"year": "год",
"Failed to create checkout session": "Не удалось создать сессию оформления заказа",
"Storage": "Хранилище",
"Terms of Service": "Условия использования",
"Privacy Policy": "Политика конфиденциальности"
}
@@ -306,7 +306,6 @@
"Current Plan": "แผนปัจจุบัน",
"Plan Limits": "ข้อจำกัดแผน",
"Failed to delete user. Please try again later.": "ไม่สามารถลบผู้ใช้ ลองใหม่ภายหลัง",
"Failed to manage subscription. Please try again later.": "ไม่สามารถจัดการสมาชิก ลองใหม่ภายหลัง",
"Loading profile...": "กำลังโหลดโปรไฟล์...",
"Processing your payment...": "กำลังประมวลผลการชำระเงิน...",
"Please wait while we confirm your subscription.": "กรุณารอสักครู่เพื่อยืนยันการสมัครสมาชิก",
@@ -345,8 +344,7 @@
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "ปลดล็อกความสามารถการแปลที่เพิ่มขึ้นพร้อมโควตารายวันและตัวเลือกขั้นสูง",
"DeepL Pro Access": "การเข้าถึง DeepL Pro",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "แปลได้สูงสุด 100,000 อักขระต่อวันด้วยเครื่องมือแปลที่แม่นยำที่สุด",
"2 GB Cloud Sync Storage": "พื้นที่ซิงค์คลาวด์ 2 GB",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "จัดเก็บและเข้าถึงคอลเลคชันการอ่านอย่างปลอดภัยด้วยพื้นที่คลาวด์ 2 GB",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "จัดเก็บและเข้าถึงคอลเลคชันการอ่านอย่างปลอดภัยด้วยพื้นที่คลาวด์ 5 GB",
"Priority Support": "การสนับสนุนแบบพิเศษ",
"Enjoy faster responses and dedicated assistance whenever you need help.": "รับการตอบสนองที่รวดเร็วและความช่วยเหลือเฉพาะเมื่อต้องการ",
"Pro Plan": "แผน Pro",
@@ -356,8 +354,7 @@
"Advanced AI Tools": "เครื่องมือ AI ขั้นสูง",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "ใช้เครื่องมือ AI อันทรงพลังเพื่อการอ่าน การแปล และการค้นหาเนื้อหาที่ฉลาดขึ้น",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "แปลได้สูงสุด 500,000 อักขระต่อวันด้วยเครื่องมือแปลที่แม่นยำที่สุด",
"10 GB Cloud Sync Storage": "พื้นที่ซิงค์คลาวด์ 10 GB",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "จัดเก็บและเข้าถึงคอลเลคชันการอ่านอย่างปลอดภัยด้วยพื้นที่คลาวด์ 10 GB",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "จัดเก็บและเข้าถึงคอลเลคชันการอ่านอย่างปลอดภัยด้วยพื้นที่คลาวด์ 20 GB",
"Version {{version}}": "เวอร์ชัน {{version}}",
"Check Update": "ตรวจสอบอัปเดต",
"Already the latest version": "เป็นเวอร์ชันล่าสุดแล้ว",
@@ -447,5 +444,17 @@
"Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์",
"Select Cover Image": "เลือกภาพปก",
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่",
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}"
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}",
"Yandex Translate": "Yandex Translate",
"YYYY or YYYY-MM-DD": "YYYY หรือ YYYY-MM-DD",
"Restore Purchase": "กู้คืนการซื้อ",
"No purchases found to restore.": "ไม่พบการซื้อเพื่อกู้คืน",
"Failed to restore purchases.": "ไม่สามารถกู้คืนการซื้อได้",
"Failed to manage subscription.": "ไม่สามารถจัดการการสมัครสมาชิกได้",
"Failed to load subscription plans.": "ไม่สามารถโหลดแผนการสมัครสมาชิกได้",
"year": "ปี",
"Failed to create checkout session": "ไม่สามารถสร้างเซสชันการชำระเงินได้",
"Storage": "พื้นที่เก็บข้อมูล",
"Terms of Service": "ข้อกำหนดในการให้บริการ",
"Privacy Policy": "นโยบายความเป็นส่วนตัว"
}
@@ -357,7 +357,6 @@
"Upgrade to Plus or Pro": "Plus veya Pro'ya yükselt",
"Current Plan": "Mevcut Plan",
"Plan Limits": "Plan Limitleri",
"Failed to manage subscription. Please try again later.": "Abonelik yönetimi başarısız oldu. Lütfen daha sonra tekrar deneyin.",
"Processing your payment...": "Ödemeniz işleniyor...",
"Please wait while we confirm your subscription.": "Aboneliğinizi onaylıyoruz, lütfen bekleyin.",
"Payment Processing": "Ödeme İşleniyor",
@@ -400,7 +399,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Sınırsız dinleyin—istediğiniz kadar metni sürükleyici sese dönüştürün.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Daha fazla günlük kullanım ve gelişmiş seçeneklerle gelişmiş çeviri yeteneklerinin kilidini açın.",
"DeepL Pro Access": "DeepL Pro Erişimi",
"2 GB Cloud Sync Storage": "2 GB Bulut Senkronizasyon Depolama",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Yardıma ihtiyacınız olduğunda daha hızlı yanıtlar ve özel destek keyfini çıkarın.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Günlük çeviri kotasına ulaşıldı. AI çevirileri kullanmaya devam etmek için planınızı yükseltin.",
"Includes All Plus Plan Benefits": "Tüm Plus Plan Avantajları Dahil",
@@ -410,9 +408,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Akıllı okuma, çeviri ve içerik keşfi için güçlü AI araçlarını kullanın.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Mevcut en doğru çeviri motoruyla günlük 100.000 karaktere kadar çeviri yapın.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Mevcut en doğru çeviri motoruyla günlük 500.000 karaktere kadar çeviri yapın.",
"10 GB Cloud Sync Storage": "10 GB Bulut Senkronizasyon Depolama",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 2 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 10 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 5 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Tüm okuma koleksiyonunuzu 20 GB'a kadar güvenli bulut depolamayla saklayın ve erişin.",
"Deleted cloud backup of the book: {{title}}": "Kitabın bulut yedeği silindi: {{title}}",
"Failed to delete cloud backup of the book": "Kitabın bulut yedeğini silme işlemi başarısız oldu.",
"Are you sure to delete the cloud backup of the selected book?": "Seçilen kitabın bulut yedeğini silmek istediğinize emin misiniz?",
@@ -451,5 +448,17 @@
"Fiction, Science, History": "Kurgu, Bilim, Tarih",
"Select Cover Image": "Kapak Resmi Seç",
"Open Book in New Window": "Kitabı Yeni Pencerede Aç",
"Voices for {{lang}}": "{{lang}} için Sesler"
"Voices for {{lang}}": "{{lang}} için Sesler",
"Yandex Translate": "Yandex Çeviri",
"YYYY or YYYY-MM-DD": "YYYY veya YYYY-MM-DD",
"Restore Purchase": "Satın Almayı Geri Yükle",
"No purchases found to restore.": "Geri yüklemek için satın alma bulunamadı.",
"Failed to restore purchases.": "Satın alımları geri yükleme başarısız oldu.",
"Failed to manage subscription.": "Aboneliği yönetme başarısız oldu.",
"Failed to load subscription plans.": "Abonelik planlarını yükleme başarısız oldu.",
"year": "yıl",
"Failed to create checkout session": "Ödeme oturumu oluşturma başarısız oldu",
"Storage": "Depolama",
"Terms of Service": "Hizmet Şartları",
"Privacy Policy": "Gizlilik Politikası"
}
@@ -365,7 +365,6 @@
"Upgrade to Plus or Pro": "Перейти на Plus або Pro",
"Current Plan": "Поточний план",
"Plan Limits": "Обмеження плану",
"Failed to manage subscription. Please try again later.": "Не вдалося керувати підпискою. Спробуйте ще раз пізніше.",
"Processing your payment...": "Обробка вашого платежу...",
"Please wait while we confirm your subscription.": "Зачекайте, ми підтверджуємо вашу підписку.",
"Payment Processing": "Обробка платежу",
@@ -408,7 +407,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Слухайте без обмежень—перетворюйте стільки тексту, скільки хочете, в захоплююче аудіо.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Розблокуйте покращені можливості перекладу з більшим щоденним використанням та розширеними функціями.",
"DeepL Pro Access": "Доступ до DeepL Pro",
"2 GB Cloud Sync Storage": "2 ГБ Хмарного сховища",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Насолоджуйтеся швидшими відповідями та персональною допомогою, коли вам потрібна підтримка.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Досягнуто денну квоту перекладів. Оновіть свій план, щоб продовжити використовувати переклади ШІ.",
"Includes All Plus Plan Benefits": "Включає усі переваги Plus плану",
@@ -418,9 +416,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Використовуйте потужні ШІ інструменти для розумного читання, перекладу та пошуку контенту.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Перекладайте до 100 000 символів щодня з найточнішим доступним рушієм перекладу.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Перекладайте до 500 000 символів щодня з найточнішим доступним рушієм перекладу.",
"10 GB Cloud Sync Storage": "10 ГБ Хмарного сховища",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 2 ГБ хмарного сховища.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 10 ГБ хмарного сховища.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 5 ГБ хмарного сховища.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Безпечно зберігайте та отримуйте доступ до всієї колекції читання з до 20 ГБ хмарного сховища.",
"Deleted cloud backup of the book: {{title}}": "Видалено хмарну резервну копію книги: {{title}}",
"Failed to delete cloud backup of the book": "Не вдалося видалити хмарну резервну копію.",
"Are you sure to delete the cloud backup of the selected book?": "Ви впевнені, що хочете видалити хмарну резервну копію вибраної книги?",
@@ -459,5 +456,17 @@
"Fiction, Science, History": "Художня література, Наукова фантастика, Історія",
"Select Cover Image": "Оберіть зображення обкладинки",
"Open Book in New Window": "Відкрити книгу в новому вікні",
"Voices for {{lang}}": "Голоси для {{lang}}"
"Voices for {{lang}}": "Голоси для {{lang}}",
"Yandex Translate": "Yandex Перекладач",
"YYYY or YYYY-MM-DD": "YYYY або YYYY-MM-DD",
"Restore Purchase": "Відновити покупку",
"No purchases found to restore.": "Не знайдено покупок для відновлення.",
"Failed to restore purchases.": "Не вдалося відновити покупки.",
"Failed to manage subscription.": "Не вдалося керувати підпискою.",
"Failed to load subscription plans.": "Не вдалося завантажити плани підписки.",
"year": "рік",
"Failed to create checkout session": "Не вдалося створити сесію оформлення замовлення",
"Storage": "Сховище",
"Terms of Service": "Умови обслуговування",
"Privacy Policy": "Політика конфіденційності"
}
@@ -353,7 +353,6 @@
"Upgrade to Plus or Pro": "Nâng cấp lên Plus hoặc Pro",
"Current Plan": "Gói hiện tại",
"Plan Limits": "Giới hạn gói",
"Failed to manage subscription. Please try again later.": "Không thể quản lý đăng ký. Vui lòng thử lại sau.",
"Processing your payment...": "Đang xử lý thanh toán...",
"Please wait while we confirm your subscription.": "Vui lòng chờ trong khi chúng tôi xác nhận đăng ký của bạn.",
"Payment Processing": "Đang xử lý thanh toán",
@@ -396,7 +395,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "Nghe không giới hạn—chuyển đổi bao nhiêu văn bản bạn muốn thành âm thanh sống động.",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Mở khóa khả năng dịch thuật nâng cao với nhiều lượt sử dụng hàng ngày và tùy chọn nâng cao.",
"DeepL Pro Access": "Truy Cập DeepL Pro",
"2 GB Cloud Sync Storage": "Lưu Trữ Đồng Bộ Đám Mây 2 GB",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Tận hưởng phản hồi nhanh hơn và hỗ trợ chuyên biệt khi bạn cần giúp đỡ.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Đã đạt hạn ngạch dịch thuật hàng ngày. Nâng cấp gói để tiếp tục sử dụng dịch thuật AI.",
"Includes All Plus Plan Benefits": "Bao Gồm Tất Cả Lợi Ích Gói Plus",
@@ -406,9 +404,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Khai thác công cụ AI mạnh mẽ cho đọc thông minh, dịch thuật và khám phá nội dung.",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Dịch tới 100.000 ký tự hàng ngày với công cụ dịch chính xác nhất hiện có.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Dịch tới 500.000 ký tự hàng ngày với công cụ dịch chính xác nhất hiện có.",
"10 GB Cloud Sync Storage": "Lưu Trữ Đồng Bộ Đám Mây 10 GB",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 2 GB lưu trữ đám mây.",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 10 GB lưu trữ đám mây.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 5 GB lưu trữ đám mây.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Lưu trữ và truy cập an toàn toàn bộ bộ sưu tập đọc với tới 20 GB lưu trữ đám mây.",
"Deleted cloud backup of the book: {{title}}": "Đã xóa bản sao lưu đám mây của sách: {{title}}",
"Failed to delete cloud backup of the book": "Không thể xóa bản sao lưu đám mây của sách.",
"Are you sure to delete the cloud backup of the selected book?": "Bạn có chắc chắn muốn xóa bản sao lưu đám mây của sách đã chọn không?",
@@ -446,6 +443,18 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Tiểu thuyết, Khoa học, Lịch sử",
"Select Cover Image": "Chọn ảnh bìa",
"Open Book in New Window": "Open Sách trong Cửa sổ Mới",
"Voices for {{lang}}": "Giọng nói cho {{lang}}"
"Open Book in New Window": "Mở Sách trong Cửa sổ Mới",
"Voices for {{lang}}": "Giọng nói cho {{lang}}",
"Yandex Translate": "Yandex Dịch",
"YYYY or YYYY-MM-DD": "YYYY hoặc YYYY-MM-DD",
"Restore Purchase": "Khôi phục mua hàng",
"No purchases found to restore.": "Không tìm thấy giao dịch mua nào để khôi phục.",
"Failed to restore purchases.": "Khôi phục giao dịch mua không thành công.",
"Failed to manage subscription.": "Quản lý đăng ký không thành công.",
"Failed to load subscription plans.": "Tải kế hoạch đăng ký không thành công.",
"year": "năm",
"Failed to create checkout session": "Không thể tạo phiên làm việc thanh toán",
"Storage": "Lưu trữ",
"Terms of Service": "Điều khoản dịch vụ",
"Privacy Policy": "Chính sách bảo mật"
}
@@ -353,7 +353,6 @@
"Upgrade to Plus or Pro": "升级到 Plus 或 Pro",
"Current Plan": "当前套餐",
"Plan Limits": "套餐限制",
"Failed to manage subscription. Please try again later.": "无法管理订阅,请稍后再试。",
"Processing your payment...": "正在处理付款...",
"Please wait while we confirm your subscription.": "请稍候,我们正在确认您的订阅。",
"Payment Processing": "付款处理中",
@@ -396,7 +395,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "将任意数量的文本转换为沉浸式音频。",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "增强翻译功能、更多翻译用量和高级选项。",
"DeepL Pro Access": "DeepL Pro 访问权限",
"2 GB Cloud Sync Storage": "2GB 云同步存储",
"Enjoy faster responses and dedicated assistance whenever you need help.": "享受更快响应和专属帮助,随时获得支持。",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "已达每日翻译配额。升级套餐以继续使用 AI 翻译。",
"Includes All Plus Plan Benefits": "包含所有 Plus 套餐功能",
@@ -406,9 +404,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "利用强大的 AI 工具实现智能阅读、翻译和内容发现。",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "使用最精准的翻译引擎每天可翻译10万字。",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "使用最精准的翻译引擎每天可翻译50万字。",
"10 GB Cloud Sync Storage": "10GB 云同步存储",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "使用多达 2GB 的安全云存储保存和访问您的整个阅读收藏。",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "使用多达 10GB 的安全云存储保存和访问您的整个阅读收藏。",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "使用多达 5GB 的安全云存储保存和访问您的整个阅读收藏。",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "使用多达 20GB 的安全云存储保存和访问您的整个阅读收藏。",
"Deleted cloud backup of the book: {{title}}": "已删除书籍云备份:{{title}}",
"Failed to delete cloud backup of the book": "无法删除书籍云备份。",
"Are you sure to delete the cloud backup of the selected book?": "您确定要删除所选书籍的云备份吗?",
@@ -447,5 +444,17 @@
"Fiction, Science, History": "小说,科学,历史",
"Select Cover Image": "选择封面图片",
"Open Book in New Window": "在新窗口中打开书籍",
"Voices for {{lang}}": "{{lang}} 的语音"
"Voices for {{lang}}": "{{lang}} 的语音",
"Yandex Translate": "Yandex 翻译",
"YYYY or YYYY-MM-DD": "YYYY 或 YYYY-MM-DD",
"Restore Purchase": "恢复购买",
"No purchases found to restore.": "未找到可恢复的购买。",
"Failed to restore purchases.": "恢复购买失败。",
"Failed to manage subscription.": "管理订阅失败。",
"Failed to load subscription plans.": "加载订阅计划失败。",
"year": "年",
"Failed to create checkout session": "创建结账会话失败",
"Storage": "云同步空间",
"Terms of Service": "服务条款",
"Privacy Policy": "隐私政策"
}
@@ -353,7 +353,6 @@
"Upgrade to Plus or Pro": "升級到 Plus 或 Pro",
"Current Plan": "當前方案",
"Plan Limits": "方案限制",
"Failed to manage subscription. Please try again later.": "無法管理訂閱,請稍後再試。",
"Processing your payment...": "正在處理付款...",
"Please wait while we confirm your subscription.": "請稍候,我們正在確認您的訂閱。",
"Payment Processing": "付款處理中",
@@ -396,7 +395,6 @@
"Listen without limits—convert as much text as you like into immersive audio.": "無限制聆聽——將任意數量的文字轉換為沉浸式音訊。",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "解鎖增強翻譯功能,享受更多翻譯用量和進階選項。",
"DeepL Pro Access": "DeepL Pro 存取權限",
"2 GB Cloud Sync Storage": "2GB 雲端同步儲存",
"Enjoy faster responses and dedicated assistance whenever you need help.": "享受更快回應和專屬幫助,隨時獲得所需支援。",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "已達每日翻譯配額。升級方案以繼續使用 AI 翻譯。",
"Includes All Plus Plan Benefits": "包含所有 Plus 方案優惠",
@@ -406,9 +404,8 @@
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "運用強大的 AI 工具實現智慧閱讀、翻譯和內容探索。",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "使用最精準的翻譯引擎每天翻譯多達10萬字元。",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "使用最精準的翻譯引擎每天翻譯多達50萬字元。",
"10 GB Cloud Sync Storage": "10GB 雲端同步儲存",
"Securely store and access your entire reading collection with up to 2 GB of cloud storage.": "使用多達 2GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
"Securely store and access your entire reading collection with up to 10 GB of cloud storage.": "使用多達 10GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "使用多達 5GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "使用多達 20GB 的安全雲端儲存保存和存取您的整個閱讀收藏。",
"Deleted cloud backup of the book: {{title}}": "已刪除書籍的雲端備份:{{title}}",
"Failed to delete cloud backup of the book": "無法刪除書籍的雲端備份。",
"Are you sure to delete the cloud backup of the selected book?": "您確定要刪除所選書籍的雲端備份嗎?",
@@ -447,5 +444,17 @@
"Fiction, Science, History": "小說,科學,歷史",
"Select Cover Image": "選擇封面圖片",
"Open Book in New Window": "在新視窗中打開書籍",
"Voices for {{lang}}": "{{lang}} 的語音"
"Voices for {{lang}}": "{{lang}} 的語音",
"Yandex Translate": "Yandex 翻譯",
"YYYY or YYYY-MM-DD": "YYYY 或 YYYY-MM-DD",
"Restore Purchase": "恢復購買",
"No purchases found to restore.": "未找到可恢復的購買。",
"Failed to restore purchases.": "恢復購買失敗。",
"Failed to manage subscription.": "管理訂閱失敗。",
"Failed to load subscription plans.": "加載訂閱計劃失敗。",
"year": "年",
"Failed to create checkout session": "創建結帳會話失敗",
"Storage": "雲同步空間",
"Terms of Service": "服務條款",
"Privacy Policy": "隱私政策"
}
+24 -1
View File
@@ -1,5 +1,28 @@
{
"releases": {
"0.9.68": {
"date": "2025-07-30",
"notes": [
"Reader: Files now open in a new window by default on Desktop",
"Translation: Skips translating content inside <pre>, <code>, and <math> tags",
"Performance: Improved multi-part downloading for large books",
"Sync: Resolved issue preventing some PDF files from syncing",
"CSS: Resolved issue with fixed font color in some EPUB files",
"TXT: Enhanced TXT parsing for better compatibility"
]
},
"0.9.67": {
"date": "2025-07-22",
"notes": [
"Reader: Fixed file opening issue when filenames contain commas",
"Translation: Added Yandex Translator as a new translation provider",
"Layout: Hovering over header now reveals macOS traffic light window controls",
"Theme: Reader background color is now customizable for EPUB and PDF",
"Library: Search now includes book format, group names, and descriptions",
"Library: Various improvements to metadata editor and bookshelf management",
"Linux: Fixed social login failure on Linux systems"
]
},
"0.9.66": {
"date": "2025-07-21",
"notes": [
@@ -316,7 +339,7 @@
"0.9.30": {
"date": "2025-04-05",
"notes": [
"Added fixed storage quata when running the app in self-hosted mode",
"Added fixed storage quota when running the app in self-hosted mode",
"Link colors will only change when you override fonts",
"Added cache for native files for better performance"
]
+20 -15
View File
@@ -14,8 +14,12 @@ rust-version = "1.77.2"
name = "readestlib"
crate-type = ["staticlib", "cdylib", "lib"]
[features]
# Internal feature to suppress warnings from old objc crate
cargo-clippy = []
[build-dependencies]
tauri-build = { version = "2.1.1", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1.0"
@@ -25,25 +29,26 @@ thiserror = "2"
tokio = { version = "1", features = ["fs"] }
tokio-util = { version = "0.7", features = ["codec"] }
futures-util = "0.3"
futures = "0.3.31"
read-progress-stream = "1.0.0"
reqwest = { version = "0.12", default-features = false, features = [
"json",
"stream",
] }
tauri = { version = "2.5.1", features = [ "protocol-asset" ] }
tauri-build = "2.3.0"
tauri-plugin-log = "2.4.0"
tauri-plugin-fs = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-os = "2.2.1"
tauri-plugin-http = "2.4.3"
tauri-plugin-shell = "2.2.1"
tauri-plugin-process = "2.2.1"
tauri-build = "2"
tauri-plugin-log = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-os = "2"
tauri-plugin-http = "2"
tauri-plugin-shell = "2"
tauri-plugin-process = "2"
tauri-plugin-oauth = "2"
tauri-plugin-opener = "2.2.6"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-sign-in-with-apple = "1.0.2"
tauri-plugin-haptics = "2.2.4"
tauri-plugin-haptics = "2"
tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" }
tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
@@ -60,6 +65,6 @@ objc2-foundation = { version = "0.3", features = ["NSError", "NSArray"] }
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-cli = "2"
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-updater = "2.7.0"
tauri-plugin-window-state = "2.2.2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
@@ -68,6 +68,9 @@
{
"url": "https://edge.microsoft.com"
},
{
"url": "https://translate.toil.cc"
},
{
"url": "https://*.microsofttranslator.com"
},
@@ -9,13 +9,13 @@ exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-native-bridge"
[dependencies]
tauri = { version = "2.3.1" }
tauri = { version = "2" }
serde = "1.0"
thiserror = "2"
schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.0.4", features = ["build"] }
tauri-plugin = { version = "2", features = ["build"] }
schemars = "0.8"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
@@ -9,6 +9,10 @@ const COMMANDS: &[&str] = &[
"get_sys_fonts_list",
"intercept_keys",
"lock_screen_orientation",
"iap_initialize",
"iap_fetch_products",
"iap_purchase_product",
"iap_restore_purchases",
];
fn main() {
@@ -3,6 +3,7 @@ import AuthenticationServices
import CoreText
import MediaPlayer
import ObjectiveC
import StoreKit
import SwiftRs
import Tauri
import UIKit
@@ -48,6 +49,37 @@ class LockScreenOrientationRequestArgs: Decodable {
let orientation: String?
}
struct InitializeRequest: Decodable {
let publicKey: String?
}
struct FetchProductsRequest: Decodable {
let productIds: [String]
}
struct PurchaseProductRequest: Decodable {
let productId: String
}
struct ProductData: Codable {
let id: String
let title: String
let description: String
let price: String
let priceCurrencyCode: String?
let priceAmountMicros: Int64
let productType: String
}
struct PurchaseData: Codable {
let productId: String
let transactionId: String
let originalTransactionId: String
let purchaseDate: String
let purchaseState: String
let platform: String
}
class VolumeKeyHandler: NSObject {
private var audioSession: AVAudioSession?
private var originalVolume: Float = 0.0
@@ -422,6 +454,85 @@ class NativeBridgePlugin: Plugin {
}
}
}
@objc public func iap_initialize(_ invoke: Invoke) {
StoreKitManager.shared.initialize()
invoke.resolve(["success": true])
}
@objc public func iap_fetch_products(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(FetchProductsRequest.self)
StoreKitManager.shared.fetchProducts(productIds: args.productIds) { products in
let productsData: [ProductData] = products.map { product in
return ProductData(
id: product.productIdentifier,
title: product.localizedTitle,
description: product.localizedDescription,
price: product.price.stringValue,
priceCurrencyCode: product.priceLocale.currencyCode,
priceAmountMicros: Int64(product.price.doubleValue * 1_000_000),
productType: product.productIdentifier.contains("monthly")
|| product.productIdentifier.contains("yearly") ? "subscription" : "consumable"
)
}
invoke.resolve(["products": productsData])
}
} catch {
invoke.reject("Failed to parse fetch products arguments: \(error.localizedDescription)")
}
}
@objc public func iap_purchase_product(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(PurchaseProductRequest.self)
StoreKitManager.shared.fetchProducts(productIds: [args.productId]) { products in
guard let product = products.first else {
invoke.reject("Product not found")
return
}
StoreKitManager.shared.purchase(product: product) { result in
switch result {
case .success(let txn):
let purchase = PurchaseData(
productId: txn.payment.productIdentifier,
transactionId: txn.transactionIdentifier ?? "",
originalTransactionId: txn.original?.transactionIdentifier ?? txn
.transactionIdentifier ?? "",
purchaseDate: ISO8601DateFormatter().string(from: txn.transactionDate ?? Date()),
purchaseState: "purchased",
platform: "ios"
)
invoke.resolve(["purchase": purchase])
case .failure(let error):
invoke.reject("Purchase failed: \(error.localizedDescription)")
}
}
}
} catch {
invoke.reject("Failed to parse purchase arguments: \(error.localizedDescription)")
}
}
@objc public func iap_restore_purchases(_ invoke: Invoke) {
StoreKitManager.shared.restorePurchases { transactions in
let restored = transactions.map { txn -> PurchaseData in
return PurchaseData(
productId: txn.payment.productIdentifier,
transactionId: txn.transactionIdentifier ?? "",
originalTransactionId: txn.original?.transactionIdentifier ?? txn.transactionIdentifier
?? "",
purchaseDate: ISO8601DateFormatter().string(from: txn.transactionDate ?? Date()),
purchaseState: "restored",
platform: "ios"
)
}
invoke.resolve(["purchases": restored])
}
}
}
@_cdecl("init_plugin_native_bridge")
@@ -0,0 +1,121 @@
import StoreKit
import os
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StoreKitManager")
class StoreKitManager: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
static let shared = StoreKitManager()
private var productsRequest: SKProductsRequest?
private var productResponseHandler: (([SKProduct]) -> Void)?
private var purchaseHandler: ((Result<SKPaymentTransaction, Error>) -> Void)?
private var restoreHandler: (([SKPaymentTransaction]) -> Void)?
private override init() {
super.init()
}
func initialize() {
SKPaymentQueue.default().add(self)
}
deinit {
SKPaymentQueue.default().remove(self)
}
func fetchProducts(productIds: [String], completion: @escaping ([SKProduct]) -> Void) {
let ids = Set(productIds)
productsRequest = SKProductsRequest(productIdentifiers: ids)
productsRequest?.delegate = self
productResponseHandler = completion
productsRequest?.start()
}
func purchase(
product: SKProduct, completion: @escaping (Result<SKPaymentTransaction, Error>) -> Void
) {
guard SKPaymentQueue.canMakePayments() else {
completion(
.failure(
NSError(
domain: "iap", code: 0, userInfo: [NSLocalizedDescriptionKey: "Purchases disabled."])))
return
}
purchaseHandler = completion
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
func restorePurchases(completion: @escaping ([SKPaymentTransaction]) -> Void) {
restoreHandler = completion
SKPaymentQueue.default().restoreCompletedTransactions()
}
// MARK: - SKProductsRequestDelegate
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
DispatchQueue.main.async { [weak self] in
self?.productResponseHandler?(response.products)
self?.productResponseHandler = nil
}
}
func request(_ request: SKRequest, didFailWithError error: Error) {
DispatchQueue.main.async { [weak self] in
logger.error("Products request failed: \(error.localizedDescription)")
self?.productResponseHandler?([])
self?.productResponseHandler = nil
}
}
// MARK: - SKPaymentTransactionObserver
func paymentQueue(
_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]
) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
SKPaymentQueue.default().finishTransaction(transaction)
DispatchQueue.main.async { [weak self] in
self?.purchaseHandler?(.success(transaction))
self?.purchaseHandler = nil
}
case .failed:
SKPaymentQueue.default().finishTransaction(transaction)
let error =
transaction.error
?? NSError(
domain: "iap", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase error"]
)
DispatchQueue.main.async { [weak self] in
self?.purchaseHandler?(.failure(error))
self?.purchaseHandler = nil
}
case .restored:
SKPaymentQueue.default().finishTransaction(transaction)
case .deferred, .purchasing:
// Handle these states if needed
break
@unknown default:
break
}
}
}
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
let restored = queue.transactions.filter { $0.transactionState == .restored }
DispatchQueue.main.async { [weak self] in
self?.restoreHandler?(restored)
self?.restoreHandler = nil
}
}
func paymentQueue(
_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error
) {
DispatchQueue.main.async { [weak self] in
logger.error("Restore purchases failed: \(error.localizedDescription)")
self?.restoreHandler?([])
self?.restoreHandler = nil
}
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-fetch-products"
description = "Enables the iap_fetch_products command without any pre-configured scope."
commands.allow = ["iap_fetch_products"]
[[permission]]
identifier = "deny-iap-fetch-products"
description = "Denies the iap_fetch_products command without any pre-configured scope."
commands.deny = ["iap_fetch_products"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-initialize"
description = "Enables the iap_initialize command without any pre-configured scope."
commands.allow = ["iap_initialize"]
[[permission]]
identifier = "deny-iap-initialize"
description = "Denies the iap_initialize command without any pre-configured scope."
commands.deny = ["iap_initialize"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-purchase-product"
description = "Enables the iap_purchase_product command without any pre-configured scope."
commands.allow = ["iap_purchase_product"]
[[permission]]
identifier = "deny-iap-purchase-product"
description = "Denies the iap_purchase_product command without any pre-configured scope."
commands.deny = ["iap_purchase_product"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-restore-purchases"
description = "Enables the iap_restore_purchases command without any pre-configured scope."
commands.allow = ["iap_restore_purchases"]
[[permission]]
identifier = "deny-iap-restore-purchases"
description = "Denies the iap_restore_purchases command without any pre-configured scope."
commands.deny = ["iap_restore_purchases"]
@@ -14,6 +14,10 @@ Default permissions for the plugin
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
- `allow-lock-screen-orientation`
- `allow-iap-initialize`
- `allow-iap-fetch-products`
- `allow-iap-purchase-product`
- `allow-iap-restore-purchases`
## Permission Table
@@ -157,6 +161,110 @@ Denies the get_sys_fonts_list command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-iap-fetch-products`
</td>
<td>
Enables the iap_fetch_products command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-fetch-products`
</td>
<td>
Denies the iap_fetch_products command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-initialize`
</td>
<td>
Enables the iap_initialize command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-initialize`
</td>
<td>
Denies the iap_initialize command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-purchase-product`
</td>
<td>
Enables the iap_purchase_product command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-purchase-product`
</td>
<td>
Denies the iap_purchase_product command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-restore-purchases`
</td>
<td>
Enables the iap_restore_purchases command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-restore-purchases`
</td>
<td>
Denies the iap_restore_purchases command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-install-package`
</td>
@@ -11,4 +11,8 @@ permissions = [
"allow-get-sys-fonts-list",
"allow-intercept-keys",
"allow-lock-screen-orientation",
"allow-iap-initialize",
"allow-iap-fetch-products",
"allow-iap-purchase-product",
"allow-iap-restore-purchases",
]
@@ -354,6 +354,54 @@
"const": "deny-get-sys-fonts-list",
"markdownDescription": "Denies the get_sys_fonts_list command without any pre-configured scope."
},
{
"description": "Enables the iap_fetch_products command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-fetch-products",
"markdownDescription": "Enables the iap_fetch_products command without any pre-configured scope."
},
{
"description": "Denies the iap_fetch_products command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-fetch-products",
"markdownDescription": "Denies the iap_fetch_products command without any pre-configured scope."
},
{
"description": "Enables the iap_initialize command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-initialize",
"markdownDescription": "Enables the iap_initialize command without any pre-configured scope."
},
{
"description": "Denies the iap_initialize command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-initialize",
"markdownDescription": "Denies the iap_initialize command without any pre-configured scope."
},
{
"description": "Enables the iap_purchase_product command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-purchase-product",
"markdownDescription": "Enables the iap_purchase_product command without any pre-configured scope."
},
{
"description": "Denies the iap_purchase_product command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-purchase-product",
"markdownDescription": "Denies the iap_purchase_product command without any pre-configured scope."
},
{
"description": "Enables the iap_restore_purchases command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-restore-purchases",
"markdownDescription": "Enables the iap_restore_purchases command without any pre-configured scope."
},
{
"description": "Denies the iap_restore_purchases command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-restore-purchases",
"markdownDescription": "Denies the iap_restore_purchases command without any pre-configured scope."
},
{
"description": "Enables the install_package command without any pre-configured scope.",
"type": "string",
@@ -415,10 +463,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`"
}
]
}
@@ -81,3 +81,34 @@ pub(crate) async fn lock_screen_orientation<R: Runtime>(
) -> Result<()> {
app.native_bridge().lock_screen_orientation(payload)
}
#[command]
pub(crate) async fn iap_initialize<R: Runtime>(
app: AppHandle<R>,
payload: IAPInitializeRequest,
) -> Result<IAPInitializeResponse> {
app.native_bridge().iap_initialize(payload)
}
#[command]
pub(crate) async fn iap_fetch_products<R: Runtime>(
app: AppHandle<R>,
payload: IAPFetchProductsRequest,
) -> Result<IAPFetchProductsResponse> {
app.native_bridge().iap_fetch_products(payload)
}
#[command]
pub(crate) async fn iap_purchase_product<R: Runtime>(
app: AppHandle<R>,
payload: IAPPurchaseProductRequest,
) -> Result<IAPPurchaseProductResponse> {
app.native_bridge().iap_purchase_product(payload)
}
#[command]
pub(crate) async fn iap_restore_purchases<R: Runtime>(
app: AppHandle<R>,
) -> Result<IAPRestorePurchasesResponse> {
app.native_bridge().iap_restore_purchases()
}
@@ -73,4 +73,29 @@ impl<R: Runtime> NativeBridge<R> {
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_initialize(
&self,
_payload: IAPInitializeRequest,
) -> crate::Result<IAPInitializeResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_fetch_products(
&self,
_payload: IAPFetchProductsRequest,
) -> crate::Result<IAPFetchProductsResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_purchase_product(
&self,
_payload: IAPPurchaseProductRequest,
) -> crate::Result<IAPPurchaseProductResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -47,6 +47,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::get_sys_fonts_list,
commands::intercept_keys,
commands::lock_screen_orientation,
commands::iap_initialize,
commands::iap_fetch_products,
commands::iap_purchase_product,
commands::iap_restore_purchases,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -112,3 +112,46 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_initialize(
&self,
payload: IAPInitializeRequest,
) -> crate::Result<IAPInitializeResponse> {
self.0
.run_mobile_plugin("iap_initialize", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_fetch_products(
&self,
payload: IAPFetchProductsRequest,
) -> crate::Result<IAPFetchProductsResponse> {
self.0
.run_mobile_plugin("iap_fetch_products", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_purchase_product(
&self,
payload: IAPPurchaseProductRequest,
) -> crate::Result<IAPPurchaseProductResponse> {
self.0
.run_mobile_plugin("iap_purchase_product", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_restore_purchases(
&self,
) -> crate::Result<IAPRestorePurchasesResponse> {
self.0
.run_mobile_plugin("iap_restore_purchases", ())
.map_err(Into::into)
}
}
@@ -86,3 +86,69 @@ pub struct InterceptKeysRequest {
pub struct LockScreenOrientationRequest {
pub orientation: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Product {
pub id: String,
pub title: String,
pub description: String,
pub price: String,
pub price_currency_code: Option<String>,
pub price_amount_micros: i64,
pub product_type: String, // "consumable", "non_consumable", or "subscription"
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Purchase {
pub product_id: String,
pub transaction_id: String,
pub purchase_date: String,
pub original_transaction_id: String,
pub purchase_state: String, // "purchased", "pending", "cancelled"
pub platform: String, // "ios" or "android"
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPInitializeRequest {
pub public_key: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPInitializeResponse {
pub success: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPFetchProductsRequest {
pub product_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPFetchProductsResponse {
pub products: Vec<Product>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPPurchaseProductRequest {
pub product_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPPurchaseProductResponse {
pub purchase: Option<Purchase>,
pub cancelled_purchase: Option<Purchase>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPRestorePurchasesResponse {
pub purchases: Vec<Purchase>,
}
@@ -9,11 +9,11 @@ exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules"]
links = "tauri-plugin-native-tts"
[dependencies]
tauri = { version = "2.5.0" }
tauri = { version = "2" }
serde = "1.0"
thiserror = "2"
schemars = "0.8"
[build-dependencies]
tauri-plugin = { version = "2.2.0", features = ["build"] }
tauri-plugin = { version = "2", features = ["build"] }
schemars = "0.8"
@@ -365,26 +365,26 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
app_box: *mut c_void = app_box,
toolbar: id = cocoa::base::nil,
super_delegate: id = current_delegate,
(windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
(windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
(windowDidResize:) => on_window_did_resize::<R> as extern fn(&Object, Sel, id),
(windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
(windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
(windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
(draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
(draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern fn(&Object, Sel, id),
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern fn(&Object, Sel, id),
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern fn(&Object, Sel, id),
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern fn(&Object, Sel, id),
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
(windowShouldClose:) => on_window_should_close as extern "C" fn(&Object, Sel, id) -> BOOL,
(windowWillClose:) => on_window_will_close as extern "C" fn(&Object, Sel, id),
(windowDidResize:) => on_window_did_resize::<R> as extern "C" fn(&Object, Sel, id),
(windowDidMove:) => on_window_did_move as extern "C" fn(&Object, Sel, id),
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern "C" fn(&Object, Sel, id),
(windowDidBecomeKey:) => on_window_did_become_key as extern "C" fn(&Object, Sel, id),
(windowDidResignKey:) => on_window_did_resign_key as extern "C" fn(&Object, Sel, id),
(draggingEntered:) => on_dragging_entered as extern "C" fn(&Object, Sel, id) -> BOOL,
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL,
(performDragOperation:) => on_perform_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL,
(concludeDragOperation:) => on_conclude_drag_operation as extern "C" fn(&Object, Sel, id),
(draggingExited:) => on_dragging_exited as extern "C" fn(&Object, Sel, id),
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern "C" fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern "C" fn(&Object, Sel, id),
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern "C" fn(&Object, Sel, id),
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern "C" fn(&Object, Sel, id),
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern "C" fn(&Object, Sel, id),
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern "C" fn(&Object, Sel, id),
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern "C" fn(&Object, Sel, id),
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern "C" fn(&Object, Sel, id)
}))
}
}
+119 -31
View File
@@ -17,8 +17,8 @@ use tokio_util::codec::{BytesCodec, FramedRead};
use read_progress_stream::ReadProgressStream;
use std::collections::HashMap;
use std::time::Instant;
use std::{collections::HashMap, sync::Arc};
type Result<T> = std::result::Result<T, Error>;
@@ -109,41 +109,129 @@ pub async fn download_file(
body: Option<String>,
on_progress: Channel<ProgressPayload>,
) -> Result<()> {
use futures::stream::{self, StreamExt};
use std::cmp::min;
use tokio::io::AsyncSeekExt;
const PART_SIZE: u64 = 1024 * 1024;
let client = reqwest::Client::new();
let mut request = if let Some(body) = body {
client.post(url).body(body)
} else {
client.get(url)
};
// Loop trought the headers keys and values
// and add them to the request object.
for (key, value) in headers {
request = request.header(&key, value);
// Check if server supports range requests
let range_resp = client.get(url).header("Range", "bytes=0-0").send().await?;
let accept_ranges = range_resp
.headers()
.get("accept-ranges")
.map(|v| v.to_str().unwrap_or(""))
.unwrap_or("")
.eq_ignore_ascii_case("bytes");
let total = range_resp
.headers()
.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split('/').nth(1))
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if !accept_ranges || total == 0 {
// Fallback to original single-threaded logic
let mut request = if let Some(body) = body {
client.post(url).body(body)
} else {
client.get(url)
};
for (key, value) in headers.iter() {
request = request.header(key, value);
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::HttpErrorCode(
response.status().as_u16(),
response.text().await.unwrap_or_default(),
));
}
let total = response.content_length().unwrap_or(0);
let mut file = BufWriter::new(File::create(file_path).await?);
let mut stream = response.bytes_stream();
let mut stats = TransferStats::default();
while let Some(chunk) = stream.try_next().await? {
file.write_all(&chunk).await?;
stats.record_chunk_transfer(chunk.len());
let _ = on_progress.send(ProgressPayload {
progress: stats.total_transferred,
total,
transfer_speed: stats.transfer_speed,
});
}
file.flush().await?;
return Ok(());
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(Error::HttpErrorCode(
response.status().as_u16(),
response.text().await.unwrap_or_default(),
));
}
let total = response.content_length().unwrap_or(0);
// Multi-part download with range access
let part_count = total.div_ceil(PART_SIZE);
let file = File::create(file_path).await?;
file.set_len(total).await?;
let mut file = BufWriter::new(File::create(file_path).await?);
let mut stream = response.bytes_stream();
let file = Arc::new(tokio::sync::Mutex::new(file));
let progress = Arc::new(tokio::sync::Mutex::new(TransferStats::default()));
let mut stats = TransferStats::default();
while let Some(chunk) = stream.try_next().await? {
file.write_all(&chunk).await?;
stats.record_chunk_transfer(chunk.len());
let _ = on_progress.send(ProgressPayload {
progress: stats.total_transferred,
total,
transfer_speed: stats.transfer_speed,
});
}
file.flush().await?;
stream::iter(0..part_count)
.for_each_concurrent(8, |i| {
let client = client.clone();
let file = Arc::clone(&file);
let progress = Arc::clone(&progress);
let headers = headers.clone();
let url = url.to_string();
let on_progress = on_progress.clone();
async move {
let start = i * PART_SIZE;
let end = min(start + PART_SIZE - 1, total - 1);
let range_header = format!("bytes={start}-{end}");
let mut req = client.get(&url).header("Range", range_header);
for (key, value) in headers {
req = req.header(key, value);
}
let resp = match req.send().await {
Ok(r) => r,
Err(_) => return,
};
if !resp.status().is_success()
&& resp.status() != reqwest::StatusCode::PARTIAL_CONTENT
{
return;
}
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(_) => return,
};
{
let mut f = file.lock().await;
f.seek(std::io::SeekFrom::Start(start)).await.unwrap();
f.write_all(&bytes).await.unwrap();
}
{
let mut stat = progress.lock().await;
stat.record_chunk_transfer(bytes.len());
let _ = on_progress.send(ProgressPayload {
progress: stat.total_transferred,
total,
transfer_speed: stat.transfer_speed,
});
}
}
})
.await;
Ok(())
}
+3 -3
View File
@@ -15,10 +15,10 @@
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://ik.imagekit.io",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://ik.imagekit.io",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app",
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
},
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { POST } from '@/app/api/apple/iap-verify/route';
import { NextRequest } from 'next/server';
import { setupSupabaseMocks } from '../helpers/supabase-mock';
vi.mock('@/utils/supabase', () => ({
supabase: {
auth: {
getUser: vi.fn(),
refreshSession: vi.fn(),
},
from: vi.fn(),
},
createSupabaseAdminClient: vi.fn(),
}));
describe('/api/apple/iap-verify', () => {
it('should verify a valid transaction', async () => {
setupSupabaseMocks();
const request = new NextRequest('http://localhost:3000/api/apple/iap-verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: JSON.stringify({
transactionId: '2000000969168810',
originalTransactionId: '2000000968585424',
}),
});
const response = await POST(request);
const data = await response.json();
console.log('Response:', data);
expect(response.status).toBe(200);
expect(data.purchase).toBeDefined();
});
});
@@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { vi } from 'vitest';
export const setupSupabaseMocks = async (
customResponses = {
getUser: null,
select: null,
upsert: null,
update: null,
insert: null,
adminUpsert: null,
adminSelect: {
data: [],
error: null,
},
adminSelectMany: null,
adminUpdate: null,
adminInsert: null,
adminDelete: null,
adminSelectSingle: null,
},
) => {
const { supabase, createSupabaseAdminClient } = await import('@/utils/supabase');
vi.mocked(supabase.auth.getUser).mockResolvedValue(
customResponses.getUser || {
data: {
user: {
id: 'test-user-123',
email: 'test@example.com',
app_metadata: {},
user_metadata: {},
aud: 'test-aud',
created_at: new Date().toISOString(),
},
},
error: null,
},
);
vi.mocked(supabase.from).mockReturnValue({
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn().mockResolvedValue(customResponses.select || { data: null, error: null }),
})),
})),
upsert: vi.fn().mockResolvedValue(customResponses.upsert || { data: {}, error: null }),
update: vi.fn(() => ({
eq: vi.fn().mockResolvedValue(customResponses.update || { data: {}, error: null }),
})),
insert: vi.fn().mockResolvedValue(customResponses.insert || { data: {}, error: null }),
} as any);
vi.mocked(createSupabaseAdminClient).mockReturnValue({
from: vi.fn(() => ({
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn().mockResolvedValue({ data: null, error: null }),
order: vi.fn().mockResolvedValue({ data: [], error: null }),
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
})),
single: vi
.fn()
.mockResolvedValue(customResponses.adminSelect || { data: null, error: null }),
limit: vi.fn(() => ({
single: vi
.fn()
.mockResolvedValue(customResponses.adminSelect || { data: null, error: null }),
})),
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
neq: vi.fn(() => ({
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
gt: vi.fn(() => ({
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
in: vi.fn(() => ({
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
limit: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
upsert: vi.fn().mockResolvedValue(customResponses.adminUpsert || { data: [], error: null }),
update: vi.fn(() => ({
eq: vi.fn().mockResolvedValue(customResponses.adminUpdate || { data: {}, error: null }),
match: vi.fn().mockResolvedValue(customResponses.adminUpdate || { data: {}, error: null }),
})),
insert: vi.fn().mockResolvedValue(customResponses.adminInsert || { data: {}, error: null }),
delete: vi.fn(() => ({
eq: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
match: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
})),
})),
} as any);
};
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { AppleIAPVerifier, createAppleIAPVerifier } from '@/libs/iap/apple/verifier';
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
const REAL_TEST_DATA = {
validSubscription: {
transactionId: '2000000969168810',
originalTransactionId: '2000000968585424',
productId: 'com.bilingify.readest.monthly.plus',
},
expiredSubscription: {
transactionId: '2000000969189989',
originalTransactionId: '2000000969189989',
productId: 'com.bilingify.readest.monthly.plus',
},
refundedTransaction: {
transactionId: '1000000555666777',
originalTransactionId: '1000000555666777',
productId: 'com.bilingify.readest.monthly.plus',
},
};
describe.skipIf(SKIP_IAP_API_TESTS)('Apple IAP Integration Tests', () => {
let verifier: AppleIAPVerifier;
beforeAll(() => {
verifier = createAppleIAPVerifier({
keyId: process.env['APPLE_IAP_KEY_ID']!,
issuerId: process.env['APPLE_IAP_ISSUER_ID']!,
bundleId: process.env['APPLE_IAP_BUNDLE_ID']!,
privateKey: atob(process.env['APPLE_IAP_PRIVATE_KEY_BASE64']!),
environment: 'sandbox',
});
});
describe('Transaction Verification', () => {
it('should verify a real valid transaction', async () => {
const { transactionId, originalTransactionId } = REAL_TEST_DATA.validSubscription;
const result = await verifier.verifyTransaction(originalTransactionId);
expect(result.success).toBe(true);
expect(result.verified).toBe(true);
expect(result.status).toBe('active');
expect(result.transactionId).toBe(transactionId);
expect(result.originalTransactionId).toBe(originalTransactionId);
expect(result.bundleId).toBe(process.env['APPLE_IAP_BUNDLE_ID']);
expect(result.productId).toBeTruthy();
expect(result.purchaseDate).toBeInstanceOf(Date);
}, 10000);
it('should handle expired subscription correctly', async () => {
const { originalTransactionId } = REAL_TEST_DATA.expiredSubscription;
const result = await verifier.verifyTransaction(originalTransactionId);
expect(result.success).toBe(true);
expect(result.verified).toBe(true);
expect(result.status).toBe('expired');
}, 10000);
it('should reject invalid transaction IDs', async () => {
const result = await verifier.verifyTransaction('invalid_transaction_id');
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
}, 10000);
});
});
@@ -0,0 +1,241 @@
import { z } from 'zod';
import { NextResponse } from 'next/server';
import { getAppleIAPVerifier } from '@/libs/iap/apple/verifier';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { IAPError } from '@/types/error';
const iapVerificationSchema = z.object({
transactionId: z.string().min(1, 'Transaction ID is required'),
originalTransactionId: z.string().min(1, 'Original Transaction ID is required'),
});
const PRODUCT_MAP: Record<string, string> = {
'com.bilingify.readest.monthly.plus': 'Plus',
'com.bilingify.readest.yearly.plus': 'Plus',
'com.bilingify.readest.monthly.pro': 'Pro',
'com.bilingify.readest.yearly.pro': 'Pro',
};
const getProductName = (productId: string) => {
return PRODUCT_MAP[productId] || productId;
};
const getProductPlan = (productId: string) => {
if (productId.includes('plus')) {
return 'plus';
} else if (productId.includes('pro')) {
return 'pro';
}
return 'free';
};
interface Purchase {
status: string;
customerEmail: string;
subscriptionId: string;
planName: string;
productId: string;
platform: string;
transactionId: string;
originalTransactionId: string;
purchaseDate?: string;
expiresDate?: string | null;
quantity: number;
environment: string;
bundleId: string;
webOrderLineItemId?: string;
subscriptionGroupIdentifier?: string;
type?: string;
revocationDate?: string | null;
revocationReason?: number | null;
}
async function updateUserSubscription(userId: string, purchase: Purchase) {
try {
const supabase = createSupabaseAdminClient();
const { data, error } = await supabase.from('apple_iap_subscriptions').upsert(
{
user_id: userId,
platform: purchase.platform,
product_id: purchase.productId,
transaction_id: purchase.transactionId,
original_transaction_id: purchase.originalTransactionId,
status: purchase.status === 'active' ? 'active' : 'expired',
purchase_date: purchase.purchaseDate,
expires_date: purchase.expiresDate,
environment: purchase.environment,
bundle_id: purchase.bundleId,
quantity: purchase.quantity || 1,
auto_renew_status: true,
web_order_line_item_id: purchase.webOrderLineItemId,
subscription_group_identifier: purchase.subscriptionGroupIdentifier,
created_at: new Date(),
updated_at: new Date(),
},
{
onConflict: 'user_id,original_transaction_id',
},
);
if (error) {
console.error('Database update error:', error);
throw new Error(`Database update failed: ${error.message}`);
}
const plan = await getProductPlan(purchase.productId);
await supabase
.from('plans')
.update({
plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free',
status: purchase.status,
})
.eq('id', userId);
return data;
} catch (error) {
console.error('Failed to update user subscription:', error);
throw error;
}
}
export async function POST(request: Request) {
const body = await request.json();
let validatedInput;
try {
validatedInput = iapVerificationSchema.parse(body);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Invalid input data',
purchase: null,
},
{ status: 400 },
);
}
}
const { transactionId, originalTransactionId } = validatedInput!;
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: IAPError.NOT_AUTHENTICATED }, { status: 403 });
}
try {
const supabase = createSupabaseAdminClient();
const { data: existingSubscription } = await supabase
.from('apple_iap_subscriptions')
.select('*')
.eq('original_transaction_id', originalTransactionId)
.single();
console.log('Existing subscription:', existingSubscription);
// Should not restore purchase for another account
if (existingSubscription && existingSubscription.user_id !== user.id) {
return NextResponse.json(
{ error: IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER },
{ status: 403 },
);
}
if (
existingSubscription &&
existingSubscription.transactionId === transactionId &&
existingSubscription.status === 'active'
) {
console.log('Transaction already verified and active');
const purchase = {
status: existingSubscription.status,
customerEmail: user.email,
subscriptionId:
existingSubscription.web_order_line_item_id ||
existingSubscription.original_transaction_id,
planName: getProductName(existingSubscription.product_id),
productId: existingSubscription.product_id,
platform: existingSubscription.platform,
transactionId: existingSubscription.transaction_id,
originalTransactionId: existingSubscription.original_transaction_id,
purchaseDate: existingSubscription.purchase_date,
expiresDate: existingSubscription.expires_date,
quantity: existingSubscription.quantity,
environment: existingSubscription.environment,
bundleId: existingSubscription.bundle_id,
};
return NextResponse.json({
purchase,
error: null,
});
}
const defaultIAPVerifier = getAppleIAPVerifier();
const verificationResult = await defaultIAPVerifier.verifyTransaction(originalTransactionId);
if (!verificationResult.success) {
console.error('Apple verification failed:', verificationResult.error);
return NextResponse.json(
{
error: verificationResult.error || IAPError.UNKNOWN_ERROR,
purchase: null,
},
{ status: 400 },
);
}
const transaction = verificationResult.transaction!;
console.log('Apple verification successful:', {
transactionId: transaction.transactionId,
productId: transaction.productId,
environment: transaction.environment,
});
if (transaction.environment === 'Sandbox' && process.env.NODE_ENV === 'production') {
console.warn('Sandbox transaction in production environment');
}
const purchase = {
status: verificationResult.status!,
customerEmail: user.email!,
subscriptionId: transaction.webOrderLineItemId || transaction.originalTransactionId,
planName: getProductName(transaction.productId),
productId: transaction.productId,
platform: 'ios',
transactionId: transaction.transactionId,
originalTransactionId: transaction.originalTransactionId,
purchaseDate: verificationResult.purchaseDate?.toISOString(),
expiresDate: verificationResult.expiresDate?.toISOString() || null,
quantity: transaction.quantity,
environment: transaction.environment.toLowerCase(),
bundleId: transaction.bundleId,
webOrderLineItemId: transaction.webOrderLineItemId,
subscriptionGroupIdentifier: transaction.subscriptionGroupIdentifier,
type: transaction.type,
revocationDate: verificationResult.revocationDate?.toISOString() || null,
revocationReason: verificationResult.revocationReason,
};
try {
await updateUserSubscription(user.id, purchase);
} catch (dbError) {
console.error('Database update failed:', dbError);
return NextResponse.json(
{
error: IAPError.TRANSACTION_SERVICE_UNAVAILABLE,
purchase: null,
},
{ status: 500 },
);
}
return NextResponse.json({
purchase,
error: null,
});
} catch (error) {
console.error('IAP verification error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : IAPError.UNKNOWN_ERROR },
{ status: 500 },
);
}
}
@@ -28,6 +28,7 @@ export async function GET() {
currency: price.currency,
interval: price.recurring?.interval,
product: price.product,
productName: product.name,
};
});
@@ -22,7 +22,7 @@ interface BookItemProps {
mode: LibraryViewModeType;
coverFit: LibraryCoverFitType;
isSelectMode: boolean;
selectedBooks: string[];
bookSelected: boolean;
transferProgress: number | null;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
@@ -34,7 +34,7 @@ const BookItem: React.FC<BookItemProps> = ({
mode,
coverFit,
isSelectMode,
selectedBooks,
bookSelected,
transferProgress,
handleBookUpload,
handleBookDownload,
@@ -61,18 +61,18 @@ const BookItem: React.FC<BookItemProps> = ({
>
<div
className={clsx(
'relative flex aspect-[28/41] items-center justify-center',
coverFit === 'crop' && 'overflow-hidden shadow-md',
'relative flex items-end justify-center',
coverFit === 'crop' && 'aspect-[28/41] overflow-hidden shadow-md',
mode === 'list' && 'min-w-20',
)}
>
<BookCover mode={mode} book={book} coverFit={coverFit} />
{selectedBooks.includes(book.hash) && (
<BookCover mode={mode} book={book} coverFit={coverFit} showSpine={false} />
{bookSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(book.hash) ? (
{bookSelected ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
@@ -54,7 +54,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { appService } = useEnv();
const { settings } = useSettingsStore();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showGroupingModal, setShowGroupingModal] = useState(false);
@@ -70,6 +69,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const isImportingBook = useRef(false);
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
@@ -162,23 +162,24 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
const toggleSelection = (id: string) => {
setSelectedBooks((prev) =>
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
);
};
const toggleSelection = useCallback((id: string) => {
toggleSelectedBook(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const openSelectedBooks = () => {
handleSetSelectMode(false);
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, selectedBooks);
showReaderWindow(appService, getSelectedBooks());
} else {
setTimeout(() => setLoading(true), 200);
navigateToReader(router, selectedBooks);
navigateToReader(router, getSelectedBooks());
}
};
const openBookDetails = () => {
handleSetSelectMode(false);
const selectedBooks = getSelectedBooks();
const book = libraryBooks.find((book) => book.hash === selectedBooks[0]);
if (book) {
handleShowDetailsBook(book);
@@ -187,6 +188,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const getBooksToDelete = () => {
const booksToDelete: Book[] = [];
const selectedBooks = getSelectedBooks();
selectedBooks.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (book && !book.deletedAt) {
@@ -221,7 +223,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return searchTerm.test(title) || searchTerm.test(authors);
return (
searchTerm.test(title) ||
searchTerm.test(authors) ||
searchTerm.test(item.format) ||
(item.groupName && searchTerm.test(item.groupName)) ||
(item.metadata?.description && searchTerm.test(item.metadata?.description))
);
};
const bookSorter = (a: Book, b: Book) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
@@ -286,6 +294,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSelectMode, isSelectAll, isSelectNone]);
const selectedBooks = getSelectedBooks();
return (
<div className='bookshelf'>
<div
@@ -303,7 +313,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
mode={viewMode as LibraryViewModeType}
coverFit={coverFit as LibraryCoverFitType}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
itemSelected={
'hash' in item ? selectedBooks.includes(item.hash) : selectedBooks.includes(item.id)
}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleBookUpload={handleBookUpload}
@@ -1,4 +1,5 @@
import clsx from 'clsx';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
import { useEnv } from '@/context/EnvContext';
@@ -8,8 +9,9 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getLocalBookFilename } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
@@ -75,7 +77,7 @@ interface BookshelfItemProps {
item: BookshelfItem;
coverFit: LibraryCoverFitType;
isSelectMode: boolean;
selectedBooks: string[];
itemSelected: boolean;
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
@@ -91,7 +93,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
item,
coverFit,
isSelectMode,
selectedBooks,
itemSelected,
transferProgress,
setLoading,
toggleSelection,
@@ -108,11 +110,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
const showBookDetailsModal = async (book: Book) => {
const showBookDetailsModal = useCallback(async (book: Book) => {
if (await makeBookAvailable(book)) {
handleShowDetailsBook(book);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const makeBookAvailable = async (book: Book) => {
if (book.uploadedAt && !book.downloadedAt) {
@@ -120,7 +123,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (!book.downloadedAt || !book.coverDownloadedAt) {
book.downloadedAt = Date.now();
book.coverDownloadedAt = Date.now();
updateBook(envConfig, book);
await updateBook(envConfig, book);
}
return true;
}
@@ -128,7 +131,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const loadingTimeout = setTimeout(() => setLoading(true), 200);
try {
available = await handleBookDownload(book);
updateBook(envConfig, book);
await updateBook(envConfig, book);
} finally {
if (loadingTimeout) clearTimeout(loadingTimeout);
setLoading(false);
@@ -138,28 +141,41 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
return true;
};
const handleBookClick = async (book: Book) => {
if (isSelectMode) {
toggleSelection(book.hash);
} else {
if (!(await makeBookAvailable(book))) return;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, [book.hash]);
const handleBookClick = useCallback(
async (book: Book) => {
if (isSelectMode) {
toggleSelection(book.hash);
} else {
navigateToReader(router, [book.hash]);
const available = await makeBookAvailable(book);
if (!available) return;
if (appService?.hasWindow && settings.openBookInNewWindow) {
showReaderWindow(appService, [book.hash]);
} else {
setTimeout(() => {
navigateToReader(router, [book.hash]);
}, 0);
}
}
}
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isSelectMode, settings.openBookInNewWindow, appService],
);
const handleGroupClick = (group: BooksGroup) => {
if (isSelectMode) {
toggleSelection(group.id);
} else {
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
navigateToLibrary(router, `${params.toString()}`);
}
};
const handleGroupClick = useCallback(
(group: BooksGroup) => {
if (isSelectMode) {
toggleSelection(group.id);
} else {
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
setTimeout(() => {
navigateToLibrary(router, `${params.toString()}`);
}, 0);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isSelectMode, searchParams],
);
const bookContextMenuHandler = async (book: Book) => {
if (!appService?.hasContextMenu) return;
@@ -167,7 +183,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
const selectBookMenuItem = await MenuItem.new({
text: selectedBooks.includes(book.hash) ? _('Deselect Book') : _('Select Book'),
text: itemSelected ? _('Deselect Book') : _('Select Book'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(book.hash);
@@ -221,7 +237,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const groupContextMenuHandler = async (group: BooksGroup) => {
if (!appService?.hasContextMenu) return;
const selectGroupMenuItem = await MenuItem.new({
text: selectedBooks.includes(group.id) ? _('Deselect Group') : _('Select Group'),
text: itemSelected ? _('Deselect Group') : _('Select Group'),
action: async () => {
if (!isSelectMode) handleSetSelectMode(true);
toggleSelection(group.id);
@@ -241,8 +257,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
menu.popup();
};
const { pressing, handlers } = useLongPress({
onLongPress: async () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSelectItem = useCallback(
throttle(() => {
if (!isSelectMode) {
handleSetSelectMode(true);
}
@@ -251,22 +268,56 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
} else {
toggleSelection((item as BooksGroup).id);
}
},
onTap: () => {
}, 100),
[isSelectMode],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleOpenItem = useCallback(
throttle(() => {
if (isSelectMode) {
handleSelectItem();
return;
}
if ('format' in item) {
handleBookClick(item as Book);
} else {
handleGroupClick(item as BooksGroup);
}
},
onContextMenu: () => {
}, 100),
[handleSelectItem, handleBookClick, handleGroupClick],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleContextMenu = useCallback(
throttle(() => {
if ('format' in item) {
bookContextMenuHandler(item as Book);
} else {
groupContextMenuHandler(item as BooksGroup);
}
}, 100),
[itemSelected],
);
const { pressing, handlers } = useLongPress(
{
onLongPress: () => {
handleSelectItem();
},
onTap: () => {
handleOpenItem();
},
onContextMenu: () => {
if (appService?.hasContextMenu) {
handleContextMenu();
} else if (appService?.isAndroidApp) {
handleSelectItem();
}
},
},
});
[isSelectMode, handleSelectItem, handleOpenItem, handleContextMenu],
);
return (
<div className={clsx(mode === 'list' && 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
@@ -275,6 +326,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
'group',
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
appService?.isMobileApp && 'no-context-menu',
pressing ? (mode === 'grid' ? 'scale-95' : 'scale-98') : 'scale-100',
)}
style={{
@@ -289,14 +341,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
book={item}
coverFit={coverFit}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
bookSelected={itemSelected}
transferProgress={transferProgress}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
<GroupItem group={item} isSelectMode={isSelectMode} groupSelected={itemSelected} />
)}
</div>
</div>
@@ -8,10 +8,10 @@ import BookCover from '@/components/BookCover';
interface GroupItemProps {
group: BooksGroup;
isSelectMode: boolean;
selectedBooks: string[];
groupSelected: boolean;
}
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBooks }) => {
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, groupSelected }) => {
const { appService } = useEnv();
const iconSize15 = useResponsiveSize(15);
@@ -30,12 +30,12 @@ const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBook
</div>
))}
</div>
{selectedBooks.includes(group.id) && (
{groupSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(group.id) ? (
{groupSelected ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
@@ -33,7 +33,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { user } = useAuth();
const { userPlan, quotas } = useQuotaStats();
const { userPlan, quotas } = useQuotaStats(true);
const { themeMode, setThemeMode } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
@@ -192,7 +192,9 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
}
>
<ul>
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
<div onClick={handleUserProfile} className='cursor-pointer'>
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
</div>
<MenuItem label={_('Account')} noIcon onClick={handleUserProfile} />
</ul>
</MenuItem>
@@ -72,6 +72,7 @@ export const useBooksSync = ({ onSyncStart, onSyncEnd }: UseBooksSyncProps) => {
if (matchingBook) {
if (!matchingBook.deletedAt && matchingBook.uploadedAt && !oldBook.coverDownloadedAt) {
await appService?.downloadBook(oldBook, true);
oldBook.coverImageUrl = await appService?.generateCoverImageUrl(oldBook);
}
const mergedBook =
matchingBook.updatedAt > oldBook.updatedAt
+70 -63
View File
@@ -436,7 +436,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
};
const selectFilesTauri = async () => {
const exts = appService?.isMobileApp ? [] : SUPPORTED_FILE_EXTS;
const exts = appService?.isIOSApp ? [] : SUPPORTED_FILE_EXTS;
const files = (await appService?.selectFiles(_('Select Books'), exts)) || [];
if (appService?.isIOSApp) {
return files.filter((file) => {
@@ -444,7 +444,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
return SUPPORTED_FILE_EXTS.includes(fileExt);
});
}
// Cannot filter out files on Android since some content providers may not return the file name
return files;
};
@@ -471,70 +470,78 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}));
}, 500);
const handleBookUpload = async (book: Book) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book uploaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return false;
const handleBookUpload = useCallback(
async (book: Book) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book uploaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
setSettings(settings);
navigateToLogin(router);
return false;
} else if (err.message.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
return false;
}
}
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to upload book: {{title}}', {
title: book.title,
}),
});
return false;
}
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to upload book: {{title}}', {
title: book.title,
}),
});
return false;
}
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
const handleBookDownload = async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
};
const handleBookDownload = useCallback(
async (book: Book, redownload = false) => {
try {
await appService?.downloadBook(book, false, redownload, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
await updateBook(envConfig, book);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Book downloaded: {{title}}', {
title: book.title,
}),
});
return true;
} catch {
eventDispatcher.dispatch('toast', {
message: _('Failed to download book: {{title}}', {
title: book.title,
}),
type: 'error',
});
return false;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
const handleBookDelete = async (book: Book) => {
try {
@@ -153,11 +153,12 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
showFooter={showFooter}
borderColor={viewSettings.borderColor}
horizontalGap={horizontalGapPercent}
contentInsets={contentInsets}
insets={viewInsets}
/>
)}
{showHeader && (
<SectionInfo
bookKey={bookKey}
section={sectionLabel}
showDoubleBorder={viewSettings.vertical && viewSettings.doubleBorder}
isScrolled={viewSettings.scrolled}
@@ -5,7 +5,7 @@ interface DoubleBorderProps {
horizontalGap: number;
showHeader: boolean;
showFooter: boolean;
contentInsets: Insets;
insets: Insets;
}
const paddingPx = 10;
@@ -14,7 +14,7 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderColor,
showHeader,
showFooter,
contentInsets,
insets,
}) => {
return (
<div>
@@ -23,10 +23,10 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
className={'borderframe pointer-events-none absolute'}
style={{
border: `4px solid ${borderColor}`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px + ${paddingPx * 2}px)`,
top: `calc(${contentInsets.top}px - ${paddingPx}px)`,
left: `calc(${contentInsets.left}px - ${paddingPx}px)`,
right: `calc(${contentInsets.right}px - ${paddingPx}px)`,
height: `calc(100% - ${insets.top + insets.bottom}px + ${paddingPx * 2}px)`,
top: `calc(${insets.top}px - ${paddingPx}px)`,
left: `calc(${insets.left}px - ${paddingPx}px)`,
right: `calc(${insets.right}px - ${paddingPx}px)`,
}}
></div>
{/* inner frame */}
@@ -34,10 +34,10 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
className={'borderframe pointer-events-none absolute'}
style={{
border: `1px solid ${borderColor}`,
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(${contentInsets.left + (showFooter ? 32 : 0)}px`,
right: `calc(${contentInsets.right + (showHeader ? 32 : 0)}px`,
height: `calc(100% - ${insets.top + insets.bottom}px)`,
top: `${insets.top}px`,
left: `calc(${insets.left + (showFooter ? 32 : 0)}px`,
right: `calc(${insets.right + (showHeader ? 32 : 0)}px`,
}}
/>
{/* footer */}
@@ -49,9 +49,9 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderBottom: `1px solid ${borderColor}`,
borderLeft: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(${contentInsets.left}px)`,
height: `calc(100% - ${insets.top + insets.bottom}px)`,
top: `${insets.top}px`,
left: `calc(${insets.left}px)`,
}}
/>
)}
@@ -64,9 +64,9 @@ const DoubleBorder: React.FC<DoubleBorderProps> = ({
borderBottom: `1px solid ${borderColor}`,
borderRight: `1px solid ${borderColor}`,
width: '32px',
height: `calc(100% - ${contentInsets.top + contentInsets.bottom}px)`,
top: `${contentInsets.top}px`,
left: `calc(100% - ${contentInsets.right}px - 32px)`,
height: `calc(100% - ${insets.top + insets.bottom}px)`,
top: `${insets.top}px`,
left: `calc(100% - ${insets.right}px - 32px)`,
}}
/>
)}
@@ -14,6 +14,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import {
applyFixedlayoutStyles,
applyImageStyle,
applyTranslationStyle,
getStyles,
@@ -134,6 +135,10 @@ const FoliateViewer: React.FC<{
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
if (bookDoc.rendition?.layout === 'pre-paginated') {
applyFixedlayoutStyles(detail.doc, viewSettings);
}
applyImageStyle(detail.doc);
// Inline scripts in tauri platforms are not executed by default
@@ -312,9 +317,13 @@ const FoliateViewer: React.FC<{
if (viewRef.current && viewRef.current.renderer) {
const viewSettings = getViewSettings(bookKey)!;
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
if (bookDoc.rendition?.layout === 'pre-paginated') {
const docs = viewRef.current.renderer.getContents();
docs.forEach(({ doc }) => applyFixedlayoutStyles(doc, viewSettings));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeCode, isDarkMode]);
}, [themeCode, isDarkMode, viewSettings?.overrideColor, viewSettings?.invertImgColorInDark]);
useEffect(() => {
if (viewRef.current && viewRef.current.renderer && viewSettings) {
@@ -40,7 +40,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const {
isTrafficLightVisible,
trafficLightInFullscreen,
setTrafficLightVisibility,
initializeTrafficLightStore,
initializeTrafficLightListeners,
@@ -70,12 +70,19 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
useEffect(() => {
if (!appService?.hasTrafficLight) return;
if (isSideBarVisible) return;
setTrafficLightVisibility(isSideBarVisible);
if (hoveredBookKey === bookKey && isTopLeft) {
setTrafficLightVisibility(true, { x: 10, y: 20 });
} else if (!hoveredBookKey) {
setTrafficLightVisibility(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService, isSideBarVisible]);
}, [appService, isSideBarVisible, hoveredBookKey]);
const isVisible = hoveredBookKey === bookKey || isDropdownOpen;
const isHeaderVisible = hoveredBookKey === bookKey || isDropdownOpen;
const trafficLightInHeader =
appService?.hasTrafficLight && !trafficLightInFullscreen && !isSideBarVisible && isTopLeft;
return (
<div
@@ -93,7 +100,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
className={clsx(
'bg-base-100 absolute left-0 right-0 top-0 z-10',
appService?.hasRoundedWindow && 'rounded-window-top-right',
isVisible ? 'visible' : 'hidden',
isHeaderVisible ? 'visible' : 'hidden',
)}
style={{
height: systemUIVisible ? `${Math.max(gridInsets.top, statusBarHeight)}px` : '0px',
@@ -104,11 +111,11 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
className={clsx(
`header-bar bg-base-100 absolute top-0 z-10 flex h-11 w-full items-center pr-4`,
`shadow-xs transition-[opacity,margin-top] duration-300`,
isTrafficLightVisible && isTopLeft && !isSideBarVisible ? 'pl-16' : 'pl-4',
trafficLightInHeader ? 'pl-20' : 'pl-4',
appService?.hasRoundedWindow && 'rounded-window-top-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-top-left',
isHoveredAnim && 'hover-bar-anim',
isVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
isHeaderVisible ? 'pointer-events-auto visible' : 'pointer-events-none opacity-0',
isDropdownOpen && 'header-bar-pinned',
)}
style={{
@@ -148,10 +155,14 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
className='window-buttons flex h-full items-center'
headerRef={headerRef}
showMinimize={
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
bookKeys.length == 1 &&
!appService?.hasTrafficLight &&
appService?.appPlatform !== 'web'
}
showMaximize={
bookKeys.length == 1 && !isTrafficLightVisible && appService?.appPlatform !== 'web'
bookKeys.length == 1 &&
!appService?.hasTrafficLight &&
appService?.appPlatform !== 'web'
}
onClose={() => {
setHoveredBookKey(null);
@@ -3,8 +3,10 @@ import React from 'react';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
interface SectionInfoProps {
bookKey: string;
section?: string;
showDoubleBorder: boolean;
isScrolled: boolean;
@@ -15,6 +17,7 @@ interface SectionInfoProps {
}
const SectionInfo: React.FC<SectionInfoProps> = ({
bookKey,
section,
showDoubleBorder,
isScrolled,
@@ -24,6 +27,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
gridInsets,
}) => {
const { appService } = useEnv();
const { hoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const topInset = Math.max(
gridInsets.top,
@@ -68,6 +72,7 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
className={clsx(
'text-neutral-content text-center font-sans text-xs font-light',
isVertical ? '' : 'line-clamp-1',
!isVertical && hoveredBookKey == bookKey && 'hidden',
)}
>
{section || ''}
@@ -216,7 +216,7 @@ const Notebook: React.FC = ({}) => {
}
`}</style>
<div
className='drag-bar absolute left-0 top-0 -m-3 h-full w-0.5 cursor-col-resize p-3'
className='drag-bar absolute left-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2'
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
/>
@@ -259,7 +259,7 @@ const SideBar: React.FC<{
)}
<div
className={clsx(
'drag-bar absolute -right-1 top-0 -m-1 h-full w-0.5 cursor-col-resize p-1',
'drag-bar absolute right-0 top-0 -m-2 h-full w-0.5 cursor-col-resize bg-transparent p-2',
isMobile && 'hidden',
)}
onMouseDown={handleHorizontalDragStart}
@@ -54,7 +54,7 @@ export function useTextTranslation(
if (!view || !enabled.current) return;
const observer = createTranslationObserver();
observerRef.current = observer;
const nodes = walkTextNodes(view);
const nodes = walkTextNodes(view, ['pre', 'code', 'math']);
console.log('Observing text nodes for translation:', nodes.length);
allTextNodes.current = nodes;
nodes.forEach((el) => observer.observe(el));
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { UserPlan } from '@/types/user';
@@ -48,16 +49,19 @@ interface AccountActionsProps {
userPlan: UserPlan;
onLogout: () => void;
onConfirmDelete: () => void;
onManageSubscription: () => void;
onRestorePurchase?: () => void;
onManageSubscription?: () => void;
}
const AccountActions: React.FC<AccountActionsProps> = ({
userPlan,
onLogout,
onConfirmDelete,
onRestorePurchase,
onManageSubscription,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const handleDeleteRequest = () => {
@@ -79,13 +83,22 @@ const AccountActions: React.FC<AccountActionsProps> = ({
}}
/>
<div className='flex flex-col gap-4 md:flex-row'>
{userPlan !== 'free' && (
{appService?.isIOSApp ? (
<button
onClick={onManageSubscription}
onClick={onRestorePurchase}
className='w-full rounded-lg bg-blue-100 px-6 py-3 font-medium text-blue-600 transition-colors hover:bg-blue-200 md:w-auto'
>
{_('Manage Subscription')}
{_('Restore Purchase')}
</button>
) : (
userPlan !== 'free' && (
<button
onClick={onManageSubscription}
className='w-full rounded-lg bg-blue-100 px-6 py-3 font-medium text-blue-600 transition-colors hover:bg-blue-200 md:w-auto'
>
{_('Manage Subscription')}
</button>
)
)}
<button
onClick={onLogout}
@@ -1,5 +1,6 @@
import { IoCheckmark } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { getLocale } from '@/utils/misc';
import { PlanDetails } from '../utils/plan';
import PlanActionButton from './PlanActionButton';
@@ -25,6 +26,10 @@ const PlanCard: React.FC<PlanCardProps> = ({
onSelectPlan,
}) => {
const _ = useTranslation();
const { price, currency } = plan;
const formattedPrice = new Intl.NumberFormat(getLocale(), { style: 'currency', currency }).format(
price / 100,
);
return (
<div
@@ -38,7 +43,7 @@ const PlanCard: React.FC<PlanCardProps> = ({
<div className='mb-6 text-center'>
<h4 className='mb-2 text-2xl font-bold'>{_(plan.name)}</h4>
<div className='text-3xl font-bold'>
{`$${(plan.price / 100).toFixed(2)}`}
{formattedPrice}
<span className='text-lg font-normal'>/{_(plan.interval)}</span>
</div>
</div>
@@ -107,11 +107,9 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
}, [currentPlanIndex, handleScroll]);
useEffect(() => {
console.log('Current plan index:', currentPlanIndex);
if (plansScrollRef.current) {
const planWidth = plansScrollRef.current.scrollWidth / allPlans.length;
const scrollPosition = currentPlanIndex * planWidth;
console.log('Scroll position:', scrollPosition);
plansScrollRef.current.scrollTo({
left: scrollPosition,
behavior: 'smooth',
@@ -146,7 +144,7 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
<PlanCard
key={plan.plan}
plan={plan}
comingSoon={['appstore', 'playstore'].includes(appService?.distChannel || '')}
comingSoon={['playstore'].includes(appService?.distChannel || '')}
isUserPlan={plan.plan === userPlan}
upgradable={index > userPlanIndex}
index={index}
+132 -15
View File
@@ -19,8 +19,10 @@ import { getStripe } from '@/libs/stripe/client';
import { getAPIBaseUrl, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { openUrl } from '@tauri-apps/plugin-opener';
import { getAccessToken } from '@/utils/access';
import { IAPService, IAPProduct } from '@/utils/iap';
import { getPlanDetails } from './utils/plan';
import { Toast } from '@/components/Toast';
import LegalLinks from '@/components/LegalLinks';
import Spinner from '@/components/Spinner';
import ProfileHeader from './components/Header';
import UserInfo from './components/UserInfo';
@@ -32,14 +34,16 @@ import Checkout from './components/Checkout';
const WEB_STRIPE_PLANS_URL = `${getAPIBaseUrl()}/stripe/plans`;
const WEB_STRIPE_CHECKOUT_URL = `${getAPIBaseUrl()}/stripe/checkout`;
const WEB_STRIPE_PORTAL_URL = `${getAPIBaseUrl()}/stripe/portal`;
const SUBSCRIPTION_SUCCESS_PATH = '/user/subscription/success';
export type AvailablePlan = {
plan: UserPlan;
price_id: string;
price: number;
price: number; // in cents
currency: string;
interval: string;
product: Stripe.Product;
productName: string;
product?: Stripe.Product;
};
type CheckoutState = {
@@ -100,7 +104,7 @@ const ProfilePage = () => {
}
};
const handleSubscribe = async (priceId?: string) => {
const handleStripeSubscribe = async (priceId?: string) => {
const token = await getAccessToken();
const stripe = await getStripe();
if (!stripe) {
@@ -109,18 +113,30 @@ const ProfilePage = () => {
}
setLoading(true);
const isEmbeddedCheckout = isTauriAppPlatform();
const { sessionId, clientSecret, url } = await fetch(WEB_STRIPE_CHECKOUT_URL, {
const response = await fetch(WEB_STRIPE_CHECKOUT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ priceId, embedded: isEmbeddedCheckout }),
}).then((res) => res.json());
});
setLoading(false);
if (!response.ok) {
console.error('Failed to create Stripe checkout session');
posthog.capture('checkout_error', {
error: 'Failed to create Stripe checkout session',
});
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to create checkout session'),
});
return;
}
const { sessionId, clientSecret, url } = await response.json();
const selectedPlan = availablePlans.find((plan) => plan.price_id === priceId)!;
const planName = selectedPlan.product.name;
const planName = selectedPlan.product?.name || selectedPlan.productName;
if (isEmbeddedCheckout && sessionId && clientSecret) {
setShowEmbeddedCheckout(true);
setCheckoutState({
@@ -153,11 +169,70 @@ const ProfilePage = () => {
const handleCheckoutSuccess = useCallback(
(sessionId: string) => {
setShowEmbeddedCheckout(false);
router.push(`/user/subscription/success?session_id=${sessionId}`);
const params = new URLSearchParams({
payment: 'stripe',
session_id: sessionId,
});
router.push(`${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`);
},
[router],
);
const handleIAPSubscribe = async (productId?: string) => {
if (!productId) return;
setLoading(true);
const iapService = new IAPService();
try {
const purchase = await iapService.purchaseProduct(productId);
if (purchase) {
const params = new URLSearchParams({
payment: 'iap',
platform: purchase.platform,
transaction_id: purchase.transactionId,
original_transaction_id: purchase.originalTransactionId,
});
router.push(`${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`);
}
} catch (error) {
console.error('IAP purchase error:', error);
}
setLoading(false);
};
const handleIAPRestorePurchase = async () => {
setLoading(true);
const iapService = new IAPService();
try {
const purchases = await iapService.restorePurchases();
if (purchases.length > 0) {
purchases.sort(
(a, b) => new Date(a.purchaseDate).getTime() - new Date(b.purchaseDate).getTime(),
);
const purchase = purchases[0]!;
const params = new URLSearchParams({
payment: 'iap',
platform: purchase.platform,
transaction_id: purchase.transactionId,
original_transaction_id: purchase.originalTransactionId,
});
router.push(`${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`);
} else {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No purchases found to restore.'),
});
}
} catch (error) {
console.error('Failed to restore purchases:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to restore purchases.'),
});
}
setLoading(false);
};
const handleManageSubscription = async () => {
setLoading(true);
const token = await getAccessToken();
@@ -175,8 +250,8 @@ const ProfilePage = () => {
if (error) {
console.error('Error creating portal session:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to manage subscription. Please try again later.'),
type: 'info',
message: _('Failed to manage subscription.'),
});
return;
}
@@ -189,16 +264,56 @@ const ProfilePage = () => {
};
useEffect(() => {
fetch(WEB_STRIPE_PLANS_URL)
.then((res) => res.json())
.then((data) => setAvailablePlans(data || []));
}, []);
if (!appService) return;
if (appService?.isIOSApp) {
const iapService = new IAPService();
iapService
.initialize()
.then(() =>
iapService.fetchProducts([
'com.bilingify.readest.monthly.plus',
'com.bilingify.readest.monthly.pro',
]),
)
.then((products: IAPProduct[]) => {
const availablePlans: AvailablePlan[] = products.map((product) => ({
plan: product.id.includes('plus')
? 'plus'
: product.id.includes('pro')
? 'pro'
: 'free',
price_id: product.id,
price: product.priceAmountMicros / 10000,
currency: product.priceCurrencyCode || 'USD',
interval: 'month',
productName: product.title,
}));
setAvailablePlans(availablePlans);
})
.catch((error) => {
console.error('Failed to fetch IAP products:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to load subscription plans.'),
});
});
} else {
fetch(WEB_STRIPE_PLANS_URL)
.then((res) => res.json())
.then((data) => {
const availablePlans = data && data instanceof Array ? data : [];
setAvailablePlans(availablePlans);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
if (!mounted) {
return null;
}
if (!user || !token) {
if (!user || !token || !appService) {
return (
<div className='mx-auto max-w-4xl px-4 py-8'>
<div className='overflow-hidden rounded-lg shadow-md'>
@@ -265,7 +380,7 @@ const ProfilePage = () => {
<PlansComparison
availablePlans={availablePlans}
userPlan={userPlan}
onSubscribe={handleSubscribe}
onSubscribe={appService.isIOSApp ? handleIAPSubscribe : handleStripeSubscribe}
/>
</div>
@@ -274,9 +389,11 @@ const ProfilePage = () => {
userPlan={userPlan}
onLogout={handleLogout}
onConfirmDelete={handleConfirmDelete}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
/>
</div>
<LegalLinks />
</div>
</div>
)}
@@ -2,12 +2,13 @@
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useTranslation } from '@/hooks/useTranslation';
import { getAPIBaseUrl } from '@/services/environment';
import { getAPIBaseUrl, getNodeAPIBaseUrl } from '@/services/environment';
import { getAccessToken } from '@/utils/access';
import { supabase } from '@/utils/supabase';
import Spinner from '@/components/Spinner';
const WEB_STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
const STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
const APPLE_IAP_VERIFY_URL = `${getNodeAPIBaseUrl()}/apple/iap-verify`;
interface SessionStatus {
status: 'loading' | 'complete' | 'failed' | 'processing';
@@ -27,15 +28,19 @@ const SuccessPageWithSearchParams = () => {
const [retryCount, setRetryCount] = useState(0);
const searchParams = useSearchParams();
const router = useRouter();
const payment = searchParams?.get('payment');
const platform = searchParams?.get('platform');
const sessionId = searchParams?.get('session_id');
const transactionId = searchParams?.get('transaction_id');
const originalTransactionId = searchParams?.get('original_transaction_id');
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
async function fetchSessionStatus() {
const updateStripeSessionStatus = async () => {
try {
const token = await getAccessToken();
const response = await fetch(WEB_STRIPE_CHECK_URL, {
const response = await fetch(STRIPE_CHECK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -74,12 +79,75 @@ const SuccessPageWithSearchParams = () => {
console.error('Failed to fetch session status:', error);
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
}
}
};
const updateIAPSessionStatus = async (
platform: string,
transactionId: string,
originalTransactionId: string,
) => {
if (!transactionId || !originalTransactionId) {
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
return;
}
if (platform === 'ios') {
try {
const token = await getAccessToken();
const response = await fetch(APPLE_IAP_VERIFY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
transactionId,
originalTransactionId,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const { purchase, error } = await response.json();
if (error) {
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
console.error('IAP verification error:', error);
return;
}
setSessionStatus({
status: purchase.status === 'active' ? 'complete' : 'failed',
customerEmail: purchase.customerEmail || '',
subscriptionId: purchase.subscriptionId,
planName: purchase.planName,
});
try {
await supabase.auth.refreshSession();
} catch {}
} catch (error) {
console.error('Failed to verify IAP transaction:', error);
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
}
}
};
const updateSessionStatus = async () => {
if (payment === 'stripe' && sessionId) {
await updateStripeSessionStatus();
} else if (payment === 'iap' && platform && transactionId && originalTransactionId) {
await updateIAPSessionStatus(platform, transactionId, originalTransactionId);
} else {
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
}
};
const handleRetry = () => {
setRetryCount(0);
setSessionStatus((prev) => ({ ...prev, status: 'loading' }));
fetchSessionStatus();
updateSessionStatus();
};
const handleGoToLibrary = () => {
@@ -91,20 +159,15 @@ const SuccessPageWithSearchParams = () => {
};
useEffect(() => {
if (!sessionId) {
handleGoToProfile();
return;
}
fetchSessionStatus();
updateSessionStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, router]);
}, [sessionId, originalTransactionId, router]);
useEffect(() => {
if (sessionStatus.status === 'processing' && retryCount < 3) {
const timer = setTimeout(() => {
setRetryCount((prev) => prev + 1);
fetchSessionStatus();
updateSessionStatus();
}, 2000);
return () => clearTimeout(timer);
@@ -166,7 +229,7 @@ const SuccessPageWithSearchParams = () => {
if (sessionStatus.status === 'failed') {
return (
<div className='flex min-h-screen items-center justify-center bg-gray-50'>
<div className='max-w-md text-center'>
<div className='mx-auto max-w-2xl px-4 text-center'>
<div className='mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100'>
<svg
className='h-6 w-6 text-red-600'
@@ -244,7 +307,7 @@ const SuccessPageWithSearchParams = () => {
{sessionStatus.planName && (
<div className='flex justify-between'>
<span className='font-medium'>{_('Plan:')}</span>
<span>{sessionStatus.planName}</span>
<span>{_(sessionStatus.planName)}</span>
</div>
)}
{sessionStatus.amount && sessionStatus.currency && (
+18 -10
View File
@@ -13,6 +13,7 @@ export type PlanDetails = {
color: string;
hintColor: string;
price: number;
currency: string;
price_id?: string;
interval: string;
features: FeatureType[];
@@ -22,8 +23,12 @@ export type PlanDetails = {
export const getPlanDetails = (
userPlan: UserPlan,
availablePlans: AvailablePlan[],
interval: 'month' | 'year' = 'month',
): PlanDetails => {
const availablePlan = availablePlans.find((plan) => plan.plan === userPlan);
const availablePlan = availablePlans.find(
(plan) => plan.plan === userPlan && (!plan.interval || plan.interval === interval),
);
const currency = availablePlans.length > 0 ? availablePlans[0]!.currency : 'USD';
switch (userPlan) {
case 'free':
return {
@@ -32,8 +37,9 @@ export const getPlanDetails = (
color: 'bg-gray-200 text-gray-800',
hintColor: 'text-gray-800/75',
price: 0,
currency,
price_id: availablePlan?.price_id,
interval: _('month'),
interval: interval === 'month' ? _('month') : _('year'),
features: [
{
label: _('Cross-Platform Sync'),
@@ -78,8 +84,9 @@ export const getPlanDetails = (
color: 'bg-blue-200 text-blue-800',
hintColor: 'text-blue-800/75',
price: availablePlan?.price || 499,
currency,
price_id: availablePlan?.price_id,
interval: _('month'),
interval: interval === 'month' ? _('month') : _('year'),
features: [
{
label: _('Includes All Free Plan Benefits'),
@@ -103,9 +110,9 @@ export const getPlanDetails = (
),
},
{
label: _('2 GB Cloud Sync Storage'),
label: _('Cloud Sync Storage'),
description: _(
'Securely store and access your entire reading collection with up to 2 GB of cloud storage.',
'Securely store and access your entire reading collection with up to 5 GB of cloud storage.',
),
},
{
@@ -116,7 +123,7 @@ export const getPlanDetails = (
},
],
limits: {
[_('Cloud Sync Storage')]: '2 GB',
[_('Cloud Sync Storage')]: '5 GB',
[_('AI Translations (per day)')]: '100K',
},
};
@@ -127,8 +134,9 @@ export const getPlanDetails = (
color: 'bg-purple-200 text-purple-800',
hintColor: 'text-purple-800/75',
price: availablePlan?.price || 999,
currency,
price_id: availablePlan?.price_id,
interval: _('month'),
interval: interval === 'month' ? _('month') : _('year'),
features: [
{
label: _('Includes All Plus Plan Benefits'),
@@ -152,14 +160,14 @@ export const getPlanDetails = (
),
},
{
label: _('10 GB Cloud Sync Storage'),
label: _('Cloud Sync Storage'),
description: _(
'Securely store and access your entire reading collection with up to 10 GB of cloud storage.',
'Securely store and access your entire reading collection with up to 20 GB of cloud storage.',
),
},
],
limits: {
[_('Cloud Sync Storage')]: '10 GB',
[_('Cloud Sync Storage')]: '20 GB',
[_('AI Translations (per day)')]: '500K',
},
};
@@ -5,6 +5,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
import { parseWebViewVersion } from '@/utils/ua';
import { getAppVersion } from '@/utils/version';
import LegalLinks from './LegalLinks';
import Dialog from './Dialog';
import Link from './Link';
@@ -122,6 +123,7 @@ export const AboutWindow = () => {
<p className='text-neutral-content text-sm'>
© {new Date().getFullYear()} Bilingify LLC. All rights reserved.
</p>
<p className='text-neutral-content mt-2 text-xs'>
This software is licensed under the{' '}
<Link
@@ -140,6 +142,8 @@ export const AboutWindow = () => {
</Link>
.
</p>
<LegalLinks />
</div>
</div>
</Dialog>
+124 -88
View File
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import Image from 'next/image';
import { useEffect, useRef } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { Book } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
@@ -11,108 +11,144 @@ interface BookCoverProps {
coverFit?: LibraryCoverFitType;
className?: string;
imageClassName?: string;
showSpine?: boolean;
isPreview?: boolean;
}
const BookCover: React.FC<BookCoverProps> = ({
book,
mode = 'grid',
coverFit = 'crop',
className,
imageClassName,
isPreview,
}) => {
const coverRef = useRef<HTMLDivElement>(null);
const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
({
book,
mode = 'grid',
coverFit = 'crop',
showSpine = false,
className,
imageClassName,
isPreview,
}) => {
const coverRef = useRef<HTMLDivElement>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const toggleImageVisibility = (showImage: boolean) => {
if (coverRef.current) {
const coverImage = coverRef.current.querySelector('.cover-image');
const fallbackCover = coverRef.current.querySelector('.fallback-cover');
if (coverImage) {
coverImage.classList.toggle('invisible', !showImage);
const shouldShowSpine = showSpine && imageLoaded && !imageError;
const toggleImageVisibility = (showImage: boolean) => {
if (coverRef.current) {
const coverImage = coverRef.current.querySelector('.cover-image');
const fallbackCover = coverRef.current.querySelector('.fallback-cover');
if (coverImage) {
coverImage.classList.toggle('invisible', !showImage);
}
if (fallbackCover) {
fallbackCover.classList.toggle('invisible', showImage);
}
}
if (fallbackCover) {
fallbackCover.classList.toggle('invisible', showImage);
}
}
};
};
const handleImageError = () => {
toggleImageVisibility(false);
};
const handleImageLoad = () => {
setImageLoaded(true);
setImageError(false);
toggleImageVisibility(true);
};
useEffect(() => {
toggleImageVisibility(true);
}, [book.metadata?.coverImageUrl, book.coverImageUrl]);
const handleImageError = () => {
setImageLoaded(false);
setImageError(true);
toggleImageVisibility(false);
};
return (
<div
ref={coverRef}
className={clsx(
'book-cover-container no-context-menu relative flex h-full w-full',
className,
)}
>
{coverFit === 'crop' ? (
<Image
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
fill={true}
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
onError={handleImageError}
/>
) : (
<div
className={clsx(
'flex h-full w-full justify-center',
mode === 'grid' ? 'items-end' : 'items-center',
)}
>
useEffect(() => {
toggleImageVisibility(true);
}, [book.metadata?.coverImageUrl, book.coverImageUrl]);
return (
<div
ref={coverRef}
className={clsx(
'book-cover-container relative flex w-full',
coverFit === 'crop' && 'h-full',
className,
)}
>
{coverFit === 'crop' ? (
<Image
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
width={0}
height={0}
sizes='100vw'
className={clsx(
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
imageClassName,
)}
fill={true}
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
<div
className={clsx(
'fallback-cover invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
isPreview ? 'bg-base-200/50' : 'bg-base-100',
) : (
<div
className={clsx(
'flex w-full justify-center',
mode === 'grid' ? 'h-full items-end' : 'items-center',
)}
>
<Image
src={book.metadata?.coverImageUrl || book.coverImageUrl!}
alt={book.title}
width={0}
height={0}
sizes='100vw'
className={clsx(
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
imageClassName,
)}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : mode === 'grid' ? 'line-clamp-3' : 'line-clamp-2',
isPreview ? 'text-[0.5em]' : mode === 'grid' ? 'text-lg' : 'text-sm',
)}
>
{formatTitle(book.title)}
</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
{formatAuthors(book.author)}
</span>
<div
className={`book-spine absolute inset-0 ${shouldShowSpine ? 'visible' : 'invisible'}`}
/>
<div
className={clsx(
'fallback-cover invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
isPreview ? 'bg-base-200/50' : 'bg-base-100',
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : mode === 'grid' ? 'line-clamp-3' : 'line-clamp-2',
isPreview ? 'text-[0.5em]' : mode === 'grid' ? 'text-lg' : 'text-sm',
)}
>
{formatTitle(book.title)}
</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
{formatAuthors(book.author)}
</span>
</div>
</div>
</div>
</div>
);
};
);
},
(prevProps, nextProps) => {
return (
prevProps.book.coverImageUrl === nextProps.book.coverImageUrl &&
prevProps.book.metadata?.coverImageUrl === nextProps.book.metadata?.coverImageUrl &&
prevProps.mode === nextProps.mode &&
prevProps.coverFit === nextProps.coverFit &&
prevProps.isPreview === nextProps.isPreview &&
prevProps.showSpine === nextProps.showSpine &&
prevProps.className === nextProps.className &&
prevProps.imageClassName === nextProps.imageClassName
);
},
);
BookCover.displayName = 'BookCover';
export default BookCover;
@@ -0,0 +1,29 @@
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import Link from './Link';
const LegalLinks = () => {
const _ = useTranslation();
const { appService } = useEnv();
const termsUrl =
appService?.isIOSApp || appService?.isMacOSApp
? 'https://www.apple.com/legal/internet-services/itunes/dev/stdeula/'
: 'https://readest.com/terms-of-service';
return (
<div className='my-2 flex flex-wrap justify-center gap-4 text-xs'>
<Link href={termsUrl} className='text-blue-500 underline hover:text-blue-600'>
{_('Terms of Service')}
</Link>
<Link
href='https://readest.com/privacy-policy'
className='text-blue-500 underline hover:text-blue-600'
>
{_('Privacy Policy')}
</Link>
</div>
);
};
export default LegalLinks;
+1 -1
View File
@@ -50,7 +50,7 @@ const Quota: React.FC<QuotaProps> = ({ quotas, showProgress, className, labelCla
<div className='lg:tooltip lg:tooltip-right' data-tip={quota.tooltip}>
<span className='truncate'>{quota.name}</span>
</div>
<div className='text-right text-xs'>
<div className='text-right text-sm'>
{quota.used} / {quota.total} {quota.unit}
</div>
</div>
+7 -7
View File
@@ -12,9 +12,9 @@ export const Toast = () => {
const toastDismissTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastClassMap = {
info: 'toast-info toast-center toast-middle',
success: 'toast-success toast-top toast-end',
warning: 'toast-warning toast-top toast-end',
error: 'toast-error toast-top toast-end',
success: 'toast-success toast-top sm:toast-end toast-center',
warning: 'toast-warning toast-top sm:toast-end toast-center',
error: 'toast-error toast-top sm:toast-end toast-center',
};
const alertClassMap = {
info: 'alert-primary border-base-300',
@@ -53,7 +53,7 @@ export const Toast = () => {
'toast toast-center toast-middle z-50 w-auto max-w-screen-sm',
toastClassMap[toastType.current],
toastClassMap[toastType.current].includes('toast-top') &&
'pt-[calc(44px+env(safe-area-inset-top))]',
'top-[calc(44px+env(safe-area-inset-top))]',
)}
>
<div
@@ -64,11 +64,11 @@ export const Toast = () => {
>
<span
className={clsx(
'max-h-[50vh] min-w-32',
'overflow-y-auto text-center',
'max-h-[50vh] min-w-32 overflow-y-auto',
'text-center font-sans text-base font-normal sm:text-sm',
toastType.current === 'info'
? 'max-w-[80vw]'
: 'max-w-80 whitespace-normal break-words',
: 'min-w-[60vw] max-w-[80vw] whitespace-normal break-words sm:min-w-40 sm:max-w-80',
messageClass.current,
)}
>
@@ -114,7 +114,7 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
field: 'published',
label: _('Publication Date'),
value: metadata.published || '',
placeholder: 'YYYY or YYYY-MM-DD',
placeholder: _('YYYY or YYYY-MM-DD'),
},
{
field: 'language',
@@ -178,9 +178,12 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
let files;
if (isTauriAppPlatform()) {
files = (await selectImageFileTauri()) as string[];
if (files.length > 0) {
if (appService && files.length > 0) {
metadata.coverImageFile = files[0]!;
metadata.coverImageUrl = appService?.fs.getURL(files[0]!);
const tempName = 'cover-temp.png';
const cachePrefix = appService.fs.getPrefix('Cache');
await appService.fs.copyFile(files[0]!, tempName, 'Cache');
metadata.coverImageUrl = await appService.fs.getURL(`${cachePrefix}/${tempName}`);
setNewCoverImageUrl(metadata.coverImageUrl!);
}
} else {
@@ -196,7 +199,10 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
<div className='bg-base-100 relative w-full rounded-lg'>
<div className='mb-6 flex items-start gap-4'>
<div className='flex w-[30%] max-w-32 flex-col gap-2'>
<div className='aspect-[28/41] h-full shadow-md'>
<div
className='aspect-[28/41] h-full shadow-md'
onClick={!isCoverLocked ? handleSelectLocalImage : undefined}
>
<BookCover
mode='list'
book={{
@@ -222,7 +228,12 @@ const BookDetailEdit: React.FC<BookDetailEditProps> = ({
)}
title={_('Change cover image')}
>
<MdEdit className='h-5 w-5 sm:h-4 sm:w-4' />
<MdEdit
className={clsx(
'h-5 w-5 sm:h-4 sm:w-4',
isCoverLocked ? 'fill-base-content' : 'fill-gray-600',
)}
/>
<span className='hidden sm:inline'>{_('Replace')}</span>
</button>
@@ -100,7 +100,7 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<div className='overflow-hidden'>
<span className='font-bold'>{_('Published')}</span>
<p className='text-neutral-content text-sm'>
{formatDate(metadata.published) || _('Unknown')}
{formatDate(metadata.published, true) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
+1 -2
View File
@@ -14,8 +14,7 @@ interface CliArgument {
const parseWindowOpenWithFiles = () => {
const params = new URLSearchParams(window.location.search);
const filesParams = params.get('files') || '';
const files = filesParams ? filesParams.split(',').map(decodeURIComponent) : [];
const files = params.getAll('file');
return files.length > 0 ? files : window.OPEN_WITH_FILES;
};
+15 -10
View File
@@ -21,14 +21,17 @@ interface UseLongPressResult {
};
}
export const useLongPress = ({
onTap,
onLongPress,
onContextMenu,
onCancel,
threshold = 500,
moveThreshold = 10,
}: UseLongPressOptions): UseLongPressResult => {
export const useLongPress = (
{
onTap,
onLongPress,
onContextMenu,
onCancel,
threshold = 500,
moveThreshold = 10,
}: UseLongPressOptions,
deps: React.DependencyList,
): UseLongPressResult => {
const [pressing, setPressing] = useState(false);
const timerRef = useRef<NodeJS.Timeout>();
const startPosRef = useRef<{ x: number; y: number } | null>(null);
@@ -114,15 +117,17 @@ export const useLongPress = ({
e.stopPropagation();
onContextMenu();
}
reset();
},
[onContextMenu],
[onContextMenu, reset],
);
useEffect(() => {
return () => {
clearTimeout(timerRef.current);
};
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return {
pressing,
+7 -6
View File
@@ -4,7 +4,7 @@ import { QuotaType, UserPlan } from '@/types/user';
import { getStoragePlanData, getTranslationPlanData, getUserPlan } from '@/utils/access';
import { useTranslation } from './useTranslation';
export const useQuotaStats = () => {
export const useQuotaStats = (briefName = false) => {
const _ = useTranslation();
const { token, user } = useAuth();
const [quotas, setQuotas] = useState<QuotaType[]>([]);
@@ -15,18 +15,19 @@ export const useQuotaStats = () => {
const userPlan = getUserPlan(token);
const storagPlan = getStoragePlanData(token);
const inGB = storagPlan.quota > 1e9;
const storageQuota: QuotaType = {
name: _('Cloud Sync Storage'),
name: briefName ? _('Storage') : _('Cloud Sync Storage'),
tooltip: _('{{percentage}}% of Cloud Sync Space Used.', {
percentage: Math.round((storagPlan.usage / storagPlan.quota) * 100),
}),
used: Math.round(storagPlan.usage / 1024 / 1024),
total: Math.round(storagPlan.quota / 1024 / 1024),
unit: 'MB',
used: parseFloat((storagPlan.usage / 1024 / 1024 / (inGB ? 1024 : 1)).toFixed(2)),
total: Math.round(storagPlan.quota / 1024 / 1024 / (inGB ? 1024 : 1)),
unit: inGB ? 'GB' : 'MB',
};
const translationPlan = getTranslationPlanData(token);
const translationQuota: QuotaType = {
name: _('Translation Characters'),
name: briefName ? _('Translation') : _('Translation Characters'),
tooltip: _('{{percentage}}% of Daily Translation Characters Used.', {
percentage: Math.round((translationPlan.usage / translationPlan.quota) * 100),
}),
+4
View File
@@ -83,6 +83,10 @@ export type BookMetadata = {
export interface BookDoc {
metadata: BookMetadata;
rendition?: {
layout?: 'pre-paginated' | 'reflowable';
viewport?: { width: number; height: number };
};
dir: string;
toc?: Array<TOCItem>;
sections?: Array<SectionItem>;
@@ -0,0 +1,137 @@
import { IAPError } from '@/types/error';
import {
AppStoreServerAPI,
Environment,
JWSTransactionDecodedPayload,
decodeTransaction,
} from 'app-store-server-api';
export interface AppleIAPConfig {
keyId: string;
issuerId: string;
bundleId: string;
privateKey: string;
environment: 'sandbox' | 'production';
}
export interface VerificationResult {
success: boolean;
verified?: boolean;
status?: string;
transaction?: JWSTransactionDecodedPayload;
environment?: string;
bundleId?: string;
productId?: string;
transactionId?: string;
originalTransactionId?: string;
purchaseDate?: Date;
expiresDate?: Date | null;
quantity?: number;
type?: string;
revocationDate?: Date | null;
revocationReason?: number;
webOrderLineItemId?: string;
subscriptionGroupIdentifier?: string;
error?: string;
}
export class AppleIAPVerifier {
private client: AppStoreServerAPI;
private bundleId: string;
private environment: 'sandbox' | 'production';
constructor(config: AppleIAPConfig) {
this.bundleId = config.bundleId;
this.environment = config.environment;
this.client = new AppStoreServerAPI(
config.privateKey,
config.keyId,
config.issuerId,
config.bundleId,
config.environment === 'sandbox' ? Environment.Sandbox : Environment.Production,
);
}
async verifyTransaction(originalTransactionId: string): Promise<VerificationResult> {
let response;
try {
response = await this.client.getSubscriptionStatuses(originalTransactionId);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : IAPError.UNKNOWN_ERROR,
};
}
if (response.data && response.data.length > 0) {
const transaction = response.data[0]!;
const lastTransaction = transaction.lastTransactions.find(
(item) => item.originalTransactionId === originalTransactionId,
);
if (!lastTransaction) {
return {
success: false,
error: IAPError.TRANSACTION_NOT_FOUND,
};
}
const decodedTransaction = await decodeTransaction(lastTransaction.signedTransactionInfo);
const status = lastTransaction.status;
const expiresDate = decodedTransaction.expiresDate
? new Date(decodedTransaction.expiresDate)
: undefined;
const now = new Date();
// Status 1 = Active subscription
const isActive = status === 1 && (!expiresDate || expiresDate > now);
return {
success: true,
verified: true,
status: isActive ? 'active' : 'expired',
transaction: decodedTransaction,
environment: this.environment,
bundleId: this.bundleId,
productId: decodedTransaction.productId,
transactionId: decodedTransaction.transactionId,
originalTransactionId: decodedTransaction.originalTransactionId,
purchaseDate: new Date(decodedTransaction.purchaseDate),
expiresDate: decodedTransaction.expiresDate
? new Date(decodedTransaction.expiresDate)
: null,
quantity: decodedTransaction.quantity,
type: decodedTransaction.type,
revocationDate: decodedTransaction.revocationDate
? new Date(decodedTransaction.revocationDate)
: null,
revocationReason: decodedTransaction.revocationReason,
webOrderLineItemId: decodedTransaction.webOrderLineItemId,
subscriptionGroupIdentifier: decodedTransaction.subscriptionGroupIdentifier,
};
} else {
return {
success: false,
error: IAPError.TRANSACTION_NOT_FOUND,
};
}
}
}
export const createAppleIAPVerifier = (config: AppleIAPConfig) => new AppleIAPVerifier(config);
let defaultIAPVerifier: AppleIAPVerifier | undefined;
export const getAppleIAPVerifier = () => {
if (!defaultIAPVerifier) {
defaultIAPVerifier = createAppleIAPVerifier({
keyId: process.env['APPLE_IAP_KEY_ID']!,
issuerId: process.env['APPLE_IAP_ISSUER_ID']!,
bundleId: process.env['APPLE_IAP_BUNDLE_ID']!,
privateKey: Buffer.from(
process.env['APPLE_IAP_PRIVATE_KEY_BASE64']! || '',
'base64',
).toString('utf-8'),
environment: 'sandbox',
// environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
}
return defaultIAPVerifier;
};
@@ -34,6 +34,54 @@ const generateCacheKey = (text: string, sourceLang: string, targetLang: string):
return `tr:${hash}`;
};
const getDailyUsageKey = (userId?: string, token?: string) => {
let dailyUsageKey = '';
if (userId && token) {
const currentDate = new Date().toISOString().split('T')[0]!;
dailyUsageKey = `daily_usage:${currentDate}:${userId}`;
}
return dailyUsageKey;
};
const checkDailyUsage = async (
userId: string,
token: string,
chars: number,
translationsKV?: KVNamespace,
) => {
const usageKey = getDailyUsageKey(userId, token);
const dailyUsage = (await translationsKV?.get(usageKey)) || '0';
const { quota: dailyQuota } = getDailyTranslationPlanData(token);
const dailyUsageChars = parseInt(dailyUsage);
if (dailyQuota <= dailyUsageChars + chars) {
throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED);
}
return dailyUsageChars;
};
const updateDailyUsage = async (
userId: string | undefined,
token: string | undefined,
newUsage: number,
translationsKV?: KVNamespace,
) => {
const usageKey = getDailyUsageKey(userId, token);
if (!usageKey) return 0;
try {
const dailyUsage = (await translationsKV?.get(usageKey)) || '0';
const newDailyUsage = parseInt(dailyUsage) + newUsage;
await translationsKV?.put(usageKey, newDailyUsage.toString(), {
expirationTtl: 86400 * 30,
});
return newDailyUsage;
} catch (cacheError) {
console.error('Update daily usage error:', cacheError);
}
return 0;
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await runMiddleware(req, res, corsAllMethods);
@@ -71,7 +119,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const translations = await Promise.all(
text.map(async (singleText) => {
if (!singleText?.trim()) {
return { text: '' };
return { text: '', daily_usage: 0 };
}
if (useCache && hasKVCache) {
try {
@@ -81,6 +129,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (cachedTranslation) {
return {
text: cachedTranslation,
daily_usage: 0,
detected_source_language: sourceLang,
};
}
@@ -90,10 +139,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
await checkDailyUsage(user?.id, token, singleText.length, env['TRANSLATIONS_KV']);
return await callDeepLAPI(
user?.id,
token,
singleText,
sourceLang,
targetLang,
@@ -104,19 +152,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
);
}),
);
const originalCharsCount = text.reduce((a, b) => a + b.length, 0);
const translatedCharsCount = translations.reduce((a, b) => a + (b?.text.length || 0), 0);
const newDailyUsage = await updateDailyUsage(
user?.id,
token,
originalCharsCount + translatedCharsCount,
env['TRANSLATIONS_KV'],
);
translations.forEach((translation) => {
if (translation && translation.text) {
translation.daily_usage = newDailyUsage;
}
});
return res.status(200).json({ translations });
} catch (error) {
console.error('Error proxying DeepL request:', error);
if (error instanceof Error && error.message.includes(ErrorCodes.DAILY_QUOTA_EXCEEDED)) {
return res.status(429).json({ error: ErrorCodes.DAILY_QUOTA_EXCEEDED });
} else {
console.error('Error proxying DeepL request:', error);
}
return res.status(500).json({ error: ErrorCodes.INTERNAL_SERVER_ERROR });
}
};
async function callDeepLAPI(
userId: string | undefined,
token: string | undefined,
text: string,
sourceLang: string,
targetLang: string,
@@ -125,17 +185,6 @@ async function callDeepLAPI(
translationsKV: KVNamespace | undefined,
useCache: boolean,
) {
let dailyUsageKey = '';
if (userId && token) {
const { quota: dailyQuota } = getDailyTranslationPlanData(token);
const currentDate = new Date().toISOString().split('T')[0]!;
dailyUsageKey = `daily_usage:${currentDate}:${userId}`;
const dailyUsage = (await translationsKV?.get(dailyUsageKey)) || '0';
if (dailyQuota <= parseInt(dailyUsage) + text.length) {
throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED);
}
}
const isV2Api = apiUrl.endsWith('/v2/translate');
// TODO: this should be processed in the client, but for now, we need to do it here
@@ -177,20 +226,6 @@ async function callDeepLAPI(
translatedText = data.data;
}
let newDailyUsage = 0;
if (dailyUsageKey && translationsKV) {
try {
const usage = translatedText.length + text.length;
const dailyUsage = (await translationsKV.get(dailyUsageKey)) || '0';
newDailyUsage = parseInt(dailyUsage) + usage;
await translationsKV.put(dailyUsageKey, newDailyUsage.toString(), {
expirationTtl: 86400 * 30,
});
} catch (cacheError) {
console.error('Cache storage error:', cacheError);
}
}
if (useCache && translationsKV && translatedText) {
try {
const cacheKey = generateCacheKey(text, sourceLang, targetLang);
@@ -202,7 +237,7 @@ async function callDeepLAPI(
return {
text: translatedText,
daily_usage: newDailyUsage,
daily_usage: 0,
detected_source_language: detectedSourceLanguage,
};
}
@@ -25,25 +25,52 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Verify the file belongs to the user
const supabase = createSupabaseClient(token);
const { data: fileRecord, error: fileError } = await supabase
const result = await supabase
.from('files')
.select('user_id')
.select('user_id, file_key')
.eq('user_id', user.id)
.eq('file_key', fileKey) // index idx_files_file_key_deleted_at on public.files
.is('deleted_at', null)
.limit(1)
.single();
const { error: fileError } = result;
let { data: fileRecord } = result;
if (fileError || !fileRecord) {
return res.status(404).json({ error: 'File not found' });
// Fallback for corrupted file names, using book hash and file extension to match fileKey
if (fileKey.includes('Readest/Book')) {
const parts = fileKey.split('/');
if (parts.length === 5) {
const bookHash = parts[3]!;
const filename = parts[4]!;
const fileExtension = filename.split('.').pop() || '';
const { data: fileRecords, error: fileError } = await supabase
.from('files')
.select('user_id, file_key')
.eq('user_id', user.id)
.eq('book_hash', bookHash)
.is('deleted_at', null);
if (!fileError && fileRecords && fileRecords.length > 0) {
const matchedFile = fileRecords.find((f) => f.file_key.endsWith(`.${fileExtension}`));
if (matchedFile) {
fileRecord = matchedFile;
}
} else {
return res.status(404).json({ error: 'File not found' });
}
}
}
}
if (fileRecord.user_id !== user.id) {
if (fileRecord?.user_id !== user.id) {
return res.status(403).json({ error: 'Unauthorized access to the file' });
}
try {
const downloadUrl = await getDownloadSignedUrl(fileKey, 1800);
const downloadUrl = await getDownloadSignedUrl(fileRecord.file_key, 1800);
res.status(200).json({
downloadUrl,
+10 -4
View File
@@ -144,8 +144,12 @@ export async function POST(req: NextRequest) {
.insert(dbRec)
.select()
.single();
console.log('Inserted record:', inserted);
if (insertError) return { error: insertError.message };
if (insertError) {
console.log(`Failed to insert ${table} record:`, JSON.stringify(dbRec));
return {
error: insertError.message,
};
}
authoritativeRecords.push(inserted);
} else {
const clientUpdatedAt = dbRec.updated_at ? new Date(dbRec.updated_at).getTime() : 0;
@@ -166,8 +170,10 @@ export async function POST(req: NextRequest) {
.match(matchConditions)
.select()
.single();
console.log('Updated record:', updated);
if (updateError) return { error: updateError.message };
if (updateError) {
console.log(`Failed to update ${table} record:`, JSON.stringify(dbRec));
return { error: updateError.message };
}
authoritativeRecords.push(updated);
} else {
authoritativeRecords.push(serverData);
+48 -7
View File
@@ -16,6 +16,7 @@ import {
formatAuthors,
getFilename,
getPrimaryLanguage,
getLibraryBackupFilename,
} from '@/utils/book';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
@@ -220,6 +221,7 @@ export abstract class BaseAppService implements AppService {
existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle;
existingBook.author = existingBook.author ?? book.author;
existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
existingBook.downloadedAt = Date.now();
}
if (!(await this.fs.exists(getDir(book), 'Books'))) {
@@ -536,17 +538,44 @@ export abstract class BaseAppService implements AppService {
: this.getCoverImageUrl(book);
}
private async loadJSONFile(
filename: string,
): Promise<{ success: boolean; data?: unknown; error?: unknown }> {
try {
const txt = await this.fs.readFile(filename, 'Books', 'text');
if (!txt || typeof txt !== 'string' || txt.trim().length === 0) {
return { success: false, error: 'File is empty or invalid' };
}
try {
const data = JSON.parse(txt as string);
return { success: true, data };
} catch (parseError) {
return { success: false, error: `JSON parse error: ${parseError}` };
}
} catch (error) {
return { success: false, error };
}
}
async loadLibraryBooks(): Promise<Book[]> {
console.log('Loading library books...');
let books: Book[] = [];
const libraryFilename = getLibraryFilename();
const backupFilename = getLibraryBackupFilename();
try {
const txt = await this.fs.readFile(libraryFilename, 'Books', 'text');
books = JSON.parse(txt as string);
} catch {
await this.fs.createDir('', 'Books', true);
await this.fs.writeFile(libraryFilename, 'Books', '[]');
const mainResult = await this.loadJSONFile(libraryFilename);
if (mainResult.success) {
books = mainResult.data as Book[];
} else {
const backupResult = await this.loadJSONFile(backupFilename);
if (backupResult.success) {
books = backupResult.data as Book[];
console.warn('Loaded library from backup file:', backupFilename);
} else {
await this.fs.createDir('', 'Books', true);
await this.fs.writeFile(libraryFilename, 'Books', '[]');
await this.fs.writeFile(backupFilename, 'Books', '[]');
}
}
await Promise.all(
@@ -563,7 +592,19 @@ export abstract class BaseAppService implements AppService {
async saveLibraryBooks(books: Book[]): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
await this.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(libraryBooks));
const jsonData = JSON.stringify(libraryBooks, null, 2);
const libraryFilename = getLibraryFilename();
const backupFilename = getLibraryBackupFilename();
const saveResults = await Promise.allSettled([
this.fs.writeFile(backupFilename, 'Books', jsonData),
this.fs.writeFile(libraryFilename, 'Books', jsonData),
]);
const backupSuccess = saveResults[0].status === 'fulfilled';
const mainSuccess = saveResults[1].status === 'fulfilled';
if (!backupSuccess || !mainSuccess) {
throw new Error('Failed to save library books');
}
}
private imageToArrayBuffer(imageUrl?: string, imageFile?: string): Promise<ArrayBuffer> {
+4 -3
View File
@@ -40,7 +40,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
keepLogin: false,
autoUpload: true,
alwaysOnTop: false,
openBookInNewWindow: false,
openBookInNewWindow: true,
alwaysShowStatusBar: false,
autoCheckUpdates: true,
screenWakeLock: false,
@@ -535,6 +535,7 @@ export const BOOK_IDS_SEPARATOR = '+';
export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web';
export const READEST_WEB_BASE_URL = 'https://web.readest.com';
export const READEST_NODE_BASE_URL = 'https://node.readest.com';
const LATEST_DOWNLOAD_BASE_URL = 'https://download.readest.com/releases';
@@ -555,8 +556,8 @@ export const ZOOM_STEP = 10;
export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
free: 500 * 1024 * 1024,
plus: 2 * 1024 * 1024 * 1024,
pro: 10 * 1024 * 1024 * 1024,
plus: 5 * 1024 * 1024 * 1024,
pro: 20 * 1024 * 1024 * 1024,
};
export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = {
+9 -3
View File
@@ -1,5 +1,5 @@
import { AppService } from '@/types/system';
import { READEST_WEB_BASE_URL } from './constants';
import { READEST_NODE_BASE_URL, READEST_WEB_BASE_URL } from './constants';
declare global {
interface Window {
@@ -12,12 +12,18 @@ export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] ==
export const hasCli = () => window.__READEST_CLI_ACCESS === true;
export const isPWA = () => window.matchMedia('(display-mode: standalone)').matches;
export const getBaseUrl = () => process.env['NEXT_PUBLIC_API_BASE_URL'] ?? READEST_WEB_BASE_URL;
export const getNodeBaseUrl = () =>
process.env['NEXT_PUBLIC_NODE_BASE_URL'] ?? READEST_NODE_BASE_URL;
const isWebDevMode = () => process.env['NODE_ENV'] === 'development' && isWebAppPlatform();
// Dev API only in development mode and web platform
// with command `pnpm dev-web`
// for production build or tauri app use the production Web API
export const getAPIBaseUrl = () =>
process.env['NODE_ENV'] === 'development' && isWebAppPlatform() ? '/api' : `${getBaseUrl()}/api`;
export const getAPIBaseUrl = () => (isWebDevMode() ? '/api' : `${getBaseUrl()}/api`);
// For Node.js API that currently not supported in some edge runtimes
export const getNodeAPIBaseUrl = () => (isWebDevMode() ? '/api' : `${getNodeBaseUrl()}/api`);
export interface EnvConfigType {
getAppService: () => Promise<AppService>;
@@ -2,6 +2,7 @@ import { TranslationProvider } from '../types';
import { deeplProvider } from './deepl';
import { azureProvider } from './azure';
import { googleProvider } from './google';
import { yandexProvider } from './yandex';
function createTranslator<T extends string>(
name: T,
@@ -18,11 +19,13 @@ function createTranslator<T extends string>(
const deeplTranslator = createTranslator('deepl', deeplProvider);
const azureTranslator = createTranslator('azure', azureProvider);
const googleTranslator = createTranslator('google', googleProvider);
const yandexTranslator = createTranslator('yandex', yandexProvider);
const availableTranslators = [
deeplTranslator,
azureTranslator,
googleTranslator,
yandexTranslator,
// Add more translators here
];
@@ -0,0 +1,77 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { normalizeToShortLang } from '@/utils/lang';
import { TranslationProvider } from '../types';
/**
* Based on https://translate.toil.cc/v2/docs API specification
*/
async function translateSingleTextForService(
text: string,
lang: string,
service: string,
): Promise<string[]> {
const fetchImpl = isTauriAppPlatform() ? tauriFetch : window.fetch;
const url = 'https://translate.toil.cc/v2/translate/';
const request = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
lang: lang,
service: service,
text: text,
})
};
const response = await fetchImpl(url, request);
if (!response.ok) {
const response_json = JSON.stringify(await response.json());
throw new Error(`${service} failed with status ${response.status}\n${text.length}\n${JSON.stringify(request)}\n${response_json}`);
}
const data = await response.json();
if (
data &&
Array.isArray(data.translations)
) {
return data.translations;
} else {
// fallback: return original texts if translation failed
return [text];
}
};
export const yandexProvider: TranslationProvider = {
name: 'yandex',
label: _('Yandex Translate'),
authRequired: false,
translate: async (texts: string[], sourceLang: string, targetLang: string): Promise<string[]> => {
if (!texts.length) return [];
/**
Possible options:
- yandexcloud: often returns 500: {"error":"The text couldn't be translated, because Forbidden"}
- yandexgpt: often better than others
- yandextranslate
- yandexbrowser
*/
const service = "yandexgpt";
// Yandex does not accept "auto" language
const source_lang = sourceLang == "AUTO" ? "en" : normalizeToShortLang(sourceLang).toLowerCase();
const target_lang = normalizeToShortLang(targetLang).toLowerCase();
const lang = `${source_lang}-${target_lang}`;
const responses = await Promise.all(texts.map(async text => {
return await translateSingleTextForService(text, lang, service)
}));
const translatedTexts = responses.flat();
return translatedTexts;
}
};
@@ -51,6 +51,7 @@ const indexedDBFileSystem: FileSystem = {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
} catch {
console.warn(`Failed to get Blob URL for path: ${path} in base: ${base}`);
return path;
}
},
@@ -117,10 +118,13 @@ const indexedDBFileSystem: FileSystem = {
request.onerror = () => reject(request.error);
});
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) {
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File) {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
if (content instanceof File) {
content = await content.arrayBuffer();
}
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
@@ -72,6 +72,7 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
const book = library.splice(bookIndex, 1)[0]!;
book.progress = config.progress;
book.updatedAt = Date.now();
book.downloadedAt = book.downloadedAt || Date.now();
library.unshift(book);
setLibrary(library);
config.updatedAt = Date.now();
+23 -1
View File
@@ -7,6 +7,10 @@ interface LibraryState {
checkOpenWithBooks: boolean;
checkLastOpenBooks: boolean;
currentBookshelf: (Book | BooksGroup)[];
selectedBooks: Set<string>; // hashes for books, ids for groups
setSelectedBooks: (ids: string[]) => void;
getSelectedBooks: () => string[];
toggleSelectedBook: (id: string) => void;
getVisibleLibrary: () => Book[];
setCheckOpenWithBooks: (check: boolean) => void;
setCheckLastOpenBooks: (check: boolean) => void;
@@ -18,6 +22,7 @@ interface LibraryState {
export const useLibraryStore = create<LibraryState>((set, get) => ({
library: [],
currentBookshelf: [],
selectedBooks: new Set(),
checkOpenWithBooks: isTauriAppPlatform(),
checkLastOpenBooks: isTauriAppPlatform(),
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
@@ -35,6 +40,23 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
library[bookIndex] = book;
}
set({ library: [...library] });
appService.saveLibraryBooks(library);
await appService.saveLibraryBooks(library);
},
setSelectedBooks: (ids: string[]) => {
set({ selectedBooks: new Set(ids) });
},
getSelectedBooks: () => {
return Array.from(get().selectedBooks);
},
toggleSelectedBook: (id: string) => {
set((state) => {
const newSelection = new Set(state.selectedBooks);
if (newSelection.has(id)) {
newSelection.delete(id);
} else {
newSelection.add(id);
}
return { selectedBooks: newSelection };
});
},
}));
+1 -1
View File
@@ -60,7 +60,7 @@ export const useThemeStore = create<ThemeState>((set, get) => {
set({ systemIsDarkMode: mediaQuery.matches, isDarkMode });
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
mediaQuery?.addEventListener('change', handleSystemThemeChange);
}
return {
@@ -9,8 +9,9 @@ interface TrafficLightState {
appService?: AppService;
isTrafficLightVisible: boolean;
shouldShowTrafficLight: boolean;
trafficLightInFullscreen: boolean;
initializeTrafficLightStore: (appService: AppService) => void;
setTrafficLightVisibility: (visible: boolean) => void;
setTrafficLightVisibility: (visible: boolean, position?: { x: number; y: number }) => void;
initializeTrafficLightListeners: () => Promise<void>;
cleanupTrafficLightListeners: () => void;
unlistenEnterFullScreen?: () => void;
@@ -22,6 +23,7 @@ export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
appService: undefined,
isTrafficLightVisible: false,
shouldShowTrafficLight: false,
trafficLightInFullscreen: false,
initializeTrafficLightStore: (appService: AppService) => {
set({
@@ -31,15 +33,19 @@ export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
});
},
setTrafficLightVisibility: async (visible: boolean) => {
setTrafficLightVisibility: async (visible: boolean, position?: { x: number; y: number }) => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
const isFullscreen = await currentWindow.isFullscreen();
set({ isTrafficLightVisible: !isFullscreen && visible, shouldShowTrafficLight: visible });
set({
isTrafficLightVisible: !isFullscreen && visible,
shouldShowTrafficLight: visible,
trafficLightInFullscreen: isFullscreen,
});
invoke('set_traffic_lights', {
visible: visible,
x: WINDOW_CONTROL_PAD_X,
y: WINDOW_CONTROL_PAD_Y,
x: position?.x ?? WINDOW_CONTROL_PAD_X,
y: position?.y ?? WINDOW_CONTROL_PAD_Y,
});
},
@@ -48,12 +54,12 @@ export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
const currentWindow = getCurrentWindow();
const unlistenEnterFullScreen = await currentWindow.listen('will-enter-fullscreen', () => {
set({ isTrafficLightVisible: false });
set({ isTrafficLightVisible: false, trafficLightInFullscreen: true });
});
const unlistenExitFullScreen = await currentWindow.listen('will-exit-fullscreen', () => {
const { shouldShowTrafficLight } = get();
set({ isTrafficLightVisible: shouldShowTrafficLight });
set({ isTrafficLightVisible: shouldShowTrafficLight, trafficLightInFullscreen: false });
});
set({ unlistenEnterFullScreen, unlistenExitFullScreen });

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