forked from akai/readest
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d120cf6573 | |||
| d2b3c165c7 | |||
| 742f06ce05 | |||
| e28dabce65 | |||
| 33ef781e21 | |||
| 02e885fbb7 | |||
| 2ca3561093 | |||
| 97f021da87 | |||
| bd2b45e1c1 | |||
| 2b6f7b71b0 | |||
| 0bad380a50 | |||
| ecc20e8521 | |||
| d5c813fac1 | |||
| d9b3757b1c | |||
| fd7f90236d | |||
| df9d21393d | |||
| 1f73f15ad8 | |||
| ba2aa4bee6 | |||
| f709a657fa | |||
| 514780a572 | |||
| 0c51a625f3 | |||
| 6f8b2d1dc4 | |||
| 0087ce2f19 | |||
| b98c2796c8 | |||
| 37d56b3205 | |||
| 5a54c0fb60 | |||
| 99b259836b | |||
| a54daaaa90 | |||
| fa66e6fca6 | |||
| b7864dded2 | |||
| 72d9698f38 | |||
| a7937cd657 | |||
| 998b14c5b0 | |||
| bff9c2a770 | |||
| b5acdffc87 | |||
| 127609160c | |||
| 8f22a5c570 | |||
| c792c18e01 | |||
| cd614e3845 | |||
| 40673f9cb8 | |||
| 911aa4c73d | |||
| 74b4cc2ceb | |||
| c0c463977d | |||
| 273bcafe01 | |||
| 9028059919 | |||
| c86af457e7 | |||
| b8e979be55 | |||
| 8849c19e8e | |||
| f1b4d02323 | |||
| 4aa8847037 | |||
| d2389400a9 |
@@ -11,7 +11,7 @@ jobs:
|
||||
env:
|
||||
RUSTFLAGS: '-C target-cpu=skylake'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
- name: Install minimal stable with clippy and rustfmt
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- platform: 'web'
|
||||
- platform: 'tauri'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install && pnpm setup-pdfjs
|
||||
pnpm install && pnpm setup-vendors
|
||||
|
||||
- name: run tests
|
||||
working-directory: apps/readest-app
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
release_version: ${{ steps.get-release-notes.outputs.release_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v6
|
||||
- name: get version
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: create KOReader plugin zip
|
||||
env:
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
@@ -180,8 +180,8 @@ jobs:
|
||||
- name: install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: copy pdfjs-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-pdfjs
|
||||
- name: copy pdfjs-dist and simplecc-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-vendors
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@v25
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
[submodule "packages/tauri-plugins"]
|
||||
path = packages/tauri-plugins
|
||||
url = https://github.com/readest/tauri-plugins-workspace.git
|
||||
[submodule "packages/simplecc-wasm"]
|
||||
path = packages/simplecc-wasm
|
||||
url = https://github.com/readest/simplecc-wasm.git
|
||||
|
||||
Generated
+265
-240
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
<h1>Readest</h1>
|
||||
<br>
|
||||
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 16](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
|
||||
|
||||
[![Website][badge-website]][link-website]
|
||||
[![Web App][badge-web-app]][link-web-readest]
|
||||
@@ -14,7 +14,7 @@
|
||||
[![Discord][badge-discord]][link-discord]
|
||||
[![Reddit][badge-reddit]][link-reddit]
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![Language Coverage][badge-language-coverage]]()
|
||||
[![Language Coverage][badge-language-coverage]][link-locales]
|
||||
[![Donate][badge-donate]][link-donate]
|
||||
[![Latest release][badge-release]][link-gh-releases]
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
@@ -111,6 +111,7 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||
- macOS / iOS / iPadOS : Search and install **Readest** on the [App Store][link-appstore], _also_ available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
|
||||
- Windows / Linux / Android: Visit and download **Readest** at [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Linux users can also install [Readest on Flathub][link-flathub].
|
||||
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
|
||||
|
||||
## Requirements
|
||||
@@ -144,8 +145,8 @@ cd readest
|
||||
# might need to rerun this when code is updated
|
||||
git submodule update --init --recursive
|
||||
pnpm install
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
# copy vendors dist libs to public directory
|
||||
pnpm --filter @readest/readest-app setup-vendors
|
||||
```
|
||||
|
||||
### 3. Verify Dependencies Installation
|
||||
@@ -308,10 +309,11 @@ The following fonts are utilized in this software, either bundled within the app
|
||||
[badge-donate]: https://donate.readest.com/badge.svg
|
||||
[badge-deepwiki]: https://deepwiki.com/badge.svg
|
||||
[badge-reddit]: https://img.shields.io/reddit/subreddit-subscribers/readest?style=flat&logo=reddit&color=F37E41
|
||||
[badge-language-coverage]: https://img.shields.io/badge/coverage-52%25%20population%20🌍-green
|
||||
[badge-language-coverage]: https://img.shields.io/badge/coverage-53%25%20population%20🌍-green
|
||||
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
|
||||
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
|
||||
[link-flathub]: https://flathub.org/en/apps/com.bilingify.readest
|
||||
[link-web-readest]: https://web.readest.com
|
||||
[link-gh-releases]: https://github.com/readest/readest/releases
|
||||
[link-gh-commits]: https://github.com/readest/readest/commits/main
|
||||
@@ -322,5 +324,6 @@ The following fonts are utilized in this software, either bundled within the app
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
|
||||
[link-deepwiki]: https://deepwiki.com/readest/readest
|
||||
[link-locales]: https://github.com/readest/readest/tree/main/apps/readest-app/public/locales
|
||||
[link-kosync-wiki]: https://github.com/readest/readest/wiki/Sync-with-Koreader-devices
|
||||
[link-reddit]: https://reddit.com/r/readest/
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import next from 'eslint-config-next';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import tseslint from 'eslint-config-next/typescript';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...tseslint,
|
||||
...next,
|
||||
...nextVitals,
|
||||
{
|
||||
rules: jsxA11y.configs.recommended.rules,
|
||||
rules: {
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
'node_modules/**',
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories-next"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dirs-sys-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys-next"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-rs-sys",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f954a9e9159ec994f73a30a12b96a702dde78f5547bcb561174597924f7d4162"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-rs-sys"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80986bbbcf925ebd3be54c26613d861255284584501595cf418320c078945608"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3502d6155304a4173a5f2c34b52b7ed0dd085890326cb50fd625fdf39e86b3b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_thumbnail"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"directories-next",
|
||||
"image",
|
||||
"md5",
|
||||
"once_cell",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fb7703e32e9a07fb3f757360338b3a567a5054f21b5f52a666752e333d58e"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "windows_thumbnail"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
name = "windows_thumbnail"
|
||||
path = "src/mod.rs"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
directories-next = "2.0"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
|
||||
md5 = "0.8"
|
||||
once_cell = "1.19"
|
||||
zip = { version = "6.0", default-features = false, features = ["deflate"] }
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Security",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Registry",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
windows-core = "0.62"
|
||||
@@ -0,0 +1,131 @@
|
||||
# Windows Thumbnail Provider for Readest
|
||||
|
||||
This crate provides Windows Explorer thumbnail support for eBook files when Readest is set as the default application.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic Cover Extraction**: Extracts cover images from EPUB, MOBI, AZW, AZW3, FB2, CBZ, CBR files
|
||||
- **Readest Branding**: Adds a small Readest icon overlay at the bottom-right corner
|
||||
- **Smart Caching**: Caches generated thumbnails for faster subsequent loads
|
||||
- **File Association Aware**: Only shows thumbnails when Readest is the default app for the file type
|
||||
- **COM Integration**: Full Windows Shell extension implementation via `IThumbnailProvider`
|
||||
|
||||
## Supported Formats
|
||||
|
||||
| Format | Extension | Cover Source |
|
||||
|--------|-----------|--------------|
|
||||
| EPUB | `.epub` | OPF manifest cover reference |
|
||||
| MOBI/AZW | `.mobi`, `.azw`, `.prc` | EXTH cover offset |
|
||||
| AZW3/KF8 | `.azw3`, `.kf8` | KF8 format cover |
|
||||
| FB2 | `.fb2` | `<binary>` coverpage element |
|
||||
| Comic Book | `.cbz`, `.cbr` | First image in archive |
|
||||
| Plain Text | `.txt` | Generated placeholder |
|
||||
|
||||
## Building
|
||||
|
||||
### Library Only
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### COM DLL (for Windows Explorer integration)
|
||||
```bash
|
||||
cargo build --release --features com
|
||||
```
|
||||
|
||||
### CLI Tool
|
||||
```bash
|
||||
cargo build --release --features cli
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
The thumbnail provider DLL is automatically registered when Readest is installed via the NSIS installer.
|
||||
|
||||
### Manual Registration (for development)
|
||||
|
||||
```powershell
|
||||
# Register the DLL
|
||||
regsvr32 /s target\release\windows_thumbnail.dll
|
||||
|
||||
# Unregister the DLL
|
||||
regsvr32 /s /u target\release\windows_thumbnail.dll
|
||||
|
||||
# Refresh Explorer to see changes
|
||||
ie4uinit.exe -show
|
||||
```
|
||||
|
||||
After registration, you may need to restart Windows Explorer or log out/in for changes to take effect.
|
||||
|
||||
## Usage (Development / Manual testing)
|
||||
|
||||
For local development and testing, build the Windows DLL (or the library) from the Readest Tauri app folder and register it manually. The legacy CLI test harness used to live in the separate `packages/tauri` workspace, but the thumbnail handler implementation now lives inside Readest's Tauri app.
|
||||
|
||||
Build the DLL (for Windows explorer integration):
|
||||
|
||||
```powershell
|
||||
cd apps/readest-app/src-tauri
|
||||
cargo build --release --manifest-path Cargo.toml --features com
|
||||
```
|
||||
|
||||
The standalone CLI test harness is no longer distributed with the app. To test the thumbnail provider locally, build and register the DLL as shown above and use a small test harness that imports `readestlib`'s thumbnail code or use Explorer after registering the handler.
|
||||
|
||||
Manual registration for development (register the generated DLL):
|
||||
|
||||
```powershell
|
||||
# Register the DLL
|
||||
regsvr32 /s target\release\windows_thumbnail.dll
|
||||
|
||||
# Unregister the DLL
|
||||
regsvr32 /s /u target\release\windows_thumbnail.dll
|
||||
|
||||
# Refresh Explorer to see changes
|
||||
ie4uinit.exe -show
|
||||
```
|
||||
|
||||
This generates a thumbnail with the Readest overlay at the specified size.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Windows Explorer │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ IThumbnailProvider ───┼──► ThumbnailProvider │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ Check File Association │
|
||||
│ │ (is Readest the default?) │
|
||||
│ │ │ │
|
||||
│ │ ▼ (if yes) │
|
||||
│ │ Extract Cover Image │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ Add Readest Overlay │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ Return HBITMAP │
|
||||
│ │ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## COM Details
|
||||
|
||||
- **CLSID**: `{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}`
|
||||
- **Shell Thumbnail Handler GUID**: `{e357fccd-a995-4576-b01f-234630154e96}`
|
||||
- **Threading Model**: Apartment
|
||||
|
||||
## How It Works
|
||||
|
||||
1. When Windows Explorer needs a thumbnail, it queries the registered shell extension
|
||||
2. The COM DLL implements `IInitializeWithItem` to receive the file path
|
||||
3. It checks if Readest.exe is the default application for that file type using `AssocQueryStringW`
|
||||
4. If Readest is the default, it extracts the cover and generates the thumbnail
|
||||
5. If Readest is NOT the default, it returns `S_FALSE` to let Windows use other handlers
|
||||
|
||||
This ensures thumbnails only appear for files the user has associated with Readest.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details.
|
||||
@@ -0,0 +1,506 @@
|
||||
/// Windows COM Thumbnail Provider for Readest
|
||||
///
|
||||
/// Implements IThumbnailProvider and IInitializeWithItem for Windows Shell integration.
|
||||
/// This allows Windows Explorer to show book covers as thumbnails for eBook files.
|
||||
///
|
||||
/// **Important**: Thumbnails are only shown when Readest.exe is the default application
|
||||
/// for the file type.
|
||||
///
|
||||
/// ## CLSID: {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}
|
||||
use std::cell::UnsafeCell;
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicIsize, AtomicU32, Ordering};
|
||||
|
||||
use windows::core::{IUnknown, Interface, GUID, HRESULT, PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{
|
||||
CLASS_E_NOAGGREGATION, E_FAIL, E_INVALIDARG, E_NOINTERFACE, HMODULE, S_FALSE, S_OK,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateDIBSection, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP,
|
||||
};
|
||||
use windows::Win32::System::Com::{CoTaskMemFree, IClassFactory, IClassFactory_Impl};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
|
||||
use windows::Win32::System::Registry::{
|
||||
RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegSetValueExW, HKEY, HKEY_CLASSES_ROOT,
|
||||
KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ,
|
||||
};
|
||||
use windows::Win32::UI::Shell::{
|
||||
AssocQueryStringW, IInitializeWithItem, IInitializeWithItem_Impl, IShellItem,
|
||||
IThumbnailProvider, IThumbnailProvider_Impl, ASSOCF_NONE, ASSOCSTR_EXECUTABLE,
|
||||
SIGDN_FILESYSPATH, WTSAT_ARGB, WTS_ALPHATYPE,
|
||||
};
|
||||
use windows_core::BOOL;
|
||||
use windows_core::{implement, Ref};
|
||||
|
||||
use super::cached_thumbnail_for_path;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CLSID for Readest Thumbnail Provider
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// CLSID: {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}
|
||||
pub const CLSID_READEST_THUMBNAIL: GUID = GUID::from_u128(0xA1B2C3D4_E5F6_7890_ABCD_EF1234567890);
|
||||
|
||||
/// Supported file extensions
|
||||
pub const SUPPORTED_EXTENSIONS: &[&str] = &[
|
||||
".epub", ".mobi", ".azw", ".azw3", ".kf8", ".prc", ".fb2", ".cbz", ".cbr", ".txt",
|
||||
];
|
||||
|
||||
// DLL reference counting
|
||||
static DLL_REF_COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
static DLL_MODULE_PTR: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
fn dll_add_ref() {
|
||||
DLL_REF_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn dll_release() {
|
||||
DLL_REF_COUNT.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn set_dll_module(h: HMODULE) {
|
||||
DLL_MODULE_PTR.store(h.0 as isize, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn get_dll_module() -> Option<HMODULE> {
|
||||
let ptr = DLL_MODULE_PTR.load(Ordering::SeqCst);
|
||||
if ptr == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(HMODULE(ptr as *mut c_void))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// File Association Check
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Check if Readest.exe is the default application for a given file extension.
|
||||
fn is_readest_default_for_extension(ext: &str) -> bool {
|
||||
let ext_wide: Vec<u16> = ext.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut buffer = [0u16; 260];
|
||||
let mut buffer_size = buffer.len() as u32;
|
||||
|
||||
unsafe {
|
||||
let result = AssocQueryStringW(
|
||||
ASSOCF_NONE,
|
||||
ASSOCSTR_EXECUTABLE,
|
||||
PCWSTR(ext_wide.as_ptr()),
|
||||
None,
|
||||
Some(PWSTR(buffer.as_mut_ptr())),
|
||||
&mut buffer_size,
|
||||
);
|
||||
|
||||
if result.is_ok() {
|
||||
let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len());
|
||||
let path = String::from_utf16_lossy(&buffer[..len]).to_lowercase();
|
||||
return path.contains("readest");
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if Readest is the default app for a specific file path.
|
||||
fn is_readest_default_for_file(path: &PathBuf) -> bool {
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
let ext_with_dot = format!(".{}", ext.to_lowercase());
|
||||
return is_readest_default_for_extension(&ext_with_dot);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ThumbnailProvider
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Interior mutability wrapper for COM single-threaded apartment
|
||||
struct ComCell<T>(UnsafeCell<T>);
|
||||
|
||||
impl<T> ComCell<T> {
|
||||
fn new(value: T) -> Self {
|
||||
Self(UnsafeCell::new(value))
|
||||
}
|
||||
fn get(&self) -> &T {
|
||||
unsafe { &*self.0.get() }
|
||||
}
|
||||
fn set(&self, value: T) {
|
||||
unsafe {
|
||||
*self.0.get() = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: COM thumbnail providers run in single-threaded apartment (STA)
|
||||
unsafe impl<T> Sync for ComCell<T> {}
|
||||
unsafe impl<T> Send for ComCell<T> {}
|
||||
|
||||
#[implement(IThumbnailProvider, IInitializeWithItem)]
|
||||
pub struct ThumbnailProvider {
|
||||
file_path: ComCell<Option<PathBuf>>,
|
||||
file_ext: ComCell<Option<String>>,
|
||||
should_provide: ComCell<bool>,
|
||||
}
|
||||
|
||||
impl ThumbnailProvider {
|
||||
pub fn new() -> Self {
|
||||
dll_add_ref();
|
||||
Self {
|
||||
file_path: ComCell::new(None),
|
||||
file_ext: ComCell::new(None),
|
||||
should_provide: ComCell::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThumbnailProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ThumbnailProvider {
|
||||
fn drop(&mut self) {
|
||||
dll_release();
|
||||
}
|
||||
}
|
||||
|
||||
impl IInitializeWithItem_Impl for ThumbnailProvider_Impl {
|
||||
fn Initialize(&self, psi: Ref<'_, IShellItem>, _grfmode: u32) -> windows::core::Result<()> {
|
||||
let item = psi.ok()?;
|
||||
|
||||
unsafe {
|
||||
let path_pwstr = item.GetDisplayName(SIGDN_FILESYSPATH)?;
|
||||
|
||||
let mut len = 0usize;
|
||||
let mut ptr = path_pwstr.0;
|
||||
while *ptr != 0 {
|
||||
len += 1;
|
||||
ptr = ptr.add(1);
|
||||
}
|
||||
|
||||
let slice = std::slice::from_raw_parts(path_pwstr.0, len);
|
||||
let path_str = String::from_utf16_lossy(slice);
|
||||
let path = PathBuf::from(&path_str);
|
||||
|
||||
CoTaskMemFree(Some(path_pwstr.0 as *const c_void));
|
||||
|
||||
let is_default = is_readest_default_for_file(&path);
|
||||
self.should_provide.set(is_default);
|
||||
|
||||
if !is_default {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
self.file_path.set(Some(path));
|
||||
self.file_ext.set(Some(ext));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IThumbnailProvider_Impl for ThumbnailProvider_Impl {
|
||||
fn GetThumbnail(
|
||||
&self,
|
||||
cx: u32,
|
||||
phbmp: *mut HBITMAP,
|
||||
pdwalpha: *mut WTS_ALPHATYPE,
|
||||
) -> windows::core::Result<()> {
|
||||
if !*self.should_provide.get() {
|
||||
return Err(E_FAIL.into());
|
||||
}
|
||||
|
||||
let path = self.file_path.get().as_ref().ok_or(E_FAIL)?;
|
||||
let ext = self.file_ext.get().as_ref().ok_or(E_FAIL)?;
|
||||
|
||||
let png_bytes = cached_thumbnail_for_path(path, ext, cx).map_err(|_| E_FAIL)?;
|
||||
let img = image::load_from_memory(&png_bytes).map_err(|_| E_FAIL)?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (width, height) = (rgba.width(), rgba.height());
|
||||
|
||||
let bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: width as i32,
|
||||
biHeight: -(height as i32),
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut bits: *mut c_void = std::ptr::null_mut();
|
||||
|
||||
unsafe {
|
||||
let hbmp = CreateDIBSection(None, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
|
||||
.map_err(|_| E_FAIL)?;
|
||||
if bits.is_null() {
|
||||
return Err(E_FAIL.into());
|
||||
}
|
||||
|
||||
// RGBA -> BGRA
|
||||
let dst =
|
||||
std::slice::from_raw_parts_mut(bits as *mut u8, (width * height * 4) as usize);
|
||||
let src = rgba.as_raw();
|
||||
for i in 0..(width * height) as usize {
|
||||
let si = i * 4;
|
||||
dst[si] = src[si + 2]; // B
|
||||
dst[si + 1] = src[si + 1]; // G
|
||||
dst[si + 2] = src[si]; // R
|
||||
dst[si + 3] = src[si + 3]; // A
|
||||
}
|
||||
|
||||
*phbmp = hbmp;
|
||||
*pdwalpha = WTSAT_ARGB;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ClassFactory
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[implement(IClassFactory)]
|
||||
pub struct ThumbnailProviderFactory;
|
||||
|
||||
impl ThumbnailProviderFactory {
|
||||
pub fn new() -> Self {
|
||||
dll_add_ref();
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThumbnailProviderFactory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ThumbnailProviderFactory {
|
||||
fn drop(&mut self) {
|
||||
dll_release();
|
||||
}
|
||||
}
|
||||
|
||||
impl IClassFactory_Impl for ThumbnailProviderFactory_Impl {
|
||||
fn CreateInstance(
|
||||
&self,
|
||||
punkouter: Ref<'_, IUnknown>,
|
||||
riid: *const GUID,
|
||||
ppvobject: *mut *mut c_void,
|
||||
) -> windows::core::Result<()> {
|
||||
unsafe {
|
||||
if ppvobject.is_null() {
|
||||
return Err(E_INVALIDARG.into());
|
||||
}
|
||||
*ppvobject = std::ptr::null_mut();
|
||||
if !punkouter.is_null() {
|
||||
return Err(CLASS_E_NOAGGREGATION.into());
|
||||
}
|
||||
|
||||
let provider: IThumbnailProvider = ThumbnailProvider::new().into();
|
||||
provider.query(&*riid, ppvobject).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn LockServer(&self, flock: BOOL) -> windows::core::Result<()> {
|
||||
if flock.as_bool() {
|
||||
dll_add_ref();
|
||||
} else {
|
||||
dll_release();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DLL Exports
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn DllMain(hinstance: HMODULE, reason: u32, _reserved: *mut c_void) -> BOOL {
|
||||
const DLL_PROCESS_ATTACH: u32 = 1;
|
||||
if reason == DLL_PROCESS_ATTACH {
|
||||
set_dll_module(hinstance);
|
||||
}
|
||||
BOOL::from(true)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn DllCanUnloadNow() -> HRESULT {
|
||||
if DLL_REF_COUNT.load(Ordering::SeqCst) == 0 {
|
||||
S_OK
|
||||
} else {
|
||||
S_FALSE
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "system" fn DllGetClassObject(
|
||||
rclsid: *const GUID,
|
||||
riid: *const GUID,
|
||||
ppv: *mut *mut c_void,
|
||||
) -> HRESULT {
|
||||
if ppv.is_null() || rclsid.is_null() || riid.is_null() {
|
||||
return E_INVALIDARG;
|
||||
}
|
||||
*ppv = std::ptr::null_mut();
|
||||
|
||||
if *rclsid != CLSID_READEST_THUMBNAIL {
|
||||
return E_NOINTERFACE;
|
||||
}
|
||||
if *riid != IClassFactory::IID && *riid != IUnknown::IID {
|
||||
return E_NOINTERFACE;
|
||||
}
|
||||
|
||||
let factory: IClassFactory = ThumbnailProviderFactory::new().into();
|
||||
factory.query(&*riid, ppv)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "system" fn DllRegisterServer() -> HRESULT {
|
||||
match register_server_impl() {
|
||||
Ok(()) => S_OK,
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "system" fn DllUnregisterServer() -> HRESULT {
|
||||
let _ = unregister_server_impl();
|
||||
S_OK
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Registry helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn get_dll_path() -> Option<String> {
|
||||
let module = get_dll_module()?;
|
||||
let mut buffer = [0u16; 260];
|
||||
unsafe {
|
||||
let len = GetModuleFileNameW(Some(module), &mut buffer);
|
||||
if len == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf16_lossy(&buffer[..len as usize]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clsid_string() -> String {
|
||||
format!(
|
||||
"{{{:08X}-{:04X}-{:04X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}}}",
|
||||
CLSID_READEST_THUMBNAIL.data1,
|
||||
CLSID_READEST_THUMBNAIL.data2,
|
||||
CLSID_READEST_THUMBNAIL.data3,
|
||||
CLSID_READEST_THUMBNAIL.data4[0],
|
||||
CLSID_READEST_THUMBNAIL.data4[1],
|
||||
CLSID_READEST_THUMBNAIL.data4[2],
|
||||
CLSID_READEST_THUMBNAIL.data4[3],
|
||||
CLSID_READEST_THUMBNAIL.data4[4],
|
||||
CLSID_READEST_THUMBNAIL.data4[5],
|
||||
CLSID_READEST_THUMBNAIL.data4[6],
|
||||
CLSID_READEST_THUMBNAIL.data4[7]
|
||||
)
|
||||
}
|
||||
|
||||
fn to_wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
unsafe fn set_reg_value(key: HKEY, name: &str, value: &str) -> Result<(), HRESULT> {
|
||||
let name_w = to_wide(name);
|
||||
let value_w = to_wide(value);
|
||||
let bytes: &[u8] = std::slice::from_raw_parts(value_w.as_ptr() as *const u8, value_w.len() * 2);
|
||||
if RegSetValueExW(key, PCWSTR(name_w.as_ptr()), Some(0), REG_SZ, Some(bytes)).is_err() {
|
||||
Err(E_FAIL)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_reg_key(parent: HKEY, subkey: &str) -> Result<HKEY, HRESULT> {
|
||||
let subkey_w = to_wide(subkey);
|
||||
let mut hkey = HKEY::default();
|
||||
let result = RegCreateKeyExW(
|
||||
parent,
|
||||
PCWSTR(subkey_w.as_ptr()),
|
||||
Some(0),
|
||||
None,
|
||||
REG_OPTION_NON_VOLATILE,
|
||||
KEY_WRITE,
|
||||
None,
|
||||
&mut hkey,
|
||||
None,
|
||||
);
|
||||
if result.is_err() {
|
||||
Err(E_FAIL)
|
||||
} else {
|
||||
Ok(hkey)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn register_server_impl() -> Result<(), HRESULT> {
|
||||
let dll_path = get_dll_path().ok_or(E_FAIL)?;
|
||||
let clsid = clsid_string();
|
||||
|
||||
// CLSID key
|
||||
let clsid_key = create_reg_key(HKEY_CLASSES_ROOT, &format!("CLSID\\{}", clsid))?;
|
||||
set_reg_value(clsid_key, "", "Readest Thumbnail Provider")?;
|
||||
|
||||
// CRITICAL: DisableProcessIsolation = 1
|
||||
let disable_isolation_name = to_wide("DisableProcessIsolation");
|
||||
let value: u32 = 1;
|
||||
let _ = windows::Win32::System::Registry::RegSetValueExW(
|
||||
clsid_key,
|
||||
PCWSTR(disable_isolation_name.as_ptr()),
|
||||
Some(0),
|
||||
windows::Win32::System::Registry::REG_DWORD,
|
||||
Some(std::slice::from_raw_parts(
|
||||
&value as *const u32 as *const u8,
|
||||
4,
|
||||
)),
|
||||
);
|
||||
|
||||
let inproc_key = create_reg_key(clsid_key, "InprocServer32")?;
|
||||
set_reg_value(inproc_key, "", &dll_path)?;
|
||||
set_reg_value(inproc_key, "ThreadingModel", "Apartment")?;
|
||||
let _ = RegCloseKey(inproc_key);
|
||||
let _ = RegCloseKey(clsid_key);
|
||||
|
||||
// Register ShellEx thumbnail handler for each extension
|
||||
for ext in SUPPORTED_EXTENSIONS {
|
||||
let ext_shellex_path =
|
||||
format!("{}\\ShellEx\\{{e357fccd-a995-4576-b01f-234630154e96}}", ext);
|
||||
if let Ok(ext_shellex_key) = create_reg_key(HKEY_CLASSES_ROOT, &ext_shellex_path) {
|
||||
let _ = set_reg_value(ext_shellex_key, "", &clsid);
|
||||
let _ = RegCloseKey(ext_shellex_key);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn unregister_server_impl() -> Result<(), HRESULT> {
|
||||
let clsid = clsid_string();
|
||||
let clsid_path = to_wide(&format!("CLSID\\{}", clsid));
|
||||
let _ = RegDeleteTreeW(HKEY_CLASSES_ROOT, PCWSTR(clsid_path.as_ptr()));
|
||||
|
||||
for ext in SUPPORTED_EXTENSIONS {
|
||||
let ext_path = to_wide(&format!(
|
||||
"{}\\ShellEx\\{{e357fccd-a995-4576-b01f-234630154e96}}",
|
||||
ext
|
||||
));
|
||||
let _ = RegDeleteTreeW(HKEY_CLASSES_ROOT, PCWSTR(ext_path.as_ptr()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
/// Cover image extraction for various eBook formats
|
||||
///
|
||||
/// Supports: EPUB, MOBI/AZW3/KF8, FB2, CBZ/CBR, TXT
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::engine::general_purpose;
|
||||
use base64::Engine as _;
|
||||
use directories_next::ProjectDirs;
|
||||
use image::{imageops, DynamicImage, Rgba};
|
||||
use md5::Context;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Thumbnail cache directory (per-user)
|
||||
static CACHE_DIR: Lazy<Option<std::path::PathBuf>> = Lazy::new(|| {
|
||||
ProjectDirs::from("app", "Readest", "").map(|pd| {
|
||||
let dir = pd.cache_dir().join("thumbnails");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir
|
||||
})
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EPUB extraction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract cover image bytes from an EPUB file.
|
||||
pub fn extract_epub_cover_bytes<R: Read + Seek>(reader: R) -> Result<Vec<u8>> {
|
||||
let mut archive = ZipArchive::new(reader)?;
|
||||
|
||||
// Pass 1: Look for files with "cover" in the name
|
||||
let mut candidates: Vec<(usize, String, u64)> = Vec::new();
|
||||
for i in 0..archive.len() {
|
||||
let file = archive.by_index(i)?;
|
||||
let name = file.name().to_lowercase();
|
||||
let size = file.size();
|
||||
drop(file);
|
||||
|
||||
if is_image_extension(&name) && (name.contains("cover") || name.contains("front")) {
|
||||
candidates.push((i, name, size));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority: exact "cover" match first, then by size
|
||||
if !candidates.is_empty() {
|
||||
candidates.sort_by(|a, b| {
|
||||
let a_exact = a.1.contains("cover.") || a.1.ends_with("cover");
|
||||
let b_exact = b.1.contains("cover.") || b.1.ends_with("cover");
|
||||
match (a_exact, b_exact) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => b.2.cmp(&a.2),
|
||||
}
|
||||
});
|
||||
|
||||
let idx = candidates[0].0;
|
||||
let mut file = archive.by_index(idx)?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)?;
|
||||
return Ok(buf);
|
||||
}
|
||||
|
||||
// Pass 2: Parse container.xml to find OPF, then parse OPF for cover
|
||||
let container_xml = read_zip_file_to_string(&mut archive, "META-INF/container.xml");
|
||||
if let Ok(xml) = container_xml {
|
||||
if let Some(rootfile) = extract_attribute(&xml, "rootfile", "full-path") {
|
||||
let opf_content = read_zip_file_to_string(&mut archive, &rootfile);
|
||||
if let Ok(opf) = opf_content {
|
||||
if let Some(cover_id) = find_cover_id_in_opf(&opf) {
|
||||
if let Some(href) = find_href_by_id_in_opf(&opf, &cover_id) {
|
||||
let base = Path::new(&rootfile).parent().unwrap_or(Path::new(""));
|
||||
let cover_path = base.join(&href).to_string_lossy().replace('\\', "/");
|
||||
if let Ok(bytes) = read_zip_file_to_bytes(&mut archive, &cover_path) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(href) = find_first_image_in_manifest(&opf) {
|
||||
let base = Path::new(&rootfile).parent().unwrap_or(Path::new(""));
|
||||
let cover_path = base.join(&href).to_string_lossy().replace('\\', "/");
|
||||
if let Ok(bytes) = read_zip_file_to_bytes(&mut archive, &cover_path) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Just grab the largest image file
|
||||
let mut largest: Option<(usize, u64)> = None;
|
||||
for i in 0..archive.len() {
|
||||
let file = archive.by_index(i)?;
|
||||
let name = file.name().to_lowercase();
|
||||
let size = file.size();
|
||||
drop(file);
|
||||
|
||||
if is_image_extension(&name) && (largest.is_none() || size > largest.unwrap().1) {
|
||||
largest = Some((i, size));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((idx, _)) = largest {
|
||||
let mut file = archive.by_index(idx)?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)?;
|
||||
return Ok(buf);
|
||||
}
|
||||
|
||||
Err(anyhow!("No cover image found in EPUB"))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MOBI/AZW3/KF8 extraction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract cover image from MOBI/AZW3/KF8 files.
|
||||
pub fn extract_mobi_cover_bytes<R: Read + Seek>(mut reader: R) -> Result<Vec<u8>> {
|
||||
let mut header = [0u8; 78];
|
||||
reader.read_exact(&mut header)?;
|
||||
|
||||
if &header[60..68] != b"BOOKMOBI" {
|
||||
return Err(anyhow!("Not a valid MOBI file"));
|
||||
}
|
||||
|
||||
let num_records = u16::from_be_bytes([header[76], header[77]]) as usize;
|
||||
|
||||
let mut record_offsets: Vec<u32> = Vec::with_capacity(num_records);
|
||||
for _ in 0..num_records {
|
||||
let mut rec = [0u8; 8];
|
||||
reader.read_exact(&mut rec)?;
|
||||
record_offsets.push(u32::from_be_bytes([rec[0], rec[1], rec[2], rec[3]]));
|
||||
}
|
||||
|
||||
if record_offsets.is_empty() {
|
||||
return Err(anyhow!("No records in MOBI file"));
|
||||
}
|
||||
|
||||
reader.seek(SeekFrom::Start(record_offsets[0] as u64))?;
|
||||
let mut mobi_header = [0u8; 256];
|
||||
reader.read_exact(&mut mobi_header)?;
|
||||
|
||||
if &mobi_header[16..20] != b"MOBI" {
|
||||
return Err(anyhow!("Invalid MOBI header"));
|
||||
}
|
||||
|
||||
let header_length = u32::from_be_bytes([
|
||||
mobi_header[20],
|
||||
mobi_header[21],
|
||||
mobi_header[22],
|
||||
mobi_header[23],
|
||||
]) as usize;
|
||||
|
||||
let exth_flags = u32::from_be_bytes([
|
||||
mobi_header[128],
|
||||
mobi_header[129],
|
||||
mobi_header[130],
|
||||
mobi_header[131],
|
||||
]);
|
||||
if exth_flags & 0x40 == 0 {
|
||||
return Err(anyhow!("No EXTH header in MOBI file"));
|
||||
}
|
||||
|
||||
let exth_offset = record_offsets[0] as u64 + 16 + header_length as u64;
|
||||
reader.seek(SeekFrom::Start(exth_offset))?;
|
||||
|
||||
let mut exth_magic = [0u8; 4];
|
||||
reader.read_exact(&mut exth_magic)?;
|
||||
if &exth_magic != b"EXTH" {
|
||||
return Err(anyhow!("EXTH header not found"));
|
||||
}
|
||||
|
||||
let mut exth_len_bytes = [0u8; 4];
|
||||
reader.read_exact(&mut exth_len_bytes)?;
|
||||
|
||||
let mut exth_count_bytes = [0u8; 4];
|
||||
reader.read_exact(&mut exth_count_bytes)?;
|
||||
let exth_count = u32::from_be_bytes(exth_count_bytes) as usize;
|
||||
|
||||
let mut cover_offset: Option<u32> = None;
|
||||
let first_img_idx = u32::from_be_bytes([
|
||||
mobi_header[108],
|
||||
mobi_header[109],
|
||||
mobi_header[110],
|
||||
mobi_header[111],
|
||||
]);
|
||||
|
||||
for _ in 0..exth_count {
|
||||
let mut rec_header = [0u8; 8];
|
||||
if reader.read_exact(&mut rec_header).is_err() {
|
||||
break;
|
||||
}
|
||||
let rec_type =
|
||||
u32::from_be_bytes([rec_header[0], rec_header[1], rec_header[2], rec_header[3]]);
|
||||
let rec_len =
|
||||
u32::from_be_bytes([rec_header[4], rec_header[5], rec_header[6], rec_header[7]])
|
||||
as usize;
|
||||
|
||||
let data_len = rec_len.saturating_sub(8);
|
||||
let mut data = vec![0u8; data_len];
|
||||
if reader.read_exact(&mut data).is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 201 && data_len >= 4 {
|
||||
cover_offset = Some(u32::from_be_bytes([data[0], data[1], data[2], data[3]]));
|
||||
}
|
||||
}
|
||||
|
||||
let cover_record_idx = if let Some(offset) = cover_offset {
|
||||
first_img_idx + offset
|
||||
} else {
|
||||
first_img_idx
|
||||
};
|
||||
|
||||
if cover_record_idx as usize >= record_offsets.len() {
|
||||
return Err(anyhow!("Cover record index out of bounds"));
|
||||
}
|
||||
|
||||
let start = record_offsets[cover_record_idx as usize] as u64;
|
||||
let end = if (cover_record_idx as usize + 1) < record_offsets.len() {
|
||||
record_offsets[cover_record_idx as usize + 1] as u64
|
||||
} else {
|
||||
reader.seek(SeekFrom::End(0))?;
|
||||
reader.stream_position()?
|
||||
};
|
||||
|
||||
let len = (end - start) as usize;
|
||||
reader.seek(SeekFrom::Start(start))?;
|
||||
let mut cover_data = vec![0u8; len];
|
||||
reader.read_exact(&mut cover_data)?;
|
||||
|
||||
if cover_data.starts_with(&[0xFF, 0xD8, 0xFF])
|
||||
|| cover_data.starts_with(&[0x89, 0x50, 0x4E, 0x47])
|
||||
|| cover_data.starts_with(b"GIF")
|
||||
{
|
||||
return Ok(cover_data);
|
||||
}
|
||||
|
||||
Err(anyhow!("No valid cover image found in MOBI"))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CBZ extraction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract cover image from CBZ (comic book ZIP) file.
|
||||
pub fn extract_cbz_cover_bytes<R: Read + Seek>(reader: R) -> Result<Vec<u8>> {
|
||||
let mut archive = ZipArchive::new(reader)?;
|
||||
|
||||
let mut images: Vec<(usize, String)> = Vec::new();
|
||||
for i in 0..archive.len() {
|
||||
let file = archive.by_index(i)?;
|
||||
let name = file.name().to_string();
|
||||
drop(file);
|
||||
|
||||
if is_image_extension(&name.to_lowercase()) {
|
||||
images.push((i, name));
|
||||
}
|
||||
}
|
||||
|
||||
images.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
|
||||
if let Some((idx, _)) = images.first() {
|
||||
let mut file = archive.by_index(*idx)?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)?;
|
||||
return Ok(buf);
|
||||
}
|
||||
|
||||
Err(anyhow!("No images found in CBZ"))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FB2 extraction
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract cover image from FB2 (FictionBook) file.
|
||||
pub fn extract_fb2_cover_bytes<R: Read>(mut reader: R) -> Result<Vec<u8>> {
|
||||
let mut content = String::new();
|
||||
reader.read_to_string(&mut content)?;
|
||||
|
||||
let cover_id = if let Some(start) = content.find("<coverpage>") {
|
||||
let end = content[start..].find("</coverpage>").unwrap_or(500);
|
||||
let coverpage = &content[start..start + end];
|
||||
if let Some(href_pos) = coverpage.find("href=\"#") {
|
||||
let id_start = href_pos + 7;
|
||||
let id_end = coverpage[id_start..].find('"').unwrap_or(50);
|
||||
Some(coverpage[id_start..id_start + id_end].to_string())
|
||||
} else if let Some(href_pos) = coverpage.find("l:href=\"#") {
|
||||
let id_start = href_pos + 9;
|
||||
let id_end = coverpage[id_start..].find('"').unwrap_or(50);
|
||||
Some(coverpage[id_start..id_start + id_end].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let search_pattern = if let Some(ref id) = cover_id {
|
||||
format!("<binary id=\"{}\"", id)
|
||||
} else {
|
||||
"<binary".to_string()
|
||||
};
|
||||
|
||||
if let Some(pos) = content.find(&search_pattern) {
|
||||
if let Some(tag_end) = content[pos..].find('>') {
|
||||
let data_start = pos + tag_end + 1;
|
||||
if let Some(data_end) = content[data_start..].find("</binary>") {
|
||||
let b64_data = content[data_start..data_start + data_end].trim();
|
||||
let b64_clean: String = b64_data.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let bytes = general_purpose::STANDARD.decode(&b64_clean)?;
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cover_id.is_some() {
|
||||
if let Some(pos) = content.find("<binary") {
|
||||
if let Some(tag_end) = content[pos..].find('>') {
|
||||
let data_start = pos + tag_end + 1;
|
||||
if let Some(data_end) = content[data_start..].find("</binary>") {
|
||||
let b64_data = content[data_start..data_start + data_end].trim();
|
||||
let b64_clean: String =
|
||||
b64_data.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let bytes = general_purpose::STANDARD.decode(&b64_clean)?;
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No cover image found in FB2"))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TXT "cover" (placeholder)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a placeholder thumbnail for TXT files.
|
||||
pub fn extract_txt_cover_bytes<R: Read>(mut reader: R, size: u32) -> Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let _n = reader.read(&mut buf)?;
|
||||
|
||||
let mut img = image::RgbaImage::from_pixel(size, size, Rgba([245, 245, 245, 255]));
|
||||
|
||||
for x in 0..size {
|
||||
img.put_pixel(x, 0, Rgba([200, 200, 200, 255]));
|
||||
img.put_pixel(x, size - 1, Rgba([200, 200, 200, 255]));
|
||||
}
|
||||
for y in 0..size {
|
||||
img.put_pixel(0, y, Rgba([200, 200, 200, 255]));
|
||||
img.put_pixel(size - 1, y, Rgba([200, 200, 200, 255]));
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
DynamicImage::ImageRgba8(img).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Unified extraction by extension
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract cover image bytes based on file extension.
|
||||
pub fn extract_cover_bytes_by_ext(path: &Path, ext: &str) -> Result<Vec<u8>> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
match ext.to_lowercase().as_str() {
|
||||
"epub" => extract_epub_cover_bytes(file),
|
||||
"mobi" | "azw" | "azw3" | "kf8" | "prc" => extract_mobi_cover_bytes(file),
|
||||
"cbz" | "cbr" => extract_cbz_cover_bytes(file),
|
||||
"fb2" => extract_fb2_cover_bytes(file),
|
||||
"txt" => extract_txt_cover_bytes(file, 256),
|
||||
_ => Err(anyhow!("Unsupported format: {}", ext)),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Thumbnail creation with overlay
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create a thumbnail from cover image bytes with Readest icon overlay.
|
||||
pub fn create_thumbnail_with_overlay(cover_bytes: &[u8], requested_size: u32) -> Result<Vec<u8>> {
|
||||
let img = image::load_from_memory(cover_bytes)?;
|
||||
let thumbnail = img.thumbnail(requested_size, requested_size);
|
||||
|
||||
let overlay_img = load_overlay_icon();
|
||||
|
||||
let mut base = thumbnail.to_rgba8();
|
||||
let (base_w, base_h) = (base.width(), base.height());
|
||||
|
||||
if let Some(ov) = overlay_img {
|
||||
let overlay_size = (requested_size / 5).clamp(24, 48);
|
||||
let ov_resized = ov.resize(overlay_size, overlay_size, imageops::FilterType::Lanczos3);
|
||||
let ovb = ov_resized.to_rgba8();
|
||||
let (ov_w, ov_h) = (ovb.width(), ovb.height());
|
||||
|
||||
let x = base_w.saturating_sub(ov_w + 4);
|
||||
let y = base_h.saturating_sub(ov_h + 4);
|
||||
|
||||
for oy in 0..ov_h {
|
||||
for ox in 0..ov_w {
|
||||
let dst_x = x + ox;
|
||||
let dst_y = y + oy;
|
||||
|
||||
if dst_x < base_w && dst_y < base_h {
|
||||
let src_pixel = ovb.get_pixel(ox, oy);
|
||||
let alpha = src_pixel.0[3] as f32 / 255.0;
|
||||
|
||||
if alpha > 0.0 {
|
||||
let dst_pixel = base.get_pixel(dst_x, dst_y);
|
||||
let mut result = dst_pixel.0;
|
||||
|
||||
for c in 0..3 {
|
||||
let fg = src_pixel.0[c] as f32;
|
||||
let bg = result[c] as f32;
|
||||
result[c] = (fg * alpha + bg * (1.0 - alpha)) as u8;
|
||||
}
|
||||
result[3] = 255;
|
||||
|
||||
base.put_pixel(dst_x, dst_y, Rgba(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
DynamicImage::ImageRgba8(base).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Load the Readest overlay icon.
|
||||
fn load_overlay_icon() -> Option<DynamicImage> {
|
||||
// Try embedded icon
|
||||
let icon_bytes = include_bytes!("../../../public/icon.png");
|
||||
if let Ok(img) = image::load_from_memory(icon_bytes) {
|
||||
return Some(img);
|
||||
}
|
||||
|
||||
// Fallback: try loading from filesystem
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let candidates = [
|
||||
exe.parent().map(|p| p.join("icon.png")),
|
||||
exe.parent().map(|p| p.join("resources").join("icon.png")),
|
||||
exe.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("resources").join("icon.png")),
|
||||
];
|
||||
|
||||
for candidate in candidates.into_iter().flatten() {
|
||||
if candidate.exists() {
|
||||
if let Ok(bytes) = std::fs::read(&candidate) {
|
||||
if let Ok(img) = image::load_from_memory(&bytes) {
|
||||
return Some(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Caching
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a thumbnail with disk caching.
|
||||
pub fn cached_thumbnail_for_path(path: &Path, ext: &str, size: u32) -> Result<Vec<u8>> {
|
||||
// Compute cache key by hashing file parts for stability without loading entire file
|
||||
let mut hasher = Context::new();
|
||||
hasher.consume(ext.as_bytes());
|
||||
hasher.consume(&size.to_le_bytes());
|
||||
|
||||
let file = std::fs::File::open(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
let file_len = metadata.len();
|
||||
|
||||
// Read partial chunks like the TypeScript partialMD5 implementation
|
||||
const STEP: u64 = 1024;
|
||||
const SIZE: u64 = 1024;
|
||||
let mut file = file;
|
||||
|
||||
for i in -1i32..=10 {
|
||||
let pos = if i == -1 {
|
||||
256u64
|
||||
} else {
|
||||
STEP << (2 * i as u32)
|
||||
};
|
||||
let start = pos.min(file_len);
|
||||
let end = (start + SIZE).min(file_len);
|
||||
|
||||
if start >= file_len {
|
||||
break;
|
||||
}
|
||||
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
let mut buf = vec![0u8; (end - start) as usize];
|
||||
file.read_exact(&mut buf)?;
|
||||
hasher.consume(&buf);
|
||||
}
|
||||
|
||||
let digest = hasher.finalize();
|
||||
let key = format!("{:x}.png", digest);
|
||||
|
||||
if let Some(ref dir) = *CACHE_DIR {
|
||||
let cache_path = dir.join(&key);
|
||||
if cache_path.exists() {
|
||||
if let Ok(cached) = std::fs::read(&cache_path) {
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cover = extract_cover_bytes_by_ext(path, ext)?;
|
||||
let thumbnail = create_thumbnail_with_overlay(&cover, size)?;
|
||||
|
||||
if let Some(ref dir) = *CACHE_DIR {
|
||||
let cache_path = dir.join(&key);
|
||||
let _ = std::fs::write(&cache_path, &thumbnail);
|
||||
}
|
||||
|
||||
Ok(thumbnail)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helper functions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn is_image_extension(name: &str) -> bool {
|
||||
name.ends_with(".jpg")
|
||||
|| name.ends_with(".jpeg")
|
||||
|| name.ends_with(".png")
|
||||
|| name.ends_with(".gif")
|
||||
|| name.ends_with(".webp")
|
||||
|| name.ends_with(".bmp")
|
||||
}
|
||||
|
||||
fn read_zip_file_to_string<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
name: &str,
|
||||
) -> Result<String> {
|
||||
let mut file = archive.by_name(name)?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn read_zip_file_to_bytes<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
name: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut file = archive.by_name(name)?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn extract_attribute(xml: &str, tag: &str, attr: &str) -> Option<String> {
|
||||
let pattern = format!("<{}", tag);
|
||||
if let Some(tag_pos) = xml.find(&pattern) {
|
||||
let tag_end = xml[tag_pos..].find('>').unwrap_or(500) + tag_pos;
|
||||
let tag_content = &xml[tag_pos..tag_end];
|
||||
|
||||
let attr_pattern = format!("{}=\"", attr);
|
||||
if let Some(attr_pos) = tag_content.find(&attr_pattern) {
|
||||
let value_start = attr_pos + attr_pattern.len();
|
||||
if let Some(value_end) = tag_content[value_start..].find('"') {
|
||||
return Some(tag_content[value_start..value_start + value_end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_cover_id_in_opf(opf: &str) -> Option<String> {
|
||||
if let Some(pos) = opf.find("name=\"cover\"") {
|
||||
let window_start = pos.saturating_sub(50);
|
||||
let window_end = (pos + 100).min(opf.len());
|
||||
let window = &opf[window_start..window_end];
|
||||
|
||||
if let Some(content_pos) = window.find("content=\"") {
|
||||
let start = content_pos + 9;
|
||||
if let Some(end) = window[start..].find('"') {
|
||||
return Some(window[start..start + end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pos) = opf.find("properties=\"cover-image\"") {
|
||||
let window_start = pos.saturating_sub(200);
|
||||
let window_end = pos;
|
||||
let window = &opf[window_start..window_end];
|
||||
|
||||
if let Some(id_pos) = window.rfind("id=\"") {
|
||||
let start = id_pos + 4;
|
||||
if let Some(end) = window[start..].find('"') {
|
||||
return Some(window[start..start + end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn find_href_by_id_in_opf(opf: &str, id: &str) -> Option<String> {
|
||||
let pattern = format!("id=\"{}\"", id);
|
||||
if let Some(pos) = opf.find(&pattern) {
|
||||
let window_start = pos.saturating_sub(10);
|
||||
let window_end = (pos + 200).min(opf.len());
|
||||
let window = &opf[window_start..window_end];
|
||||
|
||||
if let Some(href_pos) = window.find("href=\"") {
|
||||
let start = href_pos + 6;
|
||||
if let Some(end) = window[start..].find('"') {
|
||||
return Some(window[start..start + end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_first_image_in_manifest(opf: &str) -> Option<String> {
|
||||
let manifest_start = opf.find("<manifest")?;
|
||||
let manifest_end = opf[manifest_start..]
|
||||
.find("</manifest>")
|
||||
.map(|e| manifest_start + e)?;
|
||||
let manifest = &opf[manifest_start..manifest_end];
|
||||
|
||||
for media_type in ["image/jpeg", "image/png", "image/gif", "image/webp"] {
|
||||
let pattern = format!("media-type=\"{}\"", media_type);
|
||||
if let Some(pos) = manifest.find(&pattern) {
|
||||
let window_start = pos.saturating_sub(200);
|
||||
let window = &manifest[window_start..pos];
|
||||
|
||||
if let Some(href_pos) = window.rfind("href=\"") {
|
||||
let start = href_pos + 6;
|
||||
if let Some(end) = window[start..].find('"') {
|
||||
return Some(window[start..start + end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Windows Thumbnail Provider for Readest
|
||||
//!
|
||||
//! This module provides Windows Explorer thumbnail support for eBook files.
|
||||
//! Thumbnails are only shown when Readest is set as the default application.
|
||||
//!
|
||||
//! Supported formats: EPUB, MOBI, AZW, AZW3, KF8, FB2, CBZ, CBR
|
||||
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod com_provider;
|
||||
mod extraction;
|
||||
|
||||
pub use extraction::*;
|
||||
@@ -27,6 +27,7 @@ module.exports = {
|
||||
'hi',
|
||||
'id',
|
||||
'vi',
|
||||
'ms',
|
||||
'ar',
|
||||
'th',
|
||||
'bo',
|
||||
|
||||
@@ -27,6 +27,7 @@ const nextConfig = {
|
||||
assetPrefix: '',
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['isows'],
|
||||
turbopack: {},
|
||||
transpilePackages: !isDev
|
||||
? [
|
||||
'i18next-browser-languagedetector',
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.93",
|
||||
"version": "0.9.94",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev --turbopack",
|
||||
"dev": "dotenv -e .env.tauri -- next dev",
|
||||
"build": "dotenv -e .env.tauri -- next build",
|
||||
"start": "dotenv -e .env.tauri -- next start",
|
||||
"dev-web": "dotenv -e .env.web -- next dev --turbopack",
|
||||
"dev-web": "dotenv -e .env.web -- next dev",
|
||||
"build-web": "dotenv -e .env.web -- next build",
|
||||
"start-web": "dotenv -e .env.web -- next start",
|
||||
"build-tauri": "dotenv -e .env.tauri -- next build",
|
||||
"i18n:extract": "i18next-scanner",
|
||||
"lint": "eslint .",
|
||||
"test": "dotenv -e .env -e .env.test.local vitest",
|
||||
"tauri": "tauri",
|
||||
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
|
||||
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc",
|
||||
"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",
|
||||
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
|
||||
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
||||
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
||||
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
|
||||
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
|
||||
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
|
||||
"setup-pdfjs": "pnpm prepare-public-vendor && pnpm copy-pdfjs",
|
||||
"setup-simplecc": "pnpm prepare-public-vendor && pnpm copy-simplecc",
|
||||
"setup-vendors": "pnpm setup-pdfjs && pnpm setup-simplecc",
|
||||
"build-win-x64": "dotenv -e .env.tauri.local -- tauri build --target i686-pc-windows-msvc --bundles nsis",
|
||||
"build-win-arm64": "dotenv -e .env.tauri.local -- tauri build --target aarch64-pc-windows-msvc --bundles nsis",
|
||||
"build-linux-x64": "dotenv -e .env.tauri.local -- tauri build --target x86_64-unknown-linux-gnu --bundles appimage",
|
||||
@@ -33,13 +37,17 @@
|
||||
"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 --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",
|
||||
"preview": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare preview --ip 0.0.0.0",
|
||||
"deploy": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare deploy",
|
||||
"upload": "pnpm patch-build-webpack && NEXT_PUBLIC_APP_PLATFORM=web opennextjs-cloudflare build && pnpm restore-build-original && opennextjs-cloudflare upload",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
|
||||
"patch-build-webpack": "if [ \"$(uname)\" = \"Darwin\" ]; then sed -i '' 's/next build\"/next build --webpack\"/' package.json; else sed -i 's/next build\"/next build --webpack\"/' package.json; fi",
|
||||
"restore-build-original": "if [ \"$(uname)\" = \"Darwin\" ]; then sed -i '' 's/next build --webpack\"/next build\"/' package.json; else sed -i 's/next build --webpack\"/next build\"/' package.json; fi",
|
||||
"update-metadata": "bash ./scripts/sync-release-notes.sh release-notes.json ../../data/metainfo/appdata.xml",
|
||||
"check:optional-chaining": "count=$(grep -rno '\\?\\.[a-zA-Z_$]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Optional chaining found in output!'; exit 1; else echo '✅ No optional chaining found.'; fi",
|
||||
"check:translations": "count=$(grep -rno '__STRING_NOT_TRANSLATED__' public/locales/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Untranslated strings found!'; exit 1; else echo '✅ All strings translated.'; fi",
|
||||
"check:all": "pnpm check:optional-chaining && pnpm check:translations",
|
||||
"check:lookbehind-regex": "count=$(grep -rnoE '\\(\\?<[!=]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Lookbehind regex found in output!'; exit 1; else echo '✅ No lookbehind regex found.'; fi",
|
||||
"check:all": "pnpm check:translations && pnpm check:lookbehind-regex",
|
||||
"build-check": "pnpm build && pnpm build-web && pnpm check:all"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -47,24 +55,24 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@opennextjs/cloudflare": "^1.11.0",
|
||||
"@opennextjs/cloudflare": "^1.13.1",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.4.0",
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.76.1",
|
||||
"@tauri-apps/api": "2.9.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-haptics": "^2.3.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.2",
|
||||
"@tauri-apps/plugin-log": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.1",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"@tauri-apps/plugin-shell": "~2.3.1",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.5",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.4",
|
||||
"@tauri-apps/plugin-haptics": "^2.3.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"@tauri-apps/plugin-log": "^2.7.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.2",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "~2.3.3",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@zip.js/zip.js": "^2.7.53",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
@@ -87,19 +95,20 @@
|
||||
"js-md5": "^0.8.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^15.0.12",
|
||||
"next": "15.5.6",
|
||||
"next": "16.0.3",
|
||||
"overlayscrollbars": "^2.11.4",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"posthog-js": "^1.246.0",
|
||||
"react": "19.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "19.0.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-window": "^1.8.11",
|
||||
"semver": "^7.7.1",
|
||||
"stripe": "^18.2.1",
|
||||
"styled-jsx": "^5.1.7",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.0.8",
|
||||
@@ -108,7 +117,7 @@
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^15.4.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.9.1",
|
||||
"@tauri-apps/cli": "2.9.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -121,6 +130,8 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -142,6 +153,6 @@
|
||||
"typescript": "^5.7.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.44.0"
|
||||
"wrangler": "^4.50.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ed533042-5626-4704-b5f2-fa3bbd1136ed
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "الوضع التلقائي",
|
||||
"Behavior": "السلوك",
|
||||
"Book": "الكتاب",
|
||||
"Book Cover": "غلاف الكتاب",
|
||||
"Bookmark": "علامة مرجعية",
|
||||
"Cancel": "إلغاء",
|
||||
"Chapter": "الفصل",
|
||||
@@ -193,7 +192,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "لم يتم العثور على ملفات مدعومة. الصيغ المدعومة: {{formats}}",
|
||||
"Drop to Import Books": "قم بالسحب والإسقاط لاستيراد الكتب",
|
||||
"Custom": "مخصص",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "العالم مسرح،\nوالناس فيه ممثلون؛\nلهم مخارج ومداخل،\nوالإنسان في حياته يلعب دورًا كثيرًا،\nوأفعاله تمر بسبع مراحل.\n\n— ويليام شكسبير",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "العالم مسرح،\nوالناس فيه ممثلون؛\nلهم مخارج ومداخل،\nوالإنسان في حياته يلعب دورًا كثيرًا،\nوأفعاله تمر بسبع مراحل.\n\n— ويليام شكسبير",
|
||||
"Custom Theme": "سمة مخصصة",
|
||||
"Theme Name": "اسم السمة",
|
||||
"Text Color": "لون النص",
|
||||
@@ -672,5 +671,75 @@
|
||||
"Update email": "تحديث البريد الإلكتروني",
|
||||
"Current email": "البريد الإلكتروني الحالي",
|
||||
"Update Email": "تحديث البريد الإلكتروني",
|
||||
"All": "الكل"
|
||||
"All": "الكل",
|
||||
"Unable to open book": "غير قادر على فتح الكتاب",
|
||||
"Punctuation": "علامات الترقيم",
|
||||
"Replace Quotation Marks": "استبدال علامات الاقتباس",
|
||||
"Enabled only in vertical layout.": "مفعل فقط في التخطيط الرأسي.",
|
||||
"No Conversion": "لا تحويل",
|
||||
"Simplified to Traditional": "من المبسّط إلى التقليدي",
|
||||
"Traditional to Simplified": "من التقليدي إلى المبسّط",
|
||||
"Simplified to Traditional (Taiwan)": "مبسّط → تقليدي (تايوان)",
|
||||
"Simplified to Traditional (Hong Kong)": "مبسّط → تقليدي (هونغ كونغ)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "مبسّط → تقليدي (تايوان • عبارات)",
|
||||
"Traditional (Taiwan) to Simplified": "تقليدي (تايوان) → مبسّط",
|
||||
"Traditional (Hong Kong) to Simplified": "تقليدي (هونغ كونغ) → مبسّط",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "تقليدي (تايوان • عبارات) → مبسّط",
|
||||
"Convert Simplified and Traditional Chinese": "تحويل المبسّط/التقليدي",
|
||||
"Convert Mode": "وضع التحويل",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "فشل في الحفظ التلقائي لغلاف الكتاب لشاشة القفل: {{error}}",
|
||||
"Download from Cloud": "تحميل من السحابة",
|
||||
"Upload to Cloud": "رفع إلى السحابة",
|
||||
"Clear Custom Fonts": "مسح الخطوط المخصصة",
|
||||
"Columns": "الأعمدة",
|
||||
"OPDS Catalogs": "كتالوجات OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "لا يتم دعم إضافة عناوين LAN في إصدار تطبيق الويب.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "كتالوج OPDS غير صالح. يرجى التحقق من عنوان URL.",
|
||||
"Browse and download books from online catalogs": "تصفح وتنزيل الكتب من الكتالوجات عبر الإنترنت",
|
||||
"My Catalogs": "كتالوجاتي",
|
||||
"Add Catalog": "إضافة كتالوج",
|
||||
"No catalogs yet": "لا توجد كتالوجات بعد",
|
||||
"Add your first OPDS catalog to start browsing books": "أضف كتالوج OPDS الأول لتبدأ في تصفح الكتب",
|
||||
"Add Your First Catalog": "أضف كتالوجك الأول",
|
||||
"Browse": "تصفح",
|
||||
"Popular Catalogs": "الكتالوجات الشائعة",
|
||||
"Add": "إضافة",
|
||||
"Add OPDS Catalog": "إضافة كتالوج OPDS",
|
||||
"Catalog Name": "اسم الكتالوج",
|
||||
"My Calibre Library": "مكتبة Calibre الخاصة بي",
|
||||
"OPDS URL": "رابط OPDS",
|
||||
"Username (optional)": "اسم المستخدم (اختياري)",
|
||||
"Password (optional)": "كلمة المرور (اختياري)",
|
||||
"Description (optional)": "الوصف (اختياري)",
|
||||
"A brief description of this catalog": "وصف موجز لهذا الكتالوج",
|
||||
"Validating...": "جارٍ التحقق...",
|
||||
"View All": "عرض الكل",
|
||||
"Forward": "إلى الأمام",
|
||||
"OPDS Catalog": "كتالوج OPDS",
|
||||
"Home": "الصفحة الرئيسية",
|
||||
"Library": "المكتبة",
|
||||
"{{count}} items_zero": "{{count}} عناصر",
|
||||
"{{count}} items_one": "{{count}} عنصر",
|
||||
"{{count}} items_two": "{{count}} عنصران",
|
||||
"{{count}} items_few": "{{count}} عناصر",
|
||||
"{{count}} items_many": "{{count}} عنصرًا",
|
||||
"{{count}} items_other": "{{count}} من العناصر",
|
||||
"Download completed": "اكتمل التنزيل",
|
||||
"Download failed": "فشل التنزيل",
|
||||
"Open Access": "الوصول المفتوح",
|
||||
"Borrow": "استعارة",
|
||||
"Buy": "شراء",
|
||||
"Subscribe": "الاشتراك",
|
||||
"Sample": "عينة",
|
||||
"Download": "تنزيل",
|
||||
"Open & Read": "فتح وقراءة",
|
||||
"Tags": "العلامات",
|
||||
"Tag": "علامة",
|
||||
"First": "الأول",
|
||||
"Previous": "السابق",
|
||||
"Next": "التالي",
|
||||
"Last": "الأخير",
|
||||
"Cannot Load Page": "تعذر تحميل الصفحة",
|
||||
"An error occurred": "حدث خطأ ما",
|
||||
"Online Library": "المكتبة عبر الإنترنت"
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
"Color": "রঙ",
|
||||
"Behavior": "আচরণ",
|
||||
"Reset {{settings}}": "{{settings}} রিসেট",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "সারা বিশ্বটাই একটা মঞ্চ,\nআর সব নর-নারী কেবল অভিনেতা;\nতাদের আছে প্রস্থান ও প্রবেশ,\nআর একজন মানুষ তার জীবনে অনেক চরিত্র পালন করে,\nতার অভিনয় সাত যুগের।\n\n— উইলিয়াম শেক্সপিয়র",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "সারা বিশ্বটাই একটা মঞ্চ,\nআর সব নর-নারী কেবল অভিনেতা;\nতাদের আছে প্রস্থান ও প্রবেশ,\nআর একজন মানুষ তার জীবনে অনেক চরিত্র পালন করে,\nতার অভিনয় সাত যুগের।\n\n— উইলিয়াম শেক্সপিয়র",
|
||||
"(from 'As You Like It', Act II)": "('অ্যাজ ইউ লাইক ইট', দ্বিতীয় অঙ্ক থেকে)",
|
||||
"Custom Theme": "কাস্টম থিম",
|
||||
"Theme Name": "থিমের নাম",
|
||||
@@ -302,7 +302,6 @@
|
||||
"Link Color": "লিঙ্ক রঙ",
|
||||
"Preview": "প্রিভিউ",
|
||||
"Font & Layout": "ফন্ট ও লেআউট",
|
||||
"Book Cover": "বইয়ের মলাট",
|
||||
"More Info": "আরো তথ্য",
|
||||
"Parallel Read": "সমান্তরাল পড়া",
|
||||
"Disable": "নিষ্ক্রিয়",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "ইমেল আপডেট করুন",
|
||||
"Current email": "বর্তমান ইমেল",
|
||||
"Update Email": "ইমেল আপডেট করুন",
|
||||
"All": "সব"
|
||||
"All": "সব",
|
||||
"Unable to open book": "বই খুলতে অক্ষম",
|
||||
"Punctuation": "বিরামচিহ্ন",
|
||||
"Replace Quotation Marks": "উদ্ধৃতি চিহ্ন প্রতিস্থাপন করুন",
|
||||
"Enabled only in vertical layout.": "শুধুমাত্র উল্লম্ব বিন্যাসে সক্রিয়।",
|
||||
"No Conversion": "রূপান্তর নয়",
|
||||
"Simplified to Traditional": "সরল → প্রথাগত",
|
||||
"Traditional to Simplified": "প্রথাগত → সরল",
|
||||
"Simplified to Traditional (Taiwan)": "সরল → প্রথাগত (তাইওয়ান)",
|
||||
"Simplified to Traditional (Hong Kong)": "সরল → প্রথাগত (হংকং)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "সরল → প্রথাগত (তাইওয়ান • বাক্য)",
|
||||
"Traditional (Taiwan) to Simplified": "প্রথাগত (তাইওয়ান) → সরল",
|
||||
"Traditional (Hong Kong) to Simplified": "প্রথাগত (হংকং) → সরল",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "প্রথাগত (তাইওয়ান • বাক্য) → সরল",
|
||||
"Convert Simplified and Traditional Chinese": "সরল/প্রথাগত রূপান্তর",
|
||||
"Convert Mode": "রূপান্তর মোড",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "লক স্ক্রিনের জন্য বইয়ের মলাট স্বয়ংক্রিয়ভাবে সংরক্ষণ করতে ব্যর্থ: {{error}}",
|
||||
"Download from Cloud": "ক্লাউড থেকে ডাউনলোড করুন",
|
||||
"Upload to Cloud": "ক্লাউডে আপলোড করুন",
|
||||
"Clear Custom Fonts": "কাস্টম ফন্টস মুছুন",
|
||||
"Columns": "কলামস",
|
||||
"OPDS Catalogs": "OPDS ক্যাটালগস",
|
||||
"Adding LAN addresses is not supported in the web app version.": "ওয়েব অ্যাপ সংস্করণে LAN ঠিকানা যোগ করা সমর্থিত নয়।",
|
||||
"Invalid OPDS catalog. Please check the URL.": "অবৈধ OPDS ক্যাটালগ। দয়া করে URL চেক করুন।",
|
||||
"Browse and download books from online catalogs": "অনলাইন ক্যাটালগ থেকে বই ব্রাউজ এবং ডাউনলোড করুন",
|
||||
"My Catalogs": "আমার ক্যাটালগস",
|
||||
"Add Catalog": "ক্যাটালগ যোগ করুন",
|
||||
"No catalogs yet": "এখনও কোন ক্যাটালগ নেই",
|
||||
"Add your first OPDS catalog to start browsing books": "আপনার প্রথম OPDS ক্যাটালগ যোগ করুন বই ব্রাউজ শুরু করতে",
|
||||
"Add Your First Catalog": "আপনার প্রথম ক্যাটালগ যোগ করুন",
|
||||
"Browse": "ব্রাউজ করুন",
|
||||
"Popular Catalogs": "জনপ্রিয় ক্যাটালগস",
|
||||
"Add": "যোগ করুন",
|
||||
"Add OPDS Catalog": "OPDS ক্যাটালগ যোগ করুন",
|
||||
"Catalog Name": "ক্যাটালগের নাম",
|
||||
"My Calibre Library": "আমার ক্যালিব্র লাইব্রেরি",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "ব্যবহারকারীর নাম (ঐচ্ছিক)",
|
||||
"Password (optional)": "পাসওয়ার্ড (ঐচ্ছিক)",
|
||||
"Description (optional)": "বর্ণনা (ঐচ্ছিক)",
|
||||
"A brief description of this catalog": "এই ক্যাটালগের সংক্ষিপ্ত বিবরণ",
|
||||
"Validating...": "যাচাই করা হচ্ছে...",
|
||||
"View All": "সব দেখুন",
|
||||
"Forward": "ফরোয়ার্ড",
|
||||
"OPDS Catalog": "OPDS ক্যাটালগ",
|
||||
"Home": "হোম",
|
||||
"Library": "লাইব্রেরি",
|
||||
"{{count}} items_one": "{{count}} আইটেম",
|
||||
"{{count}} items_other": "{{count}} আইটেম",
|
||||
"Download completed": "ডাউনলোড সম্পন্ন হয়েছে",
|
||||
"Download failed": "ডাউনলোড ব্যর্থ হয়েছে",
|
||||
"Open Access": "ওপেন অ্যাক্সেস",
|
||||
"Borrow": "ঋণ নিন",
|
||||
"Buy": "কিনুন",
|
||||
"Subscribe": "সাবস্ক্রাইব করুন",
|
||||
"Sample": "নমুনা",
|
||||
"Download": "ডাউনলোড",
|
||||
"Open & Read": "খুলুন ও পড়ুন",
|
||||
"Tags": "ট্যাগসমূহ",
|
||||
"Tag": "ট্যাগ",
|
||||
"First": "প্রথম",
|
||||
"Previous": "পূর্ববর্তী",
|
||||
"Next": "পরবর্তী",
|
||||
"Last": "শেষ",
|
||||
"Cannot Load Page": "পৃষ্ঠা লোড করা যায়নি",
|
||||
"An error occurred": "একটি ত্রুটি ঘটেছে",
|
||||
"Online Library": "অনলাইন লাইব্রেরি"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "རང་འགུལ་བརྗོད་བྱ།",
|
||||
"Behavior": "བྱ་སྤྱོད།",
|
||||
"Book": "དཔེ་དེབ།",
|
||||
"Book Cover": "དཔེ་དེབ་ཀྱི་སྟོང་ངོས།",
|
||||
"Bookmark": "དཔེ་རྟགས།",
|
||||
"Cancel": "འདོར་བ།",
|
||||
"Chapter": "ལེའུ།",
|
||||
@@ -487,7 +486,7 @@
|
||||
"another device": "གཞན་ཡིག་སྣེ།",
|
||||
"Local Progress": "ཀུན་འབྱུང་ཁུངས།",
|
||||
"Remote Progress": "བརྒྱབ་འབྱུང་ཁུངས།",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "འབྱུང་ཁུངས་འདེམས་པ་བྱས་ནས་བརྗེ་བ།",
|
||||
"Page {{page}} of {{total}} ({{percentage}}%)": "ཤོག་ {{page}} དང་ {{total}} ({{percentage}}%)",
|
||||
"Current position": "ད་ལྟའི་གནས་ས།",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "ཕྲན་བུ་ཤོག་ངོས་ {{page}} / {{total}} ({{percentage}}%)",
|
||||
@@ -652,5 +651,70 @@
|
||||
"Update email": "ཡིག་འཕྲིན་གསར་བསྒྱུར།",
|
||||
"Current email": "མིག་སྔའི་ཡིག་འཕྲིན།",
|
||||
"Update Email": "ཡིག་འཕྲིན་གསར་བསྒྱུར།",
|
||||
"All": "ཆེན་མོ།"
|
||||
"All": "ཆེན་མོ།",
|
||||
"Unable to open book": "དེབ་ཀྱི་སྤྱོད་བྱས་མ་ཐུབ།",
|
||||
"Punctuation": "ཚིག་གྲུབ།",
|
||||
"Replace Quotation Marks": "འདྲེན་འབྲེལ་མཐུད་ཐིག་བརྗེ་བ།",
|
||||
"Enabled only in vertical layout.": "གཡས་སྒྲིག་ནང་མིང་བརྗེ་བ།",
|
||||
"No Conversion": "བསྒྱུར་བཤེར་མེད་",
|
||||
"Simplified to Traditional": "སྟོང་བཟོས → སྲོལ་རྒྱུན",
|
||||
"Traditional to Simplified": "སྲོལ་རྒྱུན → སྟོང་བཟོས",
|
||||
"Simplified to Traditional (Taiwan)": "སྟོང་བཟོས → སྲོལ་རྒྱུན (ཏའི་ཝན)",
|
||||
"Simplified to Traditional (Hong Kong)": "སྟོང་བཟོས → སྲོལ་རྒྱུན (ཧོང་ཀོང)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "སྟོང་བཟོས → སྲོལ་རྒྱུན (ཏའི་ཝན • ཚིག་ཕྲེང)",
|
||||
"Traditional (Taiwan) to Simplified": "སྲོལ་རྒྱུན (ཏའི་ཝན) → སྟོང་བཟོས",
|
||||
"Traditional (Hong Kong) to Simplified": "སྲོལ་རྒྱུན (ཧོང་ཀོང) → སྟོང་བཟོས",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "སྲོལ་རྒྱུན (ཏའི་ཝན • ཚིག་ཕྲེང) → སྟོང་བཟོས",
|
||||
"Convert Simplified and Traditional Chinese": "རྒྱ་ཡིག སྟོང་བཟོས/སྲོལ་རྒྱུན བསྒྱུར་བ",
|
||||
"Convert Mode": "བསྒྱུར་བའི་རྣམ་པ",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "སྒྲོམ་སྒོར་དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།: {{error}}",
|
||||
"Download from Cloud": "སྤྲིན་ནས་ཕབ་སྟེ་འབེབས་",
|
||||
"Upload to Cloud": "སྤྲིན་ལ་ཡར་སྤེལ་",
|
||||
"Clear Custom Fonts": "རང་བཞིན་ཡིག་གཟུགས་སུབ་པ།",
|
||||
"Columns": "མཚན་ཉིད།",
|
||||
"OPDS Catalogs": "OPDS གནས་ཚུལ།",
|
||||
"Adding LAN addresses is not supported in the web app version.": "གནས་ཚུལ་དང་ལུས་སྐོར་གྱི་གྲོང་ཁྱེར་གྱི་གློག་འཕྲིན་ལས་མཐུན་པ་མེད།",
|
||||
"Invalid OPDS catalog. Please check the URL.": "མི་འབྱུང་བའི OPDS གནས་ཚུལ། ཨེ་ཨོ་ཨེར་ལུས་སྐོར་བཟོ་བྱས།",
|
||||
"Browse and download books from online catalogs": "དེབ་གནས་བཅས་པའི་གྲོང་ཁྱེར་ལས་དེབ་འདེམས་དང་ཕབ་སྟེ་འབེབས།",
|
||||
"My Catalogs": "ངའི་དཀར་ཆག",
|
||||
"Add Catalog": "དཀར་ཆག་ཁ་སྣོན་",
|
||||
"No catalogs yet": "དཀར་ཆག་མེད་པས།",
|
||||
"Add your first OPDS catalog to start browsing books": "དེབ་ལྟ་བཤེར་འགོ་བཙུགས་ནས་ དང་པོའི་ OPDS དཀར་ཆག་ཁ་སྣོན་གནང་།",
|
||||
"Add Your First Catalog": "དཀར་ཆག་དང་པོ་ཁ་སྣོན་",
|
||||
"Browse": "ལྟ་བཤེར་",
|
||||
"Popular Catalogs": "མི་མང་མཐོང་བའི་དཀར་ཆག",
|
||||
"Add": "ཁ་སྣོན་",
|
||||
"Add OPDS Catalog": "OPDS དཀར་ཆག་ཁ་སྣོན་",
|
||||
"Catalog Name": "དཀར་ཆག་མིང་",
|
||||
"My Calibre Library": "ངའི Calibre དེབ་མཛོད་",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "མིང་སྒྲོམ་ (འདེམས་རུང་)",
|
||||
"Password (optional)": "གསང་ཚིག (འདེམས་རུང་)",
|
||||
"Description (optional)": "འགྲེལ་བཤད་ (འདེམས་རུང་)",
|
||||
"A brief description of this catalog": "དཀར་ཆག་འདིའི་གསལ་བཤད་ཐུང་ཐུང་",
|
||||
"Validating...": "བདེན་སྦྱོར་བཞིན་...",
|
||||
"View All": "ཡོངས་ལྟ་བ་",
|
||||
"Forward": "མདུན་དུ་",
|
||||
"OPDS Catalog": "OPDS དཀར་ཆག",
|
||||
"Home": "གཙོ་ངོས་",
|
||||
"Library": "དེབ་མཛོད་",
|
||||
"{{count}} items_other": "{{count}} རྣམ་གྲངས་",
|
||||
"Download completed": "ཕབ་ལེན་རྫོགས་སོང་།",
|
||||
"Download failed": "ཕབ་ལེན་ཕམ་པ་",
|
||||
"Open Access": "ཁ་ཕྱབ་ལྟ་བཤེར་",
|
||||
"Borrow": "ལེན་པ་",
|
||||
"Buy": "ཉོ་",
|
||||
"Subscribe": "མཁོ་མངགས་",
|
||||
"Sample": "དཔེ་དབང་",
|
||||
"Download": "ཕབ་ལེན་",
|
||||
"Open & Read": "ཁ་ཕྱེས་ནས་ཀློག་",
|
||||
"Tags": "མཚོན་འགྲེལ་",
|
||||
"Tag": "མཚོན་འགྲེལ་",
|
||||
"First": "དང་པོ།",
|
||||
"Previous": "སྔོན་མ།",
|
||||
"Next": "རྗེས་མ།",
|
||||
"Last": "མཐའ་མ།",
|
||||
"Cannot Load Page": "ཤོག་ངོས་འགུལ་སྐྱོང་བྱེད་ཐུབ་མེད།",
|
||||
"An error occurred": "ནོར་འཁྲུལ་ཞིག་བྱུང་སོང་།",
|
||||
"Online Library": "དྲ་རྒྱུན་དེབ་མཛོད།"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Automatischer Modus",
|
||||
"Behavior": "Verhalten",
|
||||
"Book": "Buch",
|
||||
"Book Cover": "Buchcover",
|
||||
"Bookmark": "Lesezeichen",
|
||||
"Cancel": "Abbrechen",
|
||||
"Chapter": "Kapitel",
|
||||
@@ -193,7 +192,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Keine unterstützten Dateien gefunden. Unterstützte Formate: {{formats}}",
|
||||
"Drop to Import Books": "Zum Importieren von Büchern ablegen",
|
||||
"Custom": "Eigen",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Die ganze Welt ist eine Bühne,\nUnd alle Männer und Frauen sind bloß Spieler;\nSie haben ihre Abgänge und ihre Auftritte,\nUnd ein Mann spielt in seinem Leben viele Rollen,\nSeine Taten sind sieben Zeitalter.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Die ganze Welt ist eine Bühne,\nUnd alle Männer und Frauen sind bloß Spieler;\nSie haben ihre Abgänge und ihre Auftritte,\nUnd ein Mann spielt in seinem Leben viele Rollen,\nSeine Taten sind sieben Zeitalter.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Benutzerdefiniertes Thema",
|
||||
"Theme Name": "Themenname",
|
||||
"Text Color": "Textfarbe",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "E-Mail aktualisieren",
|
||||
"Current email": "Aktuelle E-Mail",
|
||||
"Update Email": "E-Mail aktualisieren",
|
||||
"All": "Alle"
|
||||
"All": "Alle",
|
||||
"Unable to open book": "Buch kann nicht geöffnet werden",
|
||||
"Punctuation": "Interpunktion",
|
||||
"Replace Quotation Marks": "Anführungszeichen ersetzen",
|
||||
"Enabled only in vertical layout.": "Nur im vertikalen Layout aktiviert.",
|
||||
"No Conversion": "Keine Konvertierung",
|
||||
"Simplified to Traditional": "Vereinfacht → Traditionell",
|
||||
"Traditional to Simplified": "Traditionell → Vereinfacht",
|
||||
"Simplified to Traditional (Taiwan)": "Vereinfacht → Traditionell (Taiwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Vereinfacht → Traditionell (Hongkong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Vereinfacht → Traditionell (Taiwan • Phrasen)",
|
||||
"Traditional (Taiwan) to Simplified": "Traditionell (Taiwan) → Vereinfacht",
|
||||
"Traditional (Hong Kong) to Simplified": "Traditionell (Hongkong) → Vereinfacht",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Traditionell (Taiwan • Phrasen) → Vereinfacht",
|
||||
"Convert Simplified and Traditional Chinese": "Vereinfach./Traditionell konvertieren",
|
||||
"Convert Mode": "Konvertierungsmodus",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Automatisches Speichern des Buchcovers für den Sperrbildschirm fehlgeschlagen: {{error}}",
|
||||
"Download from Cloud": "Aus der Cloud herunterladen",
|
||||
"Upload to Cloud": "In die Cloud hochladen",
|
||||
"Clear Custom Fonts": "Benutzerdefinierte Schriftarten löschen",
|
||||
"Columns": "Spalten",
|
||||
"OPDS Catalogs": "OPDS-Kataloge",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Das Hinzufügen von LAN-Adressen wird in der Web-App nicht unterstützt.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Ungültiger OPDS-Katalog. Bitte überprüfe die URL.",
|
||||
"Browse and download books from online catalogs": "Bücher aus Online-Katalogen durchsuchen und herunterladen",
|
||||
"My Catalogs": "Meine Kataloge",
|
||||
"Add Catalog": "Katalog hinzufügen",
|
||||
"No catalogs yet": "Noch keine Kataloge",
|
||||
"Add your first OPDS catalog to start browsing books": "Füge deinen ersten OPDS-Katalog hinzu, um Bücher zu durchsuchen.",
|
||||
"Add Your First Catalog": "Ersten Katalog hinzufügen",
|
||||
"Browse": "Durchsuchen",
|
||||
"Popular Catalogs": "Beliebte Kataloge",
|
||||
"Add": "Hinzufügen",
|
||||
"Add OPDS Catalog": "OPDS-Katalog hinzufügen",
|
||||
"Catalog Name": "Katalogname",
|
||||
"My Calibre Library": "Meine Calibre-Bibliothek",
|
||||
"OPDS URL": "OPDS-URL",
|
||||
"Username (optional)": "Benutzername (optional)",
|
||||
"Password (optional)": "Passwort (optional)",
|
||||
"Description (optional)": "Beschreibung (optional)",
|
||||
"A brief description of this catalog": "Kurzbeschreibung dieses Katalogs",
|
||||
"Validating...": "Wird überprüft...",
|
||||
"View All": "Alle anzeigen",
|
||||
"Forward": "Weiter",
|
||||
"OPDS Catalog": "OPDS-Katalog",
|
||||
"Home": "Start",
|
||||
"Library": "Bibliothek",
|
||||
"{{count}} items_one": "{{count}} Element",
|
||||
"{{count}} items_other": "{{count}} Elemente",
|
||||
"Download completed": "Download abgeschlossen",
|
||||
"Download failed": "Download fehlgeschlagen",
|
||||
"Open Access": "Offener Zugriff",
|
||||
"Borrow": "Ausleihen",
|
||||
"Buy": "Kaufen",
|
||||
"Subscribe": "Abonnieren",
|
||||
"Sample": "Leseprobe",
|
||||
"Download": "Herunterladen",
|
||||
"Open & Read": "Öffnen & Lesen",
|
||||
"Tags": "Tags",
|
||||
"Tag": "Tag",
|
||||
"First": "Erste",
|
||||
"Previous": "Vorherige",
|
||||
"Next": "Nächste",
|
||||
"Last": "Letzte",
|
||||
"Cannot Load Page": "Seite kann nicht geladen werden",
|
||||
"An error occurred": "Ein Fehler ist aufgetreten",
|
||||
"Online Library": "Online-Bibliothek"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Αυτόματη λειτουργία",
|
||||
"Behavior": "Συμπεριφορά",
|
||||
"Book": "Βιβλίο",
|
||||
"Book Cover": "Εξώφυλλο βιβλίου",
|
||||
"Bookmark": "Σελιδοδείκτης",
|
||||
"Cancel": "Ακύρωση",
|
||||
"Chapter": "Κεφάλαιο",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Δεν βρέθηκαν υποστηριζόμενα αρχεία. Υποστηριζόμενες μορφές: {{formats}}",
|
||||
"Drop to Import Books": "Ρίξτε για εισαγωγή βιβλίων",
|
||||
"Custom": "Προσαρμοσμένο",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Όλος ο κόσμος είναι σκηνή,\nΚαι όλοι οι άνδρες και οι γυναίκες απλώς ηθοποιοί·\nΈχουν τις εξόδους και τις εισόδους τους,\nΚαι ένας άνθρωπος στον χρόνο του παίζει πολλούς ρόλους,\nΟι πράξεις του υπάρχουν σε επτά ηλικίες.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Όλος ο κόσμος είναι σκηνή,\nΚαι όλοι οι άνδρες και οι γυναίκες απλώς ηθοποιοί·\nΈχουν τις εξόδους και τις εισόδους τους,\nΚαι ένας άνθρωπος στον χρόνο του παίζει πολλούς ρόλους,\nΟι πράξεις του υπάρχουν σε επτά ηλικίες.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Προσαρμοσμένο θέμα",
|
||||
"Theme Name": "Όνομα θέματος",
|
||||
"Text Color": "Χρώμα κειμένου",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "Ενημέρωση email",
|
||||
"Current email": "Τρέχον email",
|
||||
"Update Email": "Ενημέρωση email",
|
||||
"All": "Όλα"
|
||||
"All": "Όλα",
|
||||
"Unable to open book": "Αδύνατη η άνοιγμα του βιβλίου",
|
||||
"Punctuation": "Στίξη",
|
||||
"Replace Quotation Marks": "Αντικατάσταση εισαγωγικών",
|
||||
"Enabled only in vertical layout.": "Ενεργοποιημένο μόνο σε κάθετη διάταξη.",
|
||||
"No Conversion": "Χωρίς μετατροπή",
|
||||
"Simplified to Traditional": "Απλοπ. → Παραδ.",
|
||||
"Traditional to Simplified": "Παραδ. → Απλοπ.",
|
||||
"Simplified to Traditional (Taiwan)": "Απλοπ. → Παραδ. (Ταϊβάν)",
|
||||
"Simplified to Traditional (Hong Kong)": "Απλοπ. → Παραδ. (Χονγκ Κονγκ)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Απλοπ. → Παραδ. (Ταϊβάν • φράσεις)",
|
||||
"Traditional (Taiwan) to Simplified": "Παραδ. (Ταϊβάν) → Απλοπ.",
|
||||
"Traditional (Hong Kong) to Simplified": "Παραδ. (Χονγκ Κονγκ) → Απλοπ.",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Παραδ. (Ταϊβάν • φράσεις) → Απλοπ.",
|
||||
"Convert Simplified and Traditional Chinese": "Μετατροπή Απλοπ./Παραδ. Κινέζικων",
|
||||
"Convert Mode": "Λειτουργία μετατροπής",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Αποτυχία αυτόματης αποθήκευσης εξωφύλλου βιβλίου για την οθόνη κλειδώματος: {{error}}",
|
||||
"Download from Cloud": "Λήψη από το Cloud",
|
||||
"Upload to Cloud": "Ανέβασμα στο Cloud",
|
||||
"Clear Custom Fonts": "Καθαρισμός προσαρμοσμένων γραμματοσειρών",
|
||||
"Columns": "Στήλες",
|
||||
"OPDS Catalogs": "Κατάλογοι OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Η προσθήκη διευθύνσεων LAN δεν υποστηρίζεται στην έκδοση web της εφαρμογής.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Μη έγκυρος κατάλογος OPDS. Ελέγξτε το URL.",
|
||||
"Browse and download books from online catalogs": "Περιηγηθείτε και κατεβάστε βιβλία από online καταλόγους",
|
||||
"My Catalogs": "Οι Κατάλογοί μου",
|
||||
"Add Catalog": "Προσθήκη Καταλόγου",
|
||||
"No catalogs yet": "Δεν υπάρχουν ακόμα κατάλογοι",
|
||||
"Add your first OPDS catalog to start browsing books": "Προσθέστε τον πρώτο σας κατάλογο OPDS για να αρχίσετε την περιήγηση.",
|
||||
"Add Your First Catalog": "Προσθήκη Πρώτου Καταλόγου",
|
||||
"Browse": "Περιήγηση",
|
||||
"Popular Catalogs": "Δημοφιλείς Κατάλογοι",
|
||||
"Add": "Προσθήκη",
|
||||
"Add OPDS Catalog": "Προσθήκη Καταλόγου OPDS",
|
||||
"Catalog Name": "Όνομα Καταλόγου",
|
||||
"My Calibre Library": "Η Βιβλιοθήκη μου στο Calibre",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "Όνομα χρήστη (προαιρετικό)",
|
||||
"Password (optional)": "Κωδικός πρόσβασης (προαιρετικό)",
|
||||
"Description (optional)": "Περιγραφή (προαιρετική)",
|
||||
"A brief description of this catalog": "Μια σύντομη περιγραφή του καταλόγου",
|
||||
"Validating...": "Γίνεται έλεγχος...",
|
||||
"View All": "Προβολή όλων",
|
||||
"Forward": "Μπροστά",
|
||||
"OPDS Catalog": "Κατάλογος OPDS",
|
||||
"Home": "Αρχική",
|
||||
"Library": "Βιβλιοθήκη",
|
||||
"{{count}} items_one": "{{count}} στοιχείο",
|
||||
"{{count}} items_other": "{{count}} στοιχεία",
|
||||
"Download completed": "Η λήψη ολοκληρώθηκε",
|
||||
"Download failed": "Η λήψη απέτυχε",
|
||||
"Open Access": "Ανοιχτή πρόσβαση",
|
||||
"Borrow": "Δανεισμός",
|
||||
"Buy": "Αγορά",
|
||||
"Subscribe": "Συνδρομή",
|
||||
"Sample": "Δείγμα",
|
||||
"Download": "Λήψη",
|
||||
"Open & Read": "Άνοιγμα & Ανάγνωση",
|
||||
"Tags": "Ετικέτες",
|
||||
"Tag": "Ετικέτα",
|
||||
"First": "Πρώτο",
|
||||
"Previous": "Προηγούμενο",
|
||||
"Next": "Επόμενο",
|
||||
"Last": "Τελευταίο",
|
||||
"Cannot Load Page": "Δεν είναι δυνατή η φόρτωση της σελίδας",
|
||||
"An error occurred": "Προέκυψε σφάλμα",
|
||||
"Online Library": "Online Βιβλιοθήκη"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Modo automático",
|
||||
"Behavior": "Comportamiento",
|
||||
"Book": "Libro",
|
||||
"Book Cover": "Portada del libro",
|
||||
"Bookmark": "Marcador",
|
||||
"Cancel": "Cancelar",
|
||||
"Chapter": "Capítulo",
|
||||
@@ -221,7 +220,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "No se encontraron archivos compatibles. Formatos compatibles: {{formats}}",
|
||||
"Drop to Import Books": "Soltar para importar libros",
|
||||
"Custom": "Personalizado",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo el mundo es un escenario,\nY todos los hombres y mujeres son meros actores;\nTienen sus salidas y sus entradas,\nY un hombre en su tiempo interpreta muchos papeles,\nSus actos son siete edades.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo el mundo es un escenario,\nY todos los hombres y mujeres son meros actores;\nTienen sus salidas y sus entradas,\nY un hombre en su tiempo interpreta muchos papeles,\nSus actos son siete edades.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Tema personalizado",
|
||||
"Theme Name": "Nombre del tema",
|
||||
"Text Color": "Color del texto",
|
||||
@@ -660,5 +659,72 @@
|
||||
"Update email": "Actualizar correo electrónico",
|
||||
"Current email": "Correo electrónico actual",
|
||||
"Update Email": "Actualizar correo electrónico",
|
||||
"All": "Todos"
|
||||
"All": "Todos",
|
||||
"Unable to open book": "No se puede abrir el libro",
|
||||
"Punctuation": "Signos de puntuación",
|
||||
"Replace Quotation Marks": "Reemplazar comillas",
|
||||
"Enabled only in vertical layout.": "Habilitado solo en diseño vertical.",
|
||||
"No Conversion": "Sin conversión",
|
||||
"Simplified to Traditional": "Simpl. → Trad.",
|
||||
"Traditional to Simplified": "Trad. → Simpl.",
|
||||
"Simplified to Traditional (Taiwan)": "Simpl. → Trad. (Taiwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Simpl. → Trad. (Hong Kong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Simpl. → Trad. (Taiwan • frases)",
|
||||
"Traditional (Taiwan) to Simplified": "Trad. (Taiwan) → Simpl.",
|
||||
"Traditional (Hong Kong) to Simplified": "Trad. (Hong Kong) → Simpl.",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Trad. (Taiwan • frases) → Simpl.",
|
||||
"Convert Simplified and Traditional Chinese": "Convertir chino Simpl./Trad.",
|
||||
"Convert Mode": "Modo de conversión",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Error al guardar automáticamente la portada del libro para la pantalla de bloqueo: {{error}}",
|
||||
"Download from Cloud": "Descargar desde la nube",
|
||||
"Upload to Cloud": "Subir a la nube",
|
||||
"Clear Custom Fonts": "Limpiar fuentes personalizadas",
|
||||
"Columns": "Columnas",
|
||||
"OPDS Catalogs": "Catálogos OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "La versión web no permite añadir direcciones LAN.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Catálogo OPDS no válido. Por favor, comprueba la URL.",
|
||||
"Browse and download books from online catalogs": "Explora y descarga libros de catálogos en línea",
|
||||
"My Catalogs": "Mis catálogos",
|
||||
"Add Catalog": "Añadir catálogo",
|
||||
"No catalogs yet": "Aún no hay catálogos",
|
||||
"Add your first OPDS catalog to start browsing books": "Añade tu primer catálogo OPDS para empezar a explorar libros.",
|
||||
"Add Your First Catalog": "Añadir primer catálogo",
|
||||
"Browse": "Explorar",
|
||||
"Popular Catalogs": "Catálogos populares",
|
||||
"Add": "Añadir",
|
||||
"Add OPDS Catalog": "Añadir catálogo OPDS",
|
||||
"Catalog Name": "Nombre del catálogo",
|
||||
"My Calibre Library": "Mi biblioteca Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Usuario (opcional)",
|
||||
"Password (optional)": "Contraseña (opcional)",
|
||||
"Description (optional)": "Descripción (opcional)",
|
||||
"A brief description of this catalog": "Una breve descripción de este catálogo",
|
||||
"Validating...": "Validando...",
|
||||
"View All": "Ver todo",
|
||||
"Forward": "Adelante",
|
||||
"OPDS Catalog": "Catálogo OPDS",
|
||||
"Home": "Inicio",
|
||||
"Library": "Biblioteca",
|
||||
"{{count}} items_one": "{{count}} elemento",
|
||||
"{{count}} items_many": "{{count}} elementos",
|
||||
"{{count}} items_other": "{{count}} elementos",
|
||||
"Download completed": "Descarga completa",
|
||||
"Download failed": "Error en la descarga",
|
||||
"Open Access": "Acceso abierto",
|
||||
"Borrow": "Prestar",
|
||||
"Buy": "Comprar",
|
||||
"Subscribe": "Suscribirse",
|
||||
"Sample": "Muestra",
|
||||
"Download": "Descargar",
|
||||
"Open & Read": "Abrir y leer",
|
||||
"Tags": "Etiquetas",
|
||||
"Tag": "Etiqueta",
|
||||
"First": "Primero",
|
||||
"Previous": "Anterior",
|
||||
"Next": "Siguiente",
|
||||
"Last": "Último",
|
||||
"Cannot Load Page": "No se puede cargar la página",
|
||||
"An error occurred": "Ocurrió un error",
|
||||
"Online Library": "Biblioteca en línea"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "حالت خودکار",
|
||||
"Behavior": "رفتار",
|
||||
"Book": "کتاب",
|
||||
"Book Cover": "جلد کتاب",
|
||||
"Bookmark": "نشانک",
|
||||
"Cancel": "لغو",
|
||||
"Chapter": "فصل",
|
||||
@@ -193,7 +192,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "فایل پشتیبانیشدهای یافت نشد. فرمتهای قابل پشتیبانی: {{formats}}",
|
||||
"Drop to Import Books": "برای وارد کردن کتابها، فایل را رها کنید",
|
||||
"Custom": "سفارشی",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "تمام دنیا صحنهای است،\nو همهی مردان و زنان تنها بازیگرانی بیش نیستند؛\nآنها ورود و خروج خود را دارند،\nو هر آدمی در طول زمان نقشهای بسیاری ایفا میکند،\nاعمال او هفت دورهاند.\n\n— ویلیام شکسپیر",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "تمام دنیا صحنهای است،\nو همهی مردان و زنان تنها بازیگرانی بیش نیستند؛\nآنها ورود و خروج خود را دارند،\nو هر آدمی در طول زمان نقشهای بسیاری ایفا میکند،\nاعمال او هفت دورهاند.\n\n— ویلیام شکسپیر",
|
||||
"Custom Theme": "تم سفارشی",
|
||||
"Theme Name": "نام تم",
|
||||
"Text Color": "رنگ متن",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "بهروزرسانی ایمیل",
|
||||
"Current email": "ایمیل فعلی",
|
||||
"Update Email": "بهروزرسانی ایمیل",
|
||||
"All": "همه"
|
||||
"All": "همه",
|
||||
"Unable to open book": "قادر به باز کردن کتاب نیست",
|
||||
"Punctuation": "علامت نگارشی",
|
||||
"Replace Quotation Marks": "جایگزینی علامت نقل قول",
|
||||
"Enabled only in vertical layout.": "فقط در طرح عمودی فعال است.",
|
||||
"No Conversion": "بدون تبدیل",
|
||||
"Simplified to Traditional": "ساده → سنتی",
|
||||
"Traditional to Simplified": "سنتی → ساده",
|
||||
"Simplified to Traditional (Taiwan)": "ساده → سنتی (تایوان)",
|
||||
"Simplified to Traditional (Hong Kong)": "ساده → سنتی (هنگکنگ)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "ساده → سنتی (تایوان • عبارات)",
|
||||
"Traditional (Taiwan) to Simplified": "سنتی (تایوان) → ساده",
|
||||
"Traditional (Hong Kong) to Simplified": "سنتی (هنگکنگ) → ساده",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "سنتی (تایوان • عبارات) → ساده",
|
||||
"Convert Simplified and Traditional Chinese": "تبدیل چینی ساده/سنتی",
|
||||
"Convert Mode": "حالت تبدیل",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "ذخیرهی خودکار جلد کتاب برای صفحهی قفل ناموفق بود: {{error}}",
|
||||
"Download from Cloud": "دانلود از فضای ابری",
|
||||
"Upload to Cloud": "آپلود به فضای ابری",
|
||||
"Clear Custom Fonts": "پاککردن فونتهای سفارشی",
|
||||
"Columns": "ستونها",
|
||||
"OPDS Catalogs": "فهرستهای OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "افزودن آدرسهای LAN در نسخه وب پشتیبانی نمیشود.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "فهرست OPDS نامعتبر است. لطفاً آدرس را بررسی کنید.",
|
||||
"Browse and download books from online catalogs": "مرور و دانلود کتابها از فهرستهای آنلاین",
|
||||
"My Catalogs": "فهرستهای من",
|
||||
"Add Catalog": "افزودن فهرست",
|
||||
"No catalogs yet": "هنوز فهرستی وجود ندارد",
|
||||
"Add your first OPDS catalog to start browsing books": "برای شروع مرور کتابها، اولین فهرست OPDS خود را اضافه کنید.",
|
||||
"Add Your First Catalog": "افزودن اولین فهرست",
|
||||
"Browse": "مرور",
|
||||
"Popular Catalogs": "فهرستهای محبوب",
|
||||
"Add": "افزودن",
|
||||
"Add OPDS Catalog": "افزودن فهرست OPDS",
|
||||
"Catalog Name": "نام فهرست",
|
||||
"My Calibre Library": "کتابخانه Calibre من",
|
||||
"OPDS URL": "آدرس OPDS",
|
||||
"Username (optional)": "نام کاربری (اختیاری)",
|
||||
"Password (optional)": "رمز عبور (اختیاری)",
|
||||
"Description (optional)": "توضیحات (اختیاری)",
|
||||
"A brief description of this catalog": "توضیح کوتاهی درباره این فهرست",
|
||||
"Validating...": "در حال بررسی...",
|
||||
"View All": "مشاهده همه",
|
||||
"Forward": "بعدی",
|
||||
"OPDS Catalog": "فهرست OPDS",
|
||||
"Home": "خانه",
|
||||
"Library": "کتابخانه",
|
||||
"{{count}} items_one": "{{count}} مورد",
|
||||
"{{count}} items_other": "{{count}} مورد",
|
||||
"Download completed": "دانلود کامل شد",
|
||||
"Download failed": "دانلود ناموفق بود",
|
||||
"Open Access": "دسترسی آزاد",
|
||||
"Borrow": "امانت گرفتن",
|
||||
"Buy": "خرید",
|
||||
"Subscribe": "اشتراک",
|
||||
"Sample": "نمونه",
|
||||
"Download": "دانلود",
|
||||
"Open & Read": "باز کردن و خواندن",
|
||||
"Tags": "برچسبها",
|
||||
"Tag": "برچسب",
|
||||
"First": "اولین",
|
||||
"Previous": "قبلی",
|
||||
"Next": "بعدی",
|
||||
"Last": "آخرین",
|
||||
"Cannot Load Page": "بارگذاری صفحه امکانپذیر نیست",
|
||||
"An error occurred": "خطایی رخ داد",
|
||||
"Online Library": "کتابخانه آنلاین"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Mode automatique",
|
||||
"Behavior": "Comportement",
|
||||
"Book": "Livre",
|
||||
"Book Cover": "Couverture du livre",
|
||||
"Bookmark": "Signet",
|
||||
"Cancel": "Annuler",
|
||||
"Chapter": "Chapitre",
|
||||
@@ -193,7 +192,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Aucun fichier pris en charge trouvé. Formats pris en charge : {{formats}}",
|
||||
"Drop to Import Books": "Déposez pour importer des livres",
|
||||
"Custom": "Personnalisé",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Le monde entier est un théâtre,\nEt tous les hommes et les femmes ne sont que des acteurs ;\nIls ont leurs sorties et leurs entrées,\nEt un homme, dans sa vie, joue de nombreux rôles,\nSes actes étant sept âges.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Le monde entier est un théâtre,\nEt tous les hommes et les femmes ne sont que des acteurs ;\nIls ont leurs sorties et leurs entrées,\nEt un homme, dans sa vie, joue de nombreux rôles,\nSes actes étant sept âges.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Thème personnalisé",
|
||||
"Theme Name": "Nom du thème",
|
||||
"Text Color": "Couleur du texte",
|
||||
@@ -660,5 +659,72 @@
|
||||
"Update email": "Mettre à jour l’e-mail",
|
||||
"Current email": "E-mail actuel",
|
||||
"Update Email": "Mettre à jour l’e-mail",
|
||||
"All": "Tous"
|
||||
"All": "Tous",
|
||||
"Unable to open book": "Impossible d'ouvrir le livre",
|
||||
"Punctuation": "Ponctuation",
|
||||
"Replace Quotation Marks": "Remplacer les guillemets",
|
||||
"Enabled only in vertical layout.": "Activé uniquement en disposition verticale.",
|
||||
"No Conversion": "Pas de conversion",
|
||||
"Simplified to Traditional": "Simp. → Trad.",
|
||||
"Traditional to Simplified": "Trad. → Simp.",
|
||||
"Simplified to Traditional (Taiwan)": "Simp. → Trad. (Taïwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Simp. → Trad. (Hong Kong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Simp. → Trad. (Taïwan • phrases)",
|
||||
"Traditional (Taiwan) to Simplified": "Trad. (Taïwan) → Simp.",
|
||||
"Traditional (Hong Kong) to Simplified": "Trad. (Hong Kong) → Simp.",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Trad. (Taïwan • phrases) → Simp.",
|
||||
"Convert Simplified and Traditional Chinese": "Convertir chinois simp./trad.",
|
||||
"Convert Mode": "Mode de conversion",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Échec de l'enregistrement automatique de la couverture du livre pour l'écran de verrouillage : {{error}}",
|
||||
"Download from Cloud": "Télécharger depuis le Cloud",
|
||||
"Upload to Cloud": "Téléverser vers le Cloud",
|
||||
"Clear Custom Fonts": "Effacer les Polices Personnalisées",
|
||||
"Columns": "Colonnes",
|
||||
"OPDS Catalogs": "Catalogues OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "L’ajout d’adresses LAN n’est pas pris en charge dans la version web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Catalogue OPDS invalide. Veuillez vérifier l’URL.",
|
||||
"Browse and download books from online catalogs": "Parcourez et téléchargez des livres depuis des catalogues en ligne",
|
||||
"My Catalogs": "Mes catalogues",
|
||||
"Add Catalog": "Ajouter un catalogue",
|
||||
"No catalogs yet": "Aucun catalogue pour le moment",
|
||||
"Add your first OPDS catalog to start browsing books": "Ajoutez votre premier catalogue OPDS pour commencer à parcourir des livres.",
|
||||
"Add Your First Catalog": "Ajouter votre premier catalogue",
|
||||
"Browse": "Parcourir",
|
||||
"Popular Catalogs": "Catalogues populaires",
|
||||
"Add": "Ajouter",
|
||||
"Add OPDS Catalog": "Ajouter un catalogue OPDS",
|
||||
"Catalog Name": "Nom du catalogue",
|
||||
"My Calibre Library": "Ma bibliothèque Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Nom d’utilisateur (optionnel)",
|
||||
"Password (optional)": "Mot de passe (optionnel)",
|
||||
"Description (optional)": "Description (optionnelle)",
|
||||
"A brief description of this catalog": "Brève description de ce catalogue",
|
||||
"Validating...": "Validation...",
|
||||
"View All": "Tout afficher",
|
||||
"Forward": "Suivant",
|
||||
"OPDS Catalog": "Catalogue OPDS",
|
||||
"Home": "Accueil",
|
||||
"Library": "Bibliothèque",
|
||||
"{{count}} items_one": "{{count}} élément",
|
||||
"{{count}} items_many": "{{count}} éléments",
|
||||
"{{count}} items_other": "{{count}} éléments",
|
||||
"Download completed": "Téléchargement terminé",
|
||||
"Download failed": "Échec du téléchargement",
|
||||
"Open Access": "Accès libre",
|
||||
"Borrow": "Emprunter",
|
||||
"Buy": "Acheter",
|
||||
"Subscribe": "S’abonner",
|
||||
"Sample": "Extrait",
|
||||
"Download": "Télécharger",
|
||||
"Open & Read": "Ouvrir et lire",
|
||||
"Tags": "Tags",
|
||||
"Tag": "Tag",
|
||||
"First": "Premier",
|
||||
"Previous": "Précédent",
|
||||
"Next": "Suivant",
|
||||
"Last": "Dernier",
|
||||
"Cannot Load Page": "Impossible de charger la page",
|
||||
"An error occurred": "Une erreur est survenue",
|
||||
"Online Library": "Bibliothèque en ligne"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "स्वचालित मोड",
|
||||
"Behavior": "व्यवहार",
|
||||
"Book": "पुस्तक",
|
||||
"Book Cover": "पुस्तक का कवर",
|
||||
"Bookmark": "बुकमार्क",
|
||||
"Cancel": "रद्द करें",
|
||||
"Chapter": "अध्याय",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "कोई समर्थित फ़ाइलें नहीं मिलीं। समर्थित प्रारूप: {{formats}}",
|
||||
"Drop to Import Books": "पुस्तकें आयात करने के लिए छोड़ें",
|
||||
"Custom": "कस्टम",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "पूरी दुनिया एक स्टेज है,\nऔर सभी आदमी और महिलाएँ केवल अभिनेता हैं;\nउनके निकास और प्रवेश होते हैं,\nऔर एक आदमी अपने समय में कई भूमिकाएँ निभाता है,\nउसके कार्य सात युग होते हैं।\n\n— विलियम शेक्सपियर",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "पूरी दुनिया एक स्टेज है,\nऔर सभी आदमी और महिलाएँ केवल अभिनेता हैं;\nउनके निकास और प्रवेश होते हैं,\nऔर एक आदमी अपने समय में कई भूमिकाएँ निभाता है,\nउसके कार्य सात युग होते हैं।\n\n— विलियम शेक्सपियर",
|
||||
"Custom Theme": "कस्टम थीम",
|
||||
"Theme Name": "थीम का नाम",
|
||||
"Text Color": "टेक्स्ट रंग",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "ईमेल अपडेट करें",
|
||||
"Current email": "वर्तमान ईमेल",
|
||||
"Update Email": "ईमेल अपडेट करें",
|
||||
"All": "सभी"
|
||||
"All": "सभी",
|
||||
"Unable to open book": "पुस्तक खोलने में असमर्थ",
|
||||
"Punctuation": "विराम चिह्न",
|
||||
"Replace Quotation Marks": "उद्धरण चिह्न बदलें",
|
||||
"Enabled only in vertical layout.": "केवल ऊर्ध्वाधर लेआउट में सक्षम।",
|
||||
"No Conversion": "कोई रूपांतरण नहीं",
|
||||
"Simplified to Traditional": "सरलीकृत → पारंपरिक",
|
||||
"Traditional to Simplified": "पारंपरिक → सरलीकृत",
|
||||
"Simplified to Traditional (Taiwan)": "सरलीकृत → पारंपरिक (ताइवान)",
|
||||
"Simplified to Traditional (Hong Kong)": "सरलीकृत → पारंपरिक (हांगकांग)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "सरलीकृत → पारंपरिक (ताइवान • वाक्यांश)",
|
||||
"Traditional (Taiwan) to Simplified": "पारंपरिक (ताइवान) → सरलीकृत",
|
||||
"Traditional (Hong Kong) to Simplified": "पारंपरिक (हांगकांग) → सरलीकृत",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "पारंपरिक (ताइवान • वाक्यांश) → सरलीकृत",
|
||||
"Convert Simplified and Traditional Chinese": "सरलीकृत/पारंपरिक चीनी रूपांतरण",
|
||||
"Convert Mode": "रूपांतरण मोड",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "लॉक स्क्रीन के लिए पुस्तक कवर स्वचालित रूप से सहेजने में विफल: {{error}}",
|
||||
"Download from Cloud": "क्लाउड से डाउनलोड करें",
|
||||
"Upload to Cloud": "क्लाउड पर अपलोड करें",
|
||||
"Clear Custom Fonts": "कस्टम फ़ॉन्ट साफ़ करें",
|
||||
"Columns": "कॉलम",
|
||||
"OPDS Catalogs": "OPDS कैटलॉग",
|
||||
"Adding LAN addresses is not supported in the web app version.": "वेब ऐप संस्करण में LAN पता जोड़ना समर्थित नहीं है।",
|
||||
"Invalid OPDS catalog. Please check the URL.": "अमान्य OPDS कैटलॉग। कृपया URL जांचें।",
|
||||
"Browse and download books from online catalogs": "ऑनलाइन कैटलॉग से किताबें ब्राउज़ और डाउनलोड करें",
|
||||
"My Catalogs": "मेरे कैटलॉग",
|
||||
"Add Catalog": "कैटलॉग जोड़ें",
|
||||
"No catalogs yet": "अभी कोई कैटलॉग नहीं",
|
||||
"Add your first OPDS catalog to start browsing books": "किताबें ब्राउज़ करने के लिए अपना पहला OPDS कैटलॉग जोड़ें",
|
||||
"Add Your First Catalog": "अपना पहला कैटलॉग जोड़ें",
|
||||
"Browse": "ब्राउज़",
|
||||
"Popular Catalogs": "लोकप्रिय कैटलॉग",
|
||||
"Add": "जोड़ें",
|
||||
"Add OPDS Catalog": "OPDS कैटलॉग जोड़ें",
|
||||
"Catalog Name": "कैटलॉग नाम",
|
||||
"My Calibre Library": "मेरी Calibre लाइब्रेरी",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "यूज़रनेम (वैकल्पिक)",
|
||||
"Password (optional)": "पासवर्ड (वैकल्पिक)",
|
||||
"Description (optional)": "विवरण (वैकल्पिक)",
|
||||
"A brief description of this catalog": "इस कैटलॉग का संक्षिप्त विवरण",
|
||||
"Validating...": "मान्य किया जा रहा है...",
|
||||
"View All": "सभी देखें",
|
||||
"Forward": "आगे",
|
||||
"OPDS Catalog": "OPDS कैटलॉग",
|
||||
"Home": "होम",
|
||||
"Library": "लाइब्रेरी",
|
||||
"{{count}} items_one": "{{count}} आइटम",
|
||||
"{{count}} items_other": "{{count}} आइटम",
|
||||
"Download completed": "डाउनलोड पूरा हुआ",
|
||||
"Download failed": "डाउनलोड विफल हुआ",
|
||||
"Open Access": "ओपन एक्सेस",
|
||||
"Borrow": "उधार लें",
|
||||
"Buy": "खरीदें",
|
||||
"Subscribe": "सदस्यता लें",
|
||||
"Sample": "नमूना",
|
||||
"Download": "डाउनलोड",
|
||||
"Open & Read": "खोलें और पढ़ें",
|
||||
"Tags": "टैग",
|
||||
"Tag": "टैग",
|
||||
"First": "पहला",
|
||||
"Previous": "पिछला",
|
||||
"Next": "अगला",
|
||||
"Last": "अंतिम",
|
||||
"Cannot Load Page": "पृष्ठ लोड नहीं किया जा सका",
|
||||
"An error occurred": "एक त्रुटि हुई",
|
||||
"Online Library": "ऑनलाइन लाइब्रेरी"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Mode Otomatis",
|
||||
"Behavior": "Perilaku",
|
||||
"Book": "Buku",
|
||||
"Book Cover": "Sampul Buku",
|
||||
"Bookmark": "Penanda",
|
||||
"Cancel": "Batal",
|
||||
"Chapter": "Bab",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Tidak ada file yang didukung ditemukan. Format yang didukung: {{formats}}",
|
||||
"Drop to Import Books": "Jatuhkan untuk Mengimpor Buku",
|
||||
"Custom": "Kustom",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Seluruh dunia adalah panggung,\nDan semua pria dan wanita hanyalah pemain;\nMereka memiliki keluar dan masuk mereka,\nDan seorang pria dalam hidupnya memainkan banyak peran,\nTindakannya ada tujuh zaman.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Seluruh dunia adalah panggung,\nDan semua pria dan wanita hanyalah pemain;\nMereka memiliki keluar dan masuk mereka,\nDan seorang pria dalam hidupnya memainkan banyak peran,\nTindakannya ada tujuh zaman.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Tema Kustom",
|
||||
"Theme Name": "Nama Tema",
|
||||
"Text Color": "Warna Teks",
|
||||
@@ -652,5 +651,70 @@
|
||||
"Update email": "Perbarui email",
|
||||
"Current email": "Email saat ini",
|
||||
"Update Email": "Perbarui email",
|
||||
"All": "Semua"
|
||||
"All": "Semua",
|
||||
"Unable to open book": "Tidak dapat membuka buku",
|
||||
"Punctuation": "Tanda Baca",
|
||||
"Replace Quotation Marks": "Ganti Tanda Kutip",
|
||||
"Enabled only in vertical layout.": "Hanya diaktifkan dalam tata letak vertikal.",
|
||||
"No Conversion": "Tanpa konversi",
|
||||
"Simplified to Traditional": "Sederhana → Tradisional",
|
||||
"Traditional to Simplified": "Tradisional → Sederhana",
|
||||
"Simplified to Traditional (Taiwan)": "Sederhana → Tradisional (Taiwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Sederhana → Tradisional (Hong Kong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Sederhana → Tradisional (Taiwan • frasa)",
|
||||
"Traditional (Taiwan) to Simplified": "Tradisional (Taiwan) → Sederhana",
|
||||
"Traditional (Hong Kong) to Simplified": "Tradisional (Hong Kong) → Sederhana",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Tradisional (Taiwan • frasa) → Sederhana",
|
||||
"Convert Simplified and Traditional Chinese": "Konversi Tionghoa Sederhana/Tradisional",
|
||||
"Convert Mode": "Mode Konversi",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Gagal menyimpan otomatis sampul buku untuk layar kunci: {{error}}",
|
||||
"Download from Cloud": "Unduh dari Cloud",
|
||||
"Upload to Cloud": "Unggah ke Cloud",
|
||||
"Clear Custom Fonts": "Bersihkan Font Kustom",
|
||||
"Columns": "Kolom",
|
||||
"OPDS Catalogs": "Katalog OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Menambahkan alamat LAN tidak didukung di versi web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Katalog OPDS tidak valid. Silakan periksa URL-nya.",
|
||||
"Browse and download books from online catalogs": "Jelajahi dan unduh buku dari katalog online",
|
||||
"My Catalogs": "Katalog Saya",
|
||||
"Add Catalog": "Tambah Katalog",
|
||||
"No catalogs yet": "Belum ada katalog",
|
||||
"Add your first OPDS catalog to start browsing books": "Tambahkan katalog OPDS pertama Anda untuk mulai menjelajahi buku",
|
||||
"Add Your First Catalog": "Tambah Katalog Pertama Anda",
|
||||
"Browse": "Jelajahi",
|
||||
"Popular Catalogs": "Katalog Populer",
|
||||
"Add": "Tambah",
|
||||
"Add OPDS Catalog": "Tambah Katalog OPDS",
|
||||
"Catalog Name": "Nama Katalog",
|
||||
"My Calibre Library": "Perpustakaan Calibre Saya",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Username (opsional)",
|
||||
"Password (optional)": "Password (opsional)",
|
||||
"Description (optional)": "Deskripsi (opsional)",
|
||||
"A brief description of this catalog": "Deskripsi singkat untuk katalog ini",
|
||||
"Validating...": "Memvalidasi...",
|
||||
"View All": "Lihat Semua",
|
||||
"Forward": "Maju",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Beranda",
|
||||
"Library": "Perpustakaan",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
"Download completed": "Unduhan selesai",
|
||||
"Download failed": "Unduhan gagal",
|
||||
"Open Access": "Akses Terbuka",
|
||||
"Borrow": "Pinjam",
|
||||
"Buy": "Beli",
|
||||
"Subscribe": "Berlangganan",
|
||||
"Sample": "Contoh",
|
||||
"Download": "Unduh",
|
||||
"Open & Read": "Buka & Baca",
|
||||
"Tags": "Tag",
|
||||
"Tag": "Tag",
|
||||
"First": "Pertama",
|
||||
"Previous": "Sebelumnya",
|
||||
"Next": "Berikutnya",
|
||||
"Last": "Terakhir",
|
||||
"Cannot Load Page": "Tidak dapat memuat halaman",
|
||||
"An error occurred": "Terjadi kesalahan",
|
||||
"Online Library": "Perpustakaan Online"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Modalità automatica",
|
||||
"Behavior": "Comportamento",
|
||||
"Book": "Libro",
|
||||
"Book Cover": "Copertina",
|
||||
"Bookmark": "Segnalibro",
|
||||
"Cancel": "Annulla",
|
||||
"Chapter": "Capitolo",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Nessun file supportato trovato. Formati supportati: {{formats}}",
|
||||
"Drop to Import Books": "Rilascia per importare libri",
|
||||
"Custom": "Personalizzato",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tutto il mondo è un palcoscenico,\nE tutti gli uomini e le donne sono solo attori;\nHanno le loro uscite e le loro entrate,\nE un uomo nella sua vita interpreta molte parti,\nI suoi atti sono sette età.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tutto il mondo è un palcoscenico,\nE tutti gli uomini e le donne sono solo attori;\nHanno le loro uscite e le loro entrate,\nE un uomo nella sua vita interpreta molte parti,\nI suoi atti sono sette età.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Tema personalizzato",
|
||||
"Theme Name": "Nome tema",
|
||||
"Text Color": "Colore testo",
|
||||
@@ -660,5 +659,72 @@
|
||||
"Update email": "Aggiorna email",
|
||||
"Current email": "Email attuale",
|
||||
"Update Email": "Aggiorna email",
|
||||
"All": "Tutti"
|
||||
"All": "Tutti",
|
||||
"Unable to open book": "Impossibile aprire il libro",
|
||||
"Punctuation": "Segni di punteggiatura",
|
||||
"Replace Quotation Marks": "Sostituisci le virgolette",
|
||||
"Enabled only in vertical layout.": "Abilitato solo in layout verticale.",
|
||||
"No Conversion": "Nessuna conversione",
|
||||
"Simplified to Traditional": "Semplificato → Tradizionale",
|
||||
"Traditional to Simplified": "Tradizionale → Semplificato",
|
||||
"Simplified to Traditional (Taiwan)": "Semplificato → Tradizionale (Taiwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Semplificato → Tradizionale (Hong Kong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Semplificato → Tradizionale (Taiwan • frasi)",
|
||||
"Traditional (Taiwan) to Simplified": "Tradizionale (Taiwan) → Semplificato",
|
||||
"Traditional (Hong Kong) to Simplified": "Tradizionale (Hong Kong) → Semplificato",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Tradizionale (Taiwan • frasi) → Semplificato",
|
||||
"Convert Simplified and Traditional Chinese": "Converti Cinese Semplificato/Tradizionale",
|
||||
"Convert Mode": "Modalità conversione",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Impossibile salvare automaticamente la copertina del libro per la schermata di blocco: {{error}}",
|
||||
"Download from Cloud": "Scarica dal Cloud",
|
||||
"Upload to Cloud": "Carica sul Cloud",
|
||||
"Clear Custom Fonts": "Pulisci Font Personalizzati",
|
||||
"Columns": "Colonne",
|
||||
"OPDS Catalogs": "Cataloghi OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "L'aggiunta di indirizzi LAN non è supportata nella versione web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Catalogo OPDS non valido. Controlla l'URL.",
|
||||
"Browse and download books from online catalogs": "Sfoglia e scarica libri dai cataloghi online",
|
||||
"My Catalogs": "I miei cataloghi",
|
||||
"Add Catalog": "Aggiungi catalogo",
|
||||
"No catalogs yet": "Nessun catalogo",
|
||||
"Add your first OPDS catalog to start browsing books": "Aggiungi il tuo primo catalogo OPDS per iniziare a sfogliare i libri",
|
||||
"Add Your First Catalog": "Aggiungi il tuo primo catalogo",
|
||||
"Browse": "Sfoglia",
|
||||
"Popular Catalogs": "Cataloghi popolari",
|
||||
"Add": "Aggiungi",
|
||||
"Add OPDS Catalog": "Aggiungi catalogo OPDS",
|
||||
"Catalog Name": "Nome del catalogo",
|
||||
"My Calibre Library": "La mia libreria Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Username (opzionale)",
|
||||
"Password (optional)": "Password (opzionale)",
|
||||
"Description (optional)": "Descrizione (opzionale)",
|
||||
"A brief description of this catalog": "Breve descrizione del catalogo",
|
||||
"Validating...": "Convalida in corso...",
|
||||
"View All": "Vedi tutto",
|
||||
"Forward": "Avanti",
|
||||
"OPDS Catalog": "Catalogo OPDS",
|
||||
"Home": "Home",
|
||||
"Library": "Libreria",
|
||||
"{{count}} items_one": "{{count}} elemento",
|
||||
"{{count}} items_many": "{{count}} elementi",
|
||||
"{{count}} items_other": "{{count}} elementi",
|
||||
"Download completed": "Download completato",
|
||||
"Download failed": "Download non riuscito",
|
||||
"Open Access": "Accesso libero",
|
||||
"Borrow": "Prendi in prestito",
|
||||
"Buy": "Acquista",
|
||||
"Subscribe": "Abbonati",
|
||||
"Sample": "Anteprima",
|
||||
"Download": "Scarica",
|
||||
"Open & Read": "Apri e leggi",
|
||||
"Tags": "Tag",
|
||||
"Tag": "Tag",
|
||||
"First": "Primo",
|
||||
"Previous": "Precedente",
|
||||
"Next": "Successivo",
|
||||
"Last": "Ultimo",
|
||||
"Cannot Load Page": "Impossibile caricare la pagina",
|
||||
"An error occurred": "Si è verificato un errore",
|
||||
"Online Library": "Libreria online"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "自動モード",
|
||||
"Behavior": "動作",
|
||||
"Book": "書籍",
|
||||
"Book Cover": "表紙",
|
||||
"Bookmark": "ブックマーク",
|
||||
"Cancel": "キャンセル",
|
||||
"Chapter": "章",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "サポートされているファイルが見つかりません。サポートされている形式:{{formats}}",
|
||||
"Drop to Import Books": "書籍をインポートするにはドロップ",
|
||||
"Custom": "カスタム",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "全世界は舞台であり、\nそしてすべての男性と女性は単なる役者です。\n彼らは出入り口を持っており、\nそして一人の男性は時に多くの役を演じます。\n彼の行為は七つの時代です。\n\n— ウィリアム・シェイクスピア",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "全世界は舞台であり、\nそしてすべての男性と女性は単なる役者です。\n彼らは出入り口を持っており、\nそして一人の男性は時に多くの役を演じます。\n彼の行為は七つの時代です。\n\n— ウィリアム・シェイクスピア",
|
||||
"Custom Theme": "カスタムテーマ",
|
||||
"Theme Name": "テーマ名",
|
||||
"Text Color": "テキストカラー",
|
||||
@@ -652,5 +651,70 @@
|
||||
"Update email": "メールを更新",
|
||||
"Current email": "現在のメールアドレス",
|
||||
"Update Email": "メールを更新",
|
||||
"All": "すべて"
|
||||
"All": "すべて",
|
||||
"Unable to open book": "本を開くことができません",
|
||||
"Punctuation": "句読点",
|
||||
"Replace Quotation Marks": "引用符を置き換える",
|
||||
"Enabled only in vertical layout.": "縦書きレイアウトでのみ有効です。",
|
||||
"No Conversion": "変換なし",
|
||||
"Simplified to Traditional": "簡体字 → 繁体字",
|
||||
"Traditional to Simplified": "繁体字 → 簡体字",
|
||||
"Simplified to Traditional (Taiwan)": "簡体字 → 繁体字(台湾)",
|
||||
"Simplified to Traditional (Hong Kong)": "簡体字 → 繁体字(香港)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "簡体字 → 繁体字(台湾)• フレーズ",
|
||||
"Traditional (Taiwan) to Simplified": "繁体字(台湾) → 簡体字",
|
||||
"Traditional (Hong Kong) to Simplified": "繁体字(香港) → 簡体字",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "繁体字(台湾) → 簡体字 • フレーズ",
|
||||
"Convert Simplified and Traditional Chinese": "簡体字/繁体字変換",
|
||||
"Convert Mode": "変換モード",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "ロック画面の本のカバーの自動保存に失敗しました:{{error}}",
|
||||
"Download from Cloud": "クラウドからダウンロード",
|
||||
"Upload to Cloud": "クラウドにアップロード",
|
||||
"Clear Custom Fonts": "カスタムフォントをクリア",
|
||||
"Columns": "列",
|
||||
"OPDS Catalogs": "OPDSカタログ",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Webアプリ版ではLANアドレスの追加はサポートされていません。",
|
||||
"Invalid OPDS catalog. Please check the URL.": "無効なOPDSカタログです。URLを確認してください。",
|
||||
"Browse and download books from online catalogs": "オンラインカタログから本を閲覧・ダウンロード",
|
||||
"My Catalogs": "マイカタログ",
|
||||
"Add Catalog": "カタログを追加",
|
||||
"No catalogs yet": "まだカタログはありません",
|
||||
"Add your first OPDS catalog to start browsing books": "本を閲覧するには、最初のOPDSカタログを追加してください。",
|
||||
"Add Your First Catalog": "最初のカタログを追加",
|
||||
"Browse": "閲覧",
|
||||
"Popular Catalogs": "人気カタログ",
|
||||
"Add": "追加",
|
||||
"Add OPDS Catalog": "OPDSカタログを追加",
|
||||
"Catalog Name": "カタログ名",
|
||||
"My Calibre Library": "私のCalibreライブラリ",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "ユーザー名(任意)",
|
||||
"Password (optional)": "パスワード(任意)",
|
||||
"Description (optional)": "説明(任意)",
|
||||
"A brief description of this catalog": "このカタログの簡単な説明",
|
||||
"Validating...": "検証中...",
|
||||
"View All": "すべて表示",
|
||||
"Forward": "進む",
|
||||
"OPDS Catalog": "OPDSカタログ",
|
||||
"Home": "ホーム",
|
||||
"Library": "ライブラリ",
|
||||
"{{count}} items_other": "{{count}} 件",
|
||||
"Download completed": "ダウンロード完了",
|
||||
"Download failed": "ダウンロード失敗",
|
||||
"Open Access": "オープンアクセス",
|
||||
"Borrow": "借りる",
|
||||
"Buy": "購入",
|
||||
"Subscribe": "購読",
|
||||
"Sample": "サンプル",
|
||||
"Download": "ダウンロード",
|
||||
"Open & Read": "開いて読む",
|
||||
"Tags": "タグ",
|
||||
"Tag": "タグ",
|
||||
"First": "最初",
|
||||
"Previous": "前へ",
|
||||
"Next": "次へ",
|
||||
"Last": "最後",
|
||||
"Cannot Load Page": "ページを読み込めません",
|
||||
"An error occurred": "エラーが発生しました",
|
||||
"Online Library": "オンラインライブラリ"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "자동 모드",
|
||||
"Behavior": "동작",
|
||||
"Book": "책",
|
||||
"Book Cover": "책 표지",
|
||||
"Bookmark": "북마크",
|
||||
"Cancel": "취소",
|
||||
"Chapter": "챕터",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "지원되는 파일이 없습니다. 지원되는 형식: {{formats}}",
|
||||
"Drop to Import Books": "책 가져오기 위해 드롭",
|
||||
"Custom": "사용자 정의",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "세상은 무대이며,\n그리고 모든 남자와 여자는 단지 배우일 뿐입니다;\n그들은 나가는 곳과 들어오는 곳이 있으며,\n그리고 한 남자는 그의 시간에 많은 역할을 합니다,\n그의 행동은 일곱 시대입니다.\n\n— 윌리엄 셰익스피어",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "세상은 무대이며,\n그리고 모든 남자와 여자는 단지 배우일 뿐입니다;\n그들은 나가는 곳과 들어오는 곳이 있으며,\n그리고 한 남자는 그의 시간에 많은 역할을 합니다,\n그의 행동은 일곱 시대입니다.\n\n— 윌리엄 셰익스피어",
|
||||
"Custom Theme": "사용자 정의 테마",
|
||||
"Theme Name": "테마 이름",
|
||||
"Text Color": "텍스트 색상",
|
||||
@@ -652,5 +651,70 @@
|
||||
"Update email": "이메일 업데이트",
|
||||
"Current email": "현재 이메일",
|
||||
"Update Email": "이메일 업데이트",
|
||||
"All": "전체"
|
||||
"All": "전체",
|
||||
"Unable to open book": "책을 열 수 없습니다",
|
||||
"Punctuation": "구두점",
|
||||
"Replace Quotation Marks": "인용 부호 바꾸기",
|
||||
"Enabled only in vertical layout.": "세로 레이아웃에서만 활성화됩니다.",
|
||||
"No Conversion": "변환 없음",
|
||||
"Simplified to Traditional": "간체 → 번체",
|
||||
"Traditional to Simplified": "번체 → 간체",
|
||||
"Simplified to Traditional (Taiwan)": "간체 → 번체 (대만)",
|
||||
"Simplified to Traditional (Hong Kong)": "간체 → 번체 (홍콩)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "간체 → 번체 (대만) • 문구",
|
||||
"Traditional (Taiwan) to Simplified": "번체 (대만) → 간체",
|
||||
"Traditional (Hong Kong) to Simplified": "번체 (홍콩) → 간체",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "번체 (대만) → 간체 • 문구",
|
||||
"Convert Simplified and Traditional Chinese": "간체/번체 변환",
|
||||
"Convert Mode": "변환 모드",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "잠금 화면용 책 표지 자동 저장 실패: {{error}}",
|
||||
"Download from Cloud": "클라우드에서 다운로드",
|
||||
"Upload to Cloud": "클라우드에 업로드",
|
||||
"Clear Custom Fonts": "사용자 정의 글꼴 지우기",
|
||||
"Columns": "열",
|
||||
"OPDS Catalogs": "OPDS 카탈로그",
|
||||
"Adding LAN addresses is not supported in the web app version.": "웹 앱 버전에서는 LAN 주소 추가를 지원하지 않습니다.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "잘못된 OPDS 카탈로그입니다. URL을 확인해주세요.",
|
||||
"Browse and download books from online catalogs": "온라인 카탈로그에서 책을 탐색하고 다운로드",
|
||||
"My Catalogs": "내 카탈로그",
|
||||
"Add Catalog": "카탈로그 추가",
|
||||
"No catalogs yet": "아직 카탈로그가 없습니다",
|
||||
"Add your first OPDS catalog to start browsing books": "책을 탐색하려면 첫 번째 OPDS 카탈로그를 추가하세요.",
|
||||
"Add Your First Catalog": "첫 번째 카탈로그 추가",
|
||||
"Browse": "탐색",
|
||||
"Popular Catalogs": "인기 카탈로그",
|
||||
"Add": "추가",
|
||||
"Add OPDS Catalog": "OPDS 카탈로그 추가",
|
||||
"Catalog Name": "카탈로그 이름",
|
||||
"My Calibre Library": "내 Calibre 라이브러리",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "사용자 이름 (선택 사항)",
|
||||
"Password (optional)": "비밀번호 (선택 사항)",
|
||||
"Description (optional)": "설명 (선택 사항)",
|
||||
"A brief description of this catalog": "이 카탈로그의 간단한 설명",
|
||||
"Validating...": "검증 중...",
|
||||
"View All": "모두 보기",
|
||||
"Forward": "다음",
|
||||
"OPDS Catalog": "OPDS 카탈로그",
|
||||
"Home": "홈",
|
||||
"Library": "라이브러리",
|
||||
"{{count}} items_other": "{{count}}개 항목",
|
||||
"Download completed": "다운로드 완료",
|
||||
"Download failed": "다운로드 실패",
|
||||
"Open Access": "오픈 액세스",
|
||||
"Borrow": "대출",
|
||||
"Buy": "구매",
|
||||
"Subscribe": "구독",
|
||||
"Sample": "샘플",
|
||||
"Download": "다운로드",
|
||||
"Open & Read": "열기 및 읽기",
|
||||
"Tags": "태그",
|
||||
"Tag": "태그",
|
||||
"First": "처음",
|
||||
"Previous": "이전",
|
||||
"Next": "다음",
|
||||
"Last": "마지막",
|
||||
"Cannot Load Page": "페이지를 불러올 수 없습니다",
|
||||
"An error occurred": "오류가 발생했습니다",
|
||||
"Online Library": "온라인 라이브러리"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
{
|
||||
"Email address": "Alamat e-mel",
|
||||
"Your Password": "Kata Laluan Anda",
|
||||
"Your email address": "Alamat e-mel anda",
|
||||
"Your password": "Kata laluan anda",
|
||||
"Sign in": "Log masuk",
|
||||
"Signing in...": "Sedang log masuk...",
|
||||
"Sign in with {{provider}}": "Log masuk dengan {{provider}}",
|
||||
"Already have an account? Sign in": "Sudah ada akaun? Log masuk",
|
||||
"Create a Password": "Cipta Kata Laluan",
|
||||
"Sign up": "Daftar",
|
||||
"Signing up...": "Sedang mendaftar...",
|
||||
"Don't have an account? Sign up": "Tiada akaun? Daftar",
|
||||
"Check your email for the confirmation link": "Semak e-mel anda untuk pautan pengesahan",
|
||||
"Signing in ...": "Sedang log masuk...",
|
||||
"Send a magic link email": "Hantar e-mel pautan ajaib",
|
||||
"Check your email for the magic link": "Semak e-mel anda untuk pautan ajaib",
|
||||
"Send reset password instructions": "Hantar arahan tetapkan semula kata laluan",
|
||||
"Sending reset instructions ...": "Menghantar arahan tetapkan semula...",
|
||||
"Forgot your password?": "Lupa kata laluan?",
|
||||
"Check your email for the password reset link": "Semak e-mel anda untuk pautan tetapkan semula kata laluan",
|
||||
"Phone number": "Nombor telefon",
|
||||
"Your phone number": "Nombor telefon anda",
|
||||
"Token": "Token",
|
||||
"Your OTP token": "Token OTP anda",
|
||||
"Verify token": "Sahkan token",
|
||||
"Go Back": "Kembali",
|
||||
"Sign in with Google": "Log masuk dengan Google",
|
||||
"Sign in with Apple": "Log masuk dengan Apple",
|
||||
"Sign in with GitHub": "Log masuk dengan GitHub",
|
||||
"New Password": "Kata Laluan Baharu",
|
||||
"Your new password": "Kata laluan baharu anda",
|
||||
"Update password": "Kemas kini kata laluan",
|
||||
"Updating password ...": "Mengemas kini kata laluan...",
|
||||
"Your password has been updated": "Kata laluan anda telah dikemas kini",
|
||||
"Back": "Kembali",
|
||||
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "E-mel pengesahan telah dihantar! Sila semak alamat e-mel lama dan baharu anda untuk mengesahkan perubahan.",
|
||||
"Failed to update email": "Gagal mengemas kini e-mel",
|
||||
"New Email": "E-mel Baharu",
|
||||
"Your new email": "E-mel baharu anda",
|
||||
"Updating email ...": "Mengemas kini e-mel...",
|
||||
"Update email": "Kemas kini e-mel",
|
||||
"Current email": "E-mel semasa",
|
||||
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Sesuatu tidak kena. Jangan risau, pasukan kami telah dimaklumkan dan kami sedang menanganinya.",
|
||||
"Error Details:": "Butiran Ralat:",
|
||||
"Try Again": "Cuba Lagi",
|
||||
"Your Library": "Perpustakaan Anda",
|
||||
"Need help?": "Perlukan bantuan?",
|
||||
"Contact Support": "Hubungi Sokongan",
|
||||
"Show Book Details": "Tunjuk Butiran Buku",
|
||||
"Bookshelf": "Rak Buku",
|
||||
"Import Books": "Import Buku",
|
||||
"Confirm Deletion": "Sahkan Pemadaman",
|
||||
"Are you sure to delete {{count}} selected book(s)?_other": "Adakah anda pasti untuk memadam {{count}} buku yang dipilih?",
|
||||
"Deselect Book": "Nyahpilih Buku",
|
||||
"Select Book": "Pilih Buku",
|
||||
"Download Book": "Muat Turun Buku",
|
||||
"Upload Book": "Muat Naik Buku",
|
||||
"Delete": "Padam",
|
||||
"Deselect Group": "Nyahpilih Kumpulan",
|
||||
"Select Group": "Pilih Kumpulan",
|
||||
"Untitled Group": "Kumpulan Tanpa Tajuk",
|
||||
"Group Books": "Kumpulan Buku",
|
||||
"Remove From Group": "Alih Keluar Daripada Kumpulan",
|
||||
"Create New Group": "Cipta Kumpulan Baharu",
|
||||
"Save": "Simpan",
|
||||
"All": "Semua",
|
||||
"Cancel": "Batal",
|
||||
"Confirm": "Sahkan",
|
||||
"From Local File": "Dari Fail Tempatan",
|
||||
"Search in {{count}} Book(s)..._other": "Cari dalam {{count}} Buku...",
|
||||
"Search Books...": "Cari Buku...",
|
||||
"Clear Search": "Kosongkan Carian",
|
||||
"Select Books": "Pilih Buku",
|
||||
"Deselect": "Nyahpilih",
|
||||
"Select All": "Pilih Semua",
|
||||
"View Menu": "Menu Paparan",
|
||||
"Settings Menu": "Menu Tetapan",
|
||||
"Failed to select directory": "Gagal memilih direktori",
|
||||
"The new data directory must be different from the current one.": "Direktori data baharu mesti berbeza daripada yang semasa.",
|
||||
"Migration failed: {{error}}": "Migrasi gagal: {{error}}",
|
||||
"Change Data Location": "Tukar Lokasi Data",
|
||||
"Current Data Location": "Lokasi Data Semasa",
|
||||
"Loading...": "Memuatkan...",
|
||||
"File count: {{size}}": "Bilangan fail: {{size}}",
|
||||
"Total size: {{size}}": "Jumlah saiz: {{size}}",
|
||||
"Calculating file info...": "Mengira maklumat fail...",
|
||||
"New Data Location": "Lokasi Data Baharu",
|
||||
"Choose New Folder": "Pilih Folder Baharu",
|
||||
"Choose Different Folder": "Pilih Folder Lain",
|
||||
"Migrating data...": "Memindahkan data...",
|
||||
"Copying: {{file}}": "Menyalin: {{file}}",
|
||||
"{{current}} of {{total}} files": "{{current}} daripada {{total}} fail",
|
||||
"Migration completed successfully!": "Migrasi berjaya diselesaikan!",
|
||||
"Your data has been moved to the new location. Please restart the application to complete the process.": "Data anda telah dipindahkan ke lokasi baharu. Sila mulakan semula aplikasi untuk melengkapkan proses.",
|
||||
"Migration failed": "Migrasi gagal",
|
||||
"Important Notice": "Notis Penting",
|
||||
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Ini akan memindahkan semua data aplikasi anda ke lokasi baharu. Pastikan destinasi mempunyai ruang kosong yang mencukupi.",
|
||||
"Close": "Tutup",
|
||||
"Restart App": "Mula Semula Aplikasi",
|
||||
"Start Migration": "Mula Migrasi",
|
||||
"Open": "Buka",
|
||||
"Group": "Kumpulan",
|
||||
"Details": "Butiran",
|
||||
"Dark Mode": "Mod Gelap",
|
||||
"Light Mode": "Mod Cerah",
|
||||
"Auto Mode": "Mod Auto",
|
||||
"Logged in as {{userDisplayName}}": "Log masuk sebagai {{userDisplayName}}",
|
||||
"Logged in": "Log masuk",
|
||||
"View account details and quota": "Lihat butiran akaun dan kuota",
|
||||
"Account": "Akaun",
|
||||
"Sign In": "Log Masuk",
|
||||
"Auto Upload Books to Cloud": "Auto Muat Naik Buku ke Awan",
|
||||
"Auto Import on File Open": "Auto Import Apabila Buka Fail",
|
||||
"Open Last Book on Start": "Buka Buku Terakhir Semasa Mula",
|
||||
"Check Updates on Start": "Semak Kemas Kini Semasa Mula",
|
||||
"Open Book in New Window": "Buka Buku dalam Tetingkap Baharu",
|
||||
"Fullscreen": "Skrin Penuh",
|
||||
"Always on Top": "Sentiasa di Atas",
|
||||
"Always Show Status Bar": "Sentiasa Tunjuk Bar Status",
|
||||
"Keep Screen Awake": "Kekalkan Skrin Berjaga",
|
||||
"Background Read Aloud": "Baca Dengan Kuat di Latar Belakang",
|
||||
"Reload Page": "Muat Semula Halaman",
|
||||
"Settings": "Tetapan",
|
||||
"Advanced Settings": "Tetapan Lanjutan",
|
||||
"Save Book Cover": "Simpan Kulit Buku",
|
||||
"Auto-save last book cover": "Auto-simpan kulit buku terakhir",
|
||||
"Upgrade to Readest Premium": "Naik Taraf ke Readest Premium",
|
||||
"Download Readest": "Muat Turun Readest",
|
||||
"About Readest": "Tentang Readest",
|
||||
"Help improve Readest": "Bantu tingkatkan Readest",
|
||||
"Sharing anonymized statistics": "Berkongsi statistik tanpa nama",
|
||||
"List": "Senarai",
|
||||
"Grid": "Grid",
|
||||
"Crop": "Pangkas",
|
||||
"Fit": "Muat",
|
||||
"Title": "Tajuk",
|
||||
"Author": "Pengarang",
|
||||
"Format": "Format",
|
||||
"Date Read": "Tarikh Dibaca",
|
||||
"Date Added": "Tarikh Ditambah",
|
||||
"Ascending": "Menaik",
|
||||
"Descending": "Menurun",
|
||||
"Book Covers": "Kulit Buku",
|
||||
"Sort by...": "Susun mengikut...",
|
||||
"No supported files found. Supported formats: {{formats}}": "Tiada fail yang disokong dijumpai. Format yang disokong: {{formats}}",
|
||||
"No chapters detected": "Tiada bab dikesan",
|
||||
"Failed to parse the EPUB file": "Gagal menghurai fail EPUB",
|
||||
"This book format is not supported": "Format buku ini tidak disokong",
|
||||
"Failed to open the book file": "Gagal membuka fail buku",
|
||||
"The book file is empty": "Fail buku kosong",
|
||||
"The book file is corrupted": "Fail buku rosak",
|
||||
"Failed to import book(s): {{filenames}}": "Gagal mengimport buku: {{filenames}}",
|
||||
"Book uploaded: {{title}}": "Buku dimuat naik: {{title}}",
|
||||
"Insufficient storage quota": "Kuota storan tidak mencukupi",
|
||||
"Failed to upload book: {{title}}": "Gagal memuat naik buku: {{title}}",
|
||||
"Book downloaded: {{title}}": "Buku dimuat turun: {{title}}",
|
||||
"Failed to download book: {{title}}": "Gagal memuat turun buku: {{title}}",
|
||||
"Book deleted: {{title}}": "Buku dipadam: {{title}}",
|
||||
"Deleted cloud backup of the book: {{title}}": "Sandaran awan buku dipadam: {{title}}",
|
||||
"Deleted local copy of the book: {{title}}": "Salinan tempatan buku dipadam: {{title}}",
|
||||
"Failed to delete book: {{title}}": "Gagal memadam buku: {{title}}",
|
||||
"Failed to delete cloud backup of the book: {{title}}": "Gagal memadam sandaran awan buku: {{title}}",
|
||||
"Failed to delete local copy of the book: {{title}}": "Gagal memadam salinan tempatan buku: {{title}}",
|
||||
"Library Header": "Pengepala Perpustakaan",
|
||||
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang ke perpustakaan anda. Anda boleh import buku anda di sini dan membacanya bila-bila masa.",
|
||||
"Copied to notebook": "Disalin ke buku nota",
|
||||
"No annotations to export": "Tiada anotasi untuk dieksport",
|
||||
"Exported from Readest": "Dieksport dari Readest",
|
||||
"Highlights & Annotations": "Penonjolan & Anotasi",
|
||||
"Untitled": "Tanpa Tajuk",
|
||||
"Note": "Nota",
|
||||
"Copied to clipboard": "Disalin ke papan klip",
|
||||
"Copy": "Salin",
|
||||
"Delete Highlight": "Padam Penonjolan",
|
||||
"Highlight": "Tonjolkan",
|
||||
"Annotate": "Anotasi",
|
||||
"Search": "Cari",
|
||||
"Dictionary": "Kamus",
|
||||
"Wikipedia": "Wikipedia",
|
||||
"Translate": "Terjemah",
|
||||
"Speak": "Tutur",
|
||||
"Login Required": "Log Masuk Diperlukan",
|
||||
"Quota Exceeded": "Kuota Terlebih",
|
||||
"Unable to fetch the translation. Please log in first and try again.": "Tidak dapat mendapatkan terjemahan. Sila log masuk dahulu dan cuba lagi.",
|
||||
"Unable to fetch the translation. Try again later.": "Tidak dapat mendapatkan terjemahan. Cuba lagi kemudian.",
|
||||
"Original Text": "Teks Asal",
|
||||
"Auto Detect": "Kesan Auto",
|
||||
"(detected)": "(dikesan)",
|
||||
"Translated Text": "Teks Terjemahan",
|
||||
"System Language": "Bahasa Sistem",
|
||||
"No translation available.": "Tiada terjemahan tersedia.",
|
||||
"Translated by {{provider}}.": "Diterjemah oleh {{provider}}.",
|
||||
"Remove Bookmark": "Alih Keluar Tandabuku",
|
||||
"Add Bookmark": "Tambah Tandabuku",
|
||||
"Books Content": "Kandungan Buku",
|
||||
"Book Content": "Kandungan Buku",
|
||||
"Screen Brightness": "Kecerahan Skrin",
|
||||
"Color": "Warna",
|
||||
"Previous Section": "Seksyen Sebelumnya",
|
||||
"Next Section": "Seksyen Seterusnya",
|
||||
"Previous Page": "Halaman Sebelumnya",
|
||||
"Next Page": "Halaman Seterusnya",
|
||||
"Go Forward": "Ke Hadapan",
|
||||
"Reading Progress": "Kemajuan Membaca",
|
||||
"Jump to Location": "Lompat ke Lokasi",
|
||||
"Font Size": "Saiz Fon",
|
||||
"Page Margin": "Margin Halaman",
|
||||
"Small": "Kecil",
|
||||
"Large": "Besar",
|
||||
"Line Spacing": "Jarak Baris",
|
||||
"Footer Bar": "Bar Pengaki",
|
||||
"Table of Contents": "Kandungan",
|
||||
"Font & Layout": "Fon & Susun Atur",
|
||||
"Header Bar": "Bar Pengepala",
|
||||
"View Options": "Pilihan Paparan",
|
||||
"Sync Conflict": "Konflik Segerak",
|
||||
"Sync reading progress from \"{{deviceName}}\"?": "Segerakkan kemajuan membaca dari \"{{deviceName}}\"?",
|
||||
"another device": "peranti lain",
|
||||
"Local Progress": "Kemajuan Tempatan",
|
||||
"Remote Progress": "Kemajuan Jauh",
|
||||
"Failed to connect": "Gagal menyambung",
|
||||
"Disconnected": "Terputus",
|
||||
"KOReader Sync Settings": "Tetapan Segerak KOReader",
|
||||
"Sync as {{userDisplayName}}": "Segerak sebagai {{userDisplayName}}",
|
||||
"Sync Server Connected": "Pelayan Segerak Disambung",
|
||||
"Sync Strategy": "Strategi Segerak",
|
||||
"Ask on conflict": "Tanya jika konflik",
|
||||
"Always use latest": "Sentiasa guna terkini",
|
||||
"Send changes only": "Hantar perubahan sahaja",
|
||||
"Receive changes only": "Terima perubahan sahaja",
|
||||
"Checksum Method": "Kaedah Semak Jumlah",
|
||||
"File Content (recommended)": "Kandungan Fail (disyorkan)",
|
||||
"File Name": "Nama Fail",
|
||||
"Device Name": "Nama Peranti",
|
||||
"Connect to your KOReader Sync server.": "Sambung ke pelayan Segerak KOReader anda.",
|
||||
"Server URL": "URL Pelayan",
|
||||
"Username": "Nama Pengguna",
|
||||
"Your Username": "Nama pengguna anda",
|
||||
"Password": "Kata Laluan",
|
||||
"Connect": "Sambung",
|
||||
"Notebook": "Buku Nota",
|
||||
"Unpin Notebook": "Nyahpin Buku Nota",
|
||||
"Pin Notebook": "Pin Buku Nota",
|
||||
"Hide Search Bar": "Sembunyikan Bar Carian",
|
||||
"Show Search Bar": "Tunjuk Bar Carian",
|
||||
"Resize Notebook": "Ubah Saiz Buku Nota",
|
||||
"No notes match your search": "Tiada nota sepadan dengan carian anda",
|
||||
"Excerpts": "Petikan",
|
||||
"Notes": "Nota",
|
||||
"Add your notes here...": "Tambah nota anda di sini...",
|
||||
"Search notes and excerpts...": "Cari nota dan petikan...",
|
||||
"{{time}} min left in chapter": "{{time}} min lagi dalam bab",
|
||||
"{{number}} pages left in chapter": "{{number}} halaman lagi dalam bab",
|
||||
"{{count}} pages left in chapter_other": "{{count}} halaman lagi dalam bab",
|
||||
"On {{current}} of {{total}} page": "Di halaman {{current}} daripada {{total}}",
|
||||
"Unable to open book": "Tidak dapat membuka buku",
|
||||
"Section Title": "Tajuk Seksyen",
|
||||
"More Info": "Maklumat Lanjut",
|
||||
"Parallel Read": "Baca Selari",
|
||||
"Disable": "Lumpuhkan",
|
||||
"Enable": "Aktifkan",
|
||||
"Exit Parallel Read": "Keluar Baca Selari",
|
||||
"Enter Parallel Read": "Masuk Baca Selari",
|
||||
"KOReader Sync": "Segerak KOReader",
|
||||
"Push Progress": "Hantar Kemajuan",
|
||||
"Pull Progress": "Tarik Kemajuan",
|
||||
"Export Annotations": "Eksport Anotasi",
|
||||
"Sort TOC by Page": "Susun Kandungan mengikut Halaman",
|
||||
"Edit": "Edit",
|
||||
"Go to Library": "Pergi ke Perpustakaan",
|
||||
"Book Menu": "Menu Buku",
|
||||
"Unpin Sidebar": "Nyahpin Bar Sisi",
|
||||
"Pin Sidebar": "Pin Bar Sisi",
|
||||
"Search...": "Cari...",
|
||||
"Search Options": "Pilihan Carian",
|
||||
"Book": "Buku",
|
||||
"Chapter": "Bab",
|
||||
"Match Case": "Padankan Kes",
|
||||
"Match Whole Words": "Padankan Seluruh Perkataan",
|
||||
"Match Diacritics": "Padankan Diakritik",
|
||||
"Sidebar": "Bar Sisi",
|
||||
"Resize Sidebar": "Ubah Saiz Bar Sisi",
|
||||
"TOC": "Kandungan",
|
||||
"Bookmark": "Tandabuku",
|
||||
"Toggle Sidebar": "Togol Bar Sisi",
|
||||
"Toggle Translation": "Togol Terjemahan",
|
||||
"Disable Translation": "Lumpuhkan Terjemahan",
|
||||
"Enable Translation": "Aktifkan Terjemahan",
|
||||
"Translation Disabled": "Terjemahan Dilumpuhkan",
|
||||
"Previous Paragraph": "Perenggan Sebelumnya",
|
||||
"Previous Sentence": "Ayat Sebelumnya",
|
||||
"Pause": "Jeda",
|
||||
"Play": "Main",
|
||||
"Next Sentence": "Ayat Seterusnya",
|
||||
"Next Paragraph": "Perenggan Seterusnya",
|
||||
"Read Aloud": "Baca Dengan Kuat",
|
||||
"Ready to read aloud": "Bersedia untuk baca dengan kuat",
|
||||
"TTS not supported for PDF": "TTS tidak disokong untuk PDF",
|
||||
"TTS not supported for this document": "TTS tidak disokong untuk dokumen ini",
|
||||
"No Timeout": "Tiada Had Masa",
|
||||
"{{value}} minute": "{{value}} minit",
|
||||
"{{value}} minutes": "{{value}} minit",
|
||||
"{{value}} hour": "{{value}} jam",
|
||||
"{{value}} hours": "{{value}} jam",
|
||||
"Voices for {{lang}}": "Suara untuk {{lang}}",
|
||||
"Slow": "Perlahan",
|
||||
"Fast": "Pantas",
|
||||
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} suara",
|
||||
"Zoom Level": "Tahap Zum",
|
||||
"Zoom Out": "Zum Keluar",
|
||||
"Reset Zoom": "Tetapkan Semula Zum",
|
||||
"Zoom In": "Zum Masuk",
|
||||
"Zoom Mode": "Mod Zum",
|
||||
"Single Page": "Halaman Tunggal",
|
||||
"Auto Spread": "Hamparan Auto",
|
||||
"Fit Page": "Muat Halaman",
|
||||
"Fit Width": "Muat Lebar",
|
||||
"Separate Cover Page": "Halaman Kulit Berasingan",
|
||||
"Scrolled Mode": "Mod Tatal",
|
||||
"Sign in to Sync": "Log masuk untuk Segerak",
|
||||
"Synced at {{time}}": "Disegerak pada {{time}}",
|
||||
"Never synced": "Tidak pernah disegerak",
|
||||
"Invert Image In Dark Mode": "Songsangkan Imej Dalam Mod Gelap",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Gagal auto-simpan kulit buku untuk skrin kunci: {{error}}",
|
||||
"Reading Progress Synced": "Kemajuan Membaca Disegerak",
|
||||
"Page {{page}} of {{total}} ({{percentage}}%)": "Halaman {{page}} daripada {{total}} ({{percentage}}%)",
|
||||
"Current position": "Kedudukan semasa",
|
||||
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Lebih kurang halaman {{page}} daripada {{total}} ({{percentage}}%)",
|
||||
"Approximately {{percentage}}%": "Lebih kurang {{percentage}}%",
|
||||
"Delete Your Account?": "Padam Akaun Anda?",
|
||||
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Tindakan ini tidak boleh dibatalkan. Semua data anda di awan akan dipadam secara kekal.",
|
||||
"Delete Permanently": "Padam Kekal",
|
||||
"Restore Purchase": "Pulihkan Pembelian",
|
||||
"Manage Subscription": "Urus Langganan",
|
||||
"Reset Password": "Tetapkan Semula Kata Laluan",
|
||||
"Update Email": "Kemas Kini E-mel",
|
||||
"Sign Out": "Log Keluar",
|
||||
"Delete Account": "Padam Akaun",
|
||||
"Upgrade to {{plan}}": "Naik Taraf ke {{plan}}",
|
||||
"Complete Your Subscription": "Lengkapkan Langganan Anda",
|
||||
"Coming Soon": "Akan Datang",
|
||||
"Upgrade to Plus or Pro": "Naik Taraf ke Plus atau Pro",
|
||||
"Current Plan": "Pelan Semasa",
|
||||
"On-Demand Purchase": "Pembelian Atas Permintaan",
|
||||
"Plan Limits": "Had Pelan",
|
||||
"Full Customization": "Penyesuaian Penuh",
|
||||
"Failed to create checkout session": "Gagal mencipta sesi pembayaran",
|
||||
"No purchases found to restore.": "Tiada pembelian dijumpai untuk dipulihkan.",
|
||||
"Failed to restore purchases.": "Gagal memulihkan pembelian.",
|
||||
"Failed to manage subscription.": "Gagal mengurus langganan.",
|
||||
"Failed to delete user. Please try again later.": "Gagal memadam pengguna. Sila cuba lagi kemudian.",
|
||||
"Loading profile...": "Memuatkan profil...",
|
||||
"Processing your payment...": "Memproses pembayaran anda...",
|
||||
"Please wait while we confirm your subscription.": "Sila tunggu sementara kami mengesahkan langganan anda.",
|
||||
"Payment Processing": "Pemprosesan Pembayaran",
|
||||
"Your payment is being processed. This usually takes a few moments.": "Pembayaran anda sedang diproses. Ini biasanya mengambil masa sebentar.",
|
||||
"Payment Failed": "Pembayaran Gagal",
|
||||
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "Kami tidak dapat memproses langganan anda. Sila cuba lagi atau hubungi sokongan jika masalah berterusan.",
|
||||
"Back to Profile": "Kembali ke Profil",
|
||||
"Purchase Successful!": "Pembelian Berjaya!",
|
||||
"Subscription Successful!": "Langganan Berjaya!",
|
||||
"Thank you for your purchase! Your payment has been processed successfully.": "Terima kasih atas pembelian anda! Pembayaran anda telah diproses dengan jayanya.",
|
||||
"Thank you for your subscription! Your payment has been processed successfully.": "Terima kasih atas langganan anda! Pembayaran anda telah diproses dengan jayanya.",
|
||||
"Email:": "E-mel:",
|
||||
"Plan:": "Pelan:",
|
||||
"Amount:": "Jumlah:",
|
||||
"Order ID:": "ID Pesanan:",
|
||||
"Need help? Contact our support team at support@readest.com": "Perlukan bantuan? Hubungi pasukan sokongan kami di support@readest.com",
|
||||
"Lifetime Plan": "Pelan Seumur Hidup",
|
||||
"lifetime": "seumur hidup",
|
||||
"One-Time Payment": "Bayaran Sekali",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Buat bayaran sekali untuk menikmati akses seumur hidup kepada ciri tertentu pada semua peranti. Beli ciri atau perkhidmatan tertentu hanya apabila anda memerlukannya.",
|
||||
"Expand Cloud Sync Storage": "Kembangkan Storan Segerak Awan",
|
||||
"Expand your cloud storage forever with a one-time purchase. Each additional purchase adds more space.": "Kembangkan storan awan anda selamanya dengan pembelian sekali. Setiap pembelian tambahan menambah lebih banyak ruang.",
|
||||
"Unlock All Customization Options": "Buka Kunci Semua Pilihan Penyesuaian",
|
||||
"Unlock additional themes, fonts, layout options and read aloud, translators, cloud storage services.": "Buka kunci tema tambahan, fon, pilihan susun atur dan baca dengan kuat, penterjemah, perkhidmatan storan awan.",
|
||||
"Free Plan": "Pelan Percuma",
|
||||
"month": "bulan",
|
||||
"year": "tahun",
|
||||
"Cross-Platform Sync": "Segerak Merentas Platform",
|
||||
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "Segerakkan perpustakaan, kemajuan, penonjolan dan nota anda dengan lancar merentas semua peranti anda—tidak akan kehilangan tempat anda lagi.",
|
||||
"Customizable Reading": "Pembacaan Boleh Disesuaikan",
|
||||
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "Personalisasikan setiap butiran dengan fon boleh laras, susun atur, tema dan tetapan paparan lanjutan untuk pengalaman membaca yang sempurna.",
|
||||
"AI Read Aloud": "Baca Dengan Kuat AI",
|
||||
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "Nikmati pembacaan tanpa tangan dengan suara AI yang berbunyi semula jadi yang menghidupkan buku anda.",
|
||||
"AI Translations": "Terjemahan AI",
|
||||
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Terjemahkan sebarang teks dengan segera dengan kuasa Google, Azure atau DeepL—fahami kandungan dalam mana-mana bahasa.",
|
||||
"Community Support": "Sokongan Komuniti",
|
||||
"Connect with fellow readers and get help fast in our friendly community channels.": "Berhubung dengan pembaca lain dan dapatkan bantuan pantas dalam saluran komuniti mesra kami.",
|
||||
"Cloud Sync Storage": "Storan Segerak Awan",
|
||||
"AI Translations (per day)": "Terjemahan AI (setiap hari)",
|
||||
"Plus Plan": "Pelan Plus",
|
||||
"Includes All Free Plan Benefits": "Termasuk Semua Faedah Pelan Percuma",
|
||||
"Unlimited AI Read Aloud Hours": "Waktu Baca Dengan Kuat AI Tanpa Had",
|
||||
"Listen without limits—convert as much text as you like into immersive audio.": "Dengar tanpa had—tukar sebanyak mana teks yang anda suka menjadi audio yang mengasyikkan.",
|
||||
"More AI Translations": "Lebih Banyak Terjemahan AI",
|
||||
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Buka kunci keupayaan terjemahan yang dipertingkatkan dengan lebih banyak penggunaan harian dan pilihan lanjutan.",
|
||||
"DeepL Pro Access": "Akses DeepL Pro",
|
||||
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Terjemahkan sehingga 100,000 aksara setiap hari dengan enjin terjemahan paling tepat yang tersedia.",
|
||||
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Simpan dan akses koleksi bacaan anda dengan selamat dengan sehingga 5 GB storan awan.",
|
||||
"Priority Support": "Sokongan Keutamaan",
|
||||
"Enjoy faster responses and dedicated assistance whenever you need help.": "Nikmati respons yang lebih pantas dan bantuan khusus apabila anda memerlukan bantuan.",
|
||||
"Pro Plan": "Pelan Pro",
|
||||
"Includes All Plus Plan Benefits": "Termasuk Semua Faedah Pelan Plus",
|
||||
"Early Feature Access": "Akses Ciri Awal",
|
||||
"Be the first to explore new features, updates, and innovations before anyone else.": "Jadilah yang pertama meneroka ciri baharu, kemas kini dan inovasi sebelum orang lain.",
|
||||
"Advanced AI Tools": "Alat AI Lanjutan",
|
||||
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Manfaatkan alat AI yang berkuasa untuk pembacaan, terjemahan dan penemuan kandungan yang lebih bijak.",
|
||||
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Terjemahkan sehingga 500,000 aksara setiap hari dengan enjin terjemahan paling tepat yang tersedia.",
|
||||
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Simpan dan akses koleksi bacaan anda dengan selamat dengan sehingga 20 GB storan awan.",
|
||||
"Version {{version}}": "Versi {{version}}",
|
||||
"Check Update": "Semak Kemas Kini",
|
||||
"Already the latest version": "Sudah versi terkini",
|
||||
"Checking for updates...": "Memeriksa kemas kini...",
|
||||
"Error checking for updates": "Ralat memeriksa kemas kini",
|
||||
"Drop to Import Books": "Lepas untuk Import Buku",
|
||||
"Terms of Service": "Terma Perkhidmatan",
|
||||
"Privacy Policy": "Dasar Privasi",
|
||||
"ON": "HIDUP",
|
||||
"OFF": "MATI",
|
||||
"Enter book title": "Masukkan tajuk buku",
|
||||
"Subtitle": "Subtajuk",
|
||||
"Enter book subtitle": "Masukkan subtajuk buku",
|
||||
"Enter author name": "Masukkan nama pengarang",
|
||||
"Series": "Siri",
|
||||
"Enter series name": "Masukkan nama siri",
|
||||
"Series Index": "Indeks Siri",
|
||||
"Enter series index": "Masukkan indeks siri",
|
||||
"Total in Series": "Jumlah dalam Siri",
|
||||
"Enter total books in series": "Masukkan jumlah buku dalam siri",
|
||||
"Publisher": "Penerbit",
|
||||
"Enter publisher": "Masukkan penerbit",
|
||||
"Publication Date": "Tarikh Penerbitan",
|
||||
"YYYY or YYYY-MM-DD": "TTTT atau TTTT-BB-HH",
|
||||
"Language": "Bahasa",
|
||||
"Identifier": "Pengenal Pasti",
|
||||
"Subjects": "Subjek",
|
||||
"Fiction, Science, History": "Fiksyen, Sains, Sejarah",
|
||||
"Description": "Penerangan",
|
||||
"Enter book description": "Masukkan penerangan buku",
|
||||
"Change cover image": "Tukar imej kulit",
|
||||
"Replace": "Ganti",
|
||||
"Remove cover image": "Alih keluar imej kulit",
|
||||
"Unlock cover": "Buka kunci kulit",
|
||||
"Lock cover": "Kunci kulit",
|
||||
"Auto-Retrieve Metadata": "Auto-Dapatkan Metadata",
|
||||
"Auto": "Auto",
|
||||
"Auto-Retrieve": "Auto-Dapatkan",
|
||||
"Unlock all fields": "Buka kunci semua medan",
|
||||
"Unlock All": "Buka Kunci Semua",
|
||||
"Lock all fields": "Kunci semua medan",
|
||||
"Lock All": "Kunci Semua",
|
||||
"Reset": "Tetapkan Semula",
|
||||
"Are you sure to delete the selected book?": "Adakah anda pasti untuk memadam buku yang dipilih?",
|
||||
"Are you sure to delete the cloud backup of the selected book?": "Adakah anda pasti untuk memadam sandaran awan buku yang dipilih?",
|
||||
"Are you sure to delete the local copy of the selected book?": "Adakah anda pasti untuk memadam salinan tempatan buku yang dipilih?",
|
||||
"Edit Metadata": "Edit Metadata",
|
||||
"Book Details": "Butiran Buku",
|
||||
"Unknown": "Tidak Diketahui",
|
||||
"Delete Book Options": "Pilihan Padam Buku",
|
||||
"Remove from Cloud & Device": "Alih Keluar dari Awan & Peranti",
|
||||
"Remove from Cloud Only": "Alih Keluar dari Awan Sahaja",
|
||||
"Remove from Device Only": "Alih Keluar dari Peranti Sahaja",
|
||||
"Download from Cloud": "Muat Turun dari Awan",
|
||||
"Upload to Cloud": "Muat Naik ke Awan",
|
||||
"Published": "Diterbitkan",
|
||||
"Updated": "Dikemas kini",
|
||||
"Added": "Ditambah",
|
||||
"File Size": "Saiz Fail",
|
||||
"No description available": "Tiada penerangan tersedia",
|
||||
"Locked": "Dikunci",
|
||||
"Select Metadata Source": "Pilih Sumber Metadata",
|
||||
"Keep manual input": "Kekalkan input manual",
|
||||
"Background Image": "Imej Latar Belakang",
|
||||
"Import Image": "Import Imej",
|
||||
"Opacity": "Kelegapan",
|
||||
"Size": "Saiz",
|
||||
"Cover": "Kulit",
|
||||
"Contain": "Kandungan",
|
||||
"Code Highlighting": "Penonjolan Kod",
|
||||
"Enable Highlighting": "Aktifkan Penonjolan",
|
||||
"Code Language": "Bahasa Kod",
|
||||
"Highlight Colors": "Warna Penonjolan",
|
||||
"Theme Color": "Warna Tema",
|
||||
"Custom": "Tersuai",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Seluruh dunia adalah pentas,\nDan semua lelaki dan wanita hanyalah pelakon;\nMereka ada pintu keluar dan pintu masuk mereka,\nDan seseorang dalam masanya memainkan banyak peranan,\nTindakannya adalah tujuh zaman.\n\n— William Shakespeare",
|
||||
"(from 'As You Like It', Act II)": "(daripada 'As You Like It', Babak II)",
|
||||
"Custom Theme": "Tema Tersuai",
|
||||
"Theme Name": "Nama Tema",
|
||||
"Text Color": "Warna Teks",
|
||||
"Background Color": "Warna Latar Belakang",
|
||||
"Link Color": "Warna Pautan",
|
||||
"Preview": "Pratonton",
|
||||
"Theme Mode": "Mod Tema",
|
||||
"TTS Highlighting": "Penonjolan TTS",
|
||||
"Style": "Gaya",
|
||||
"Highlighter": "Penanda",
|
||||
"Underline": "Garis Bawah",
|
||||
"Strikethrough": "Garis Lorek",
|
||||
"Squiggly": "Berkelok-kelok",
|
||||
"Outline": "Garis Besar",
|
||||
"Save Current Color": "Simpan Warna Semasa",
|
||||
"Quick Colors": "Warna Pantas",
|
||||
"Override Book Color": "Ganti Warna Buku",
|
||||
"Scroll": "Tatal",
|
||||
"Continuous Scroll": "Tatal Berterusan",
|
||||
"Overlap Pixels": "Piksel Bertindih",
|
||||
"Pagination": "Penomboran Halaman",
|
||||
"Tap to Paginate": "Ketik untuk Nomborkan Halaman",
|
||||
"Click to Paginate": "Klik untuk Nomborkan Halaman",
|
||||
"Tap Both Sides": "Ketik Kedua-dua Sisi",
|
||||
"Click Both Sides": "Klik Kedua-dua Sisi",
|
||||
"Swap Tap Sides": "Tukar Sisi Ketukan",
|
||||
"Swap Click Sides": "Tukar Sisi Klik",
|
||||
"Disable Double Tap": "Lumpuhkan Ketuk Dua Kali",
|
||||
"Disable Double Click": "Lumpuhkan Klik Dua Kali",
|
||||
"Volume Keys for Page Flip": "Kekunci Volum untuk Balik Halaman",
|
||||
"Animation": "Animasi",
|
||||
"Paging Animation": "Animasi Halaman",
|
||||
"Device": "Peranti",
|
||||
"E-Ink Mode": "Mod E-Ink",
|
||||
"Auto Screen Brightness": "Kecerahan Skrin Auto",
|
||||
"Security": "Keselamatan",
|
||||
"Allow JavaScript": "Benarkan JavaScript",
|
||||
"Enable only if you trust the file.": "Aktifkan hanya jika anda mempercayai fail.",
|
||||
"Font": "Fon",
|
||||
"Custom Fonts": "Fon Tersuai",
|
||||
"Cancel Delete": "Batal Pemadaman",
|
||||
"Delete Font": "Padam Fon",
|
||||
"Import Font": "Import Fon",
|
||||
"Tips": "Petua",
|
||||
"Supported font formats: .ttf, .otf, .woff, .woff2": "Format fon yang disokong: .ttf, .otf, .woff, .woff2",
|
||||
"Custom fonts can be selected from the Font Face menu": "Fon tersuai boleh dipilih dari menu Muka Fon",
|
||||
"Global Settings": "Tetapan Global",
|
||||
"Apply to All Books": "Gunakan pada Semua Buku",
|
||||
"Apply to This Book": "Gunakan pada Buku Ini",
|
||||
"Reset Settings": "Tetapkan Semula Tetapan",
|
||||
"Clear Custom Fonts": "Kosongkan Fon Tersuai",
|
||||
"Manage Custom Fonts": "Urus Fon Tersuai",
|
||||
"System Fonts": "Fon Sistem",
|
||||
"Serif Font": "Fon Serif",
|
||||
"Sans-Serif Font": "Fon Sans-Serif",
|
||||
"Override Book Font": "Ganti Fon Buku",
|
||||
"Default Font Size": "Saiz Fon Lalai",
|
||||
"Minimum Font Size": "Saiz Fon Minimum",
|
||||
"Font Weight": "Berat Fon",
|
||||
"Font Family": "Keluarga Fon",
|
||||
"Default Font": "Fon Lalai",
|
||||
"CJK Font": "Fon CJK",
|
||||
"Font Face": "Muka Fon",
|
||||
"Monospace Font": "Fon Monospace",
|
||||
"Source and Translated": "Sumber dan Terjemahan",
|
||||
"Translated Only": "Terjemahan Sahaja",
|
||||
"Source Only": "Sumber Sahaja",
|
||||
"No Conversion": "Tiada Penukaran",
|
||||
"Simplified to Traditional": "Ringkas ke Tradisional",
|
||||
"Traditional to Simplified": "Tradisional ke Ringkas",
|
||||
"Simplified to Traditional (Taiwan)": "Ringkas ke Tradisional (Taiwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Ringkas ke Tradisional (Hong Kong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Ringkas ke Tradisional (Taiwan), dengan frasa",
|
||||
"Traditional (Taiwan) to Simplified": "Tradisional (Taiwan) ke Ringkas",
|
||||
"Traditional (Hong Kong) to Simplified": "Tradisional (Hong Kong) ke Ringkas",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Tradisional (Taiwan) ke Ringkas, dengan frasa",
|
||||
"Interface Language": "Bahasa Antara Muka",
|
||||
"Translation": "Terjemahan",
|
||||
"Show Source Text": "Tunjuk Teks Sumber",
|
||||
"TTS Text": "Teks TTS",
|
||||
"Translation Service": "Perkhidmatan Terjemahan",
|
||||
"Translate To": "Terjemah Ke",
|
||||
"Punctuation": "Tanda Baca",
|
||||
"Replace Quotation Marks": "Ganti Tanda Petikan",
|
||||
"Enabled only in vertical layout.": "Diaktifkan hanya dalam susun atur menegak.",
|
||||
"Convert Simplified and Traditional Chinese": "Tukar Cina Ringkas dan Tradisional",
|
||||
"Convert Mode": "Mod Tukar",
|
||||
"Override Book Layout": "Ganti Susun Atur Buku",
|
||||
"Writing Mode": "Mod Penulisan",
|
||||
"Default": "Lalai",
|
||||
"Horizontal Direction": "Arah Mendatar",
|
||||
"Vertical Direction": "Arah Menegak",
|
||||
"RTL Direction": "Arah RTL",
|
||||
"Border Frame": "Bingkai Sempadan",
|
||||
"Double Border": "Sempadan Berganda",
|
||||
"Border Color": "Warna Sempadan",
|
||||
"Paragraph": "Perenggan",
|
||||
"Paragraph Margin": "Margin Perenggan",
|
||||
"Word Spacing": "Jarak Perkataan",
|
||||
"Letter Spacing": "Jarak Huruf",
|
||||
"Text Indent": "Inden Teks",
|
||||
"Full Justification": "Justifikasi Penuh",
|
||||
"Hyphenation": "Pemenggalan",
|
||||
"Page": "Halaman",
|
||||
"Top Margin (px)": "Margin Atas (px)",
|
||||
"Bottom Margin (px)": "Margin Bawah (px)",
|
||||
"Left Margin (px)": "Margin Kiri (px)",
|
||||
"Right Margin (px)": "Margin Kanan (px)",
|
||||
"Column Gap (%)": "Jurang Lajur (%)",
|
||||
"Maximum Number of Columns": "Bilangan Maksimum Lajur",
|
||||
"Maximum Column Height": "Tinggi Maksimum Lajur",
|
||||
"Maximum Column Width": "Lebar Maksimum Lajur",
|
||||
"Apply also in Scrolled Mode": "Gunakan juga dalam Mod Tatal",
|
||||
"Header & Footer": "Pengepala & Pengaki",
|
||||
"Show Header": "Tunjuk Pengepala",
|
||||
"Show Footer": "Tunjuk Pengaki",
|
||||
"Show Remaining Time": "Tunjuk Masa Berbaki",
|
||||
"Show Remaining Pages": "Tunjuk Halaman Berbaki",
|
||||
"Show Reading Progress": "Tunjuk Kemajuan Membaca",
|
||||
"Reading Progress Style": "Gaya Kemajuan Membaca",
|
||||
"Page Number": "Nombor Halaman",
|
||||
"Percentage": "Peratusan",
|
||||
"Screen": "Skrin",
|
||||
"Orientation": "Orientasi",
|
||||
"Portrait": "Potret",
|
||||
"Landscape": "Landskap",
|
||||
"Apply": "Gunakan",
|
||||
"Custom Content CSS": "CSS Kandungan Tersuai",
|
||||
"Enter CSS for book content styling...": "Masukkan CSS untuk penggayaan kandungan buku...",
|
||||
"Custom Reader UI CSS": "CSS UI Pembaca Tersuai",
|
||||
"Enter CSS for reader interface styling...": "Masukkan CSS untuk penggayaan antara muka pembaca...",
|
||||
"Decrease": "Kurangkan",
|
||||
"Increase": "Tambahkan",
|
||||
"Layout": "Susun Atur",
|
||||
"Behavior": "Tingkah Laku",
|
||||
"Settings Panels": "Panel Tetapan",
|
||||
"Reset {{settings}}": "Tetapkan Semula {{settings}}",
|
||||
"Get Help from the Readest Community": "Dapatkan Bantuan dari Komuniti Readest",
|
||||
"A new version of Readest is available!": "Versi baharu Readest tersedia!",
|
||||
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} tersedia (versi terpasang {{currentVersion}}).",
|
||||
"Download and install now?": "Muat turun dan pasang sekarang?",
|
||||
"Downloading {{downloaded}} of {{contentLength}}": "Memuat turun {{downloaded}} daripada {{contentLength}}",
|
||||
"Download finished": "Muat turun selesai",
|
||||
"DOWNLOAD & INSTALL": "MUAT TURUN & PASANG",
|
||||
"Changelog": "Log Perubahan",
|
||||
"Software Update": "Kemas Kini Perisian",
|
||||
"What's New in Readest": "Apa Yang Baharu dalam Readest",
|
||||
"Minimize": "Minima",
|
||||
"Maximize or Restore": "Maksima atau Pulihkan",
|
||||
"Failed to load subscription plans.": "Gagal memuatkan pelan langganan.",
|
||||
"Select Files": "Pilih Fail",
|
||||
"Select Image": "Pilih Imej",
|
||||
"Select Video": "Pilih Video",
|
||||
"Select Audio": "Pilih Audio",
|
||||
"Select Fonts": "Pilih Fon",
|
||||
"Storage": "Storan",
|
||||
"{{percentage}}% of Cloud Sync Space Used.": "{{percentage}}% Ruang Segerak Awan Digunakan.",
|
||||
"Translation Characters": "Aksara Terjemahan",
|
||||
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% Aksara Terjemahan Harian Digunakan.",
|
||||
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Kuota terjemahan harian dicapai. Naik taraf pelan anda untuk terus menggunakan terjemahan AI.",
|
||||
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
|
||||
"LXGW WenKai TC": "LXGW WenKai TC",
|
||||
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
|
||||
"Source Han Serif CN VF": "Source Han Serif CN VF",
|
||||
"Huiwen-mincho": "Huiwen-mincho",
|
||||
"KingHwa_OldSong": "KingHwa_OldSong",
|
||||
"Google Books": "Google Books",
|
||||
"Open Library": "Open Library",
|
||||
"Azure Translator": "Azure Translator",
|
||||
"DeepL": "DeepL",
|
||||
"Google Translate": "Google Translate",
|
||||
"Yandex Translate": "Yandex Translate",
|
||||
"Gray": "Kelabu",
|
||||
"Sepia": "Sepia",
|
||||
"Grass": "Rumput",
|
||||
"Cherry": "Ceri",
|
||||
"Sky": "Langit",
|
||||
"Solarized": "Solarized",
|
||||
"Gruvbox": "Gruvbox",
|
||||
"Nord": "Nord",
|
||||
"Contrast": "Kontras",
|
||||
"Sunset": "Matahari Terbenam",
|
||||
"Reveal in Finder": "Tunjukkan dalam Finder",
|
||||
"Reveal in File Explorer": "Tunjukkan dalam File Explorer",
|
||||
"Reveal in Folder": "Tunjukkan dalam Folder",
|
||||
"Columns": "Lajur",
|
||||
"OPDS Catalogs": "Katalog OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Menambah alamat LAN tidak disokong dalam versi web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Katalog OPDS tidak sah. Sila semak URL.",
|
||||
"Browse and download books from online catalogs": "Terokai dan muat turun buku dari katalog dalam talian",
|
||||
"My Catalogs": "Katalog Saya",
|
||||
"Add Catalog": "Tambah Katalog",
|
||||
"No catalogs yet": "Tiada katalog lagi",
|
||||
"Add your first OPDS catalog to start browsing books": "Tambah katalog OPDS pertama anda untuk mula meneroka buku",
|
||||
"Add Your First Catalog": "Tambah Katalog Pertama Anda",
|
||||
"Browse": "Terokai",
|
||||
"Popular Catalogs": "Katalog Popular",
|
||||
"Add": "Tambah",
|
||||
"Add OPDS Catalog": "Tambah Katalog OPDS",
|
||||
"Catalog Name": "Nama Katalog",
|
||||
"My Calibre Library": "Perpustakaan Calibre Saya",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Nama Pengguna (pilihan)",
|
||||
"Password (optional)": "Kata Laluan (pilihan)",
|
||||
"Description (optional)": "Penerangan (pilihan)",
|
||||
"A brief description of this catalog": "Penerangan ringkas katalog ini",
|
||||
"Validating...": "Mengesahkan...",
|
||||
"View All": "Lihat Semua",
|
||||
"Forward": "Maju",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Laman Utama",
|
||||
"Library": "Perpustakaan",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
"Download completed": "Muat turun selesai",
|
||||
"Download failed": "Muat turun gagal",
|
||||
"Open Access": "Akses Terbuka",
|
||||
"Borrow": "Pinjam",
|
||||
"Buy": "Beli",
|
||||
"Subscribe": "Langgan",
|
||||
"Sample": "Contoh",
|
||||
"Download": "Muat turun",
|
||||
"Open & Read": "Buka & Baca",
|
||||
"Tags": "Tag",
|
||||
"Tag": "Tag",
|
||||
"First": "Pertama",
|
||||
"Previous": "Sebelumnya",
|
||||
"Next": "Seterusnya",
|
||||
"Last": "Terakhir",
|
||||
"Cannot Load Page": "Tidak dapat memuatkan halaman",
|
||||
"An error occurred": "Ralat telah berlaku",
|
||||
"Online Library": "Perpustakaan Dalam Talian"
|
||||
}
|
||||
@@ -196,7 +196,6 @@
|
||||
"Link Color": "Linkkleur",
|
||||
"Preview": "Voorbeeld",
|
||||
"Font & Layout": "Lettertype & opmaak",
|
||||
"Book Cover": "Boekomslag",
|
||||
"More Info": "Meer informatie",
|
||||
"Parallel Read": "Parallel lezen",
|
||||
"Export Annotations": "Annotaties exporteren",
|
||||
@@ -269,7 +268,7 @@
|
||||
"Reveal in File Explorer": "Tonen in Verkenner",
|
||||
"Reveal in Folder": "Tonen in map",
|
||||
"Don't have an account? Sign up": "Nog geen account? Registreer u",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "De hele wereld is een toneel,\nEn alle mannen en vrouwen slechts spelers;\nZij hebben hun uitgangen en hun entrees,\nEn één man speelt in zijn tijd vele rollen,\nZijn daden zijnde zeven leeftijden.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "De hele wereld is een toneel,\nEn alle mannen en vrouwen slechts spelers;\nZij hebben hun uitgangen en hun entrees,\nEn één man speelt in zijn tijd vele rollen,\nZijn daden zijnde zeven leeftijden.\n\n— William Shakespeare",
|
||||
"Next Section": "Volgende sectie",
|
||||
"Previous Section": "Vorige sectie",
|
||||
"Next Page": "Volgende pagina",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "E-mail bijwerken",
|
||||
"Current email": "Huidig e-mailadres",
|
||||
"Update Email": "E-mail bijwerken",
|
||||
"All": "Alles"
|
||||
"All": "Alles",
|
||||
"Unable to open book": "Kan boek niet openen",
|
||||
"Punctuation": "Interpunctie",
|
||||
"Replace Quotation Marks": "Aanhalingstekens vervangen",
|
||||
"Enabled only in vertical layout.": "Alleen ingeschakeld in verticale lay-out.",
|
||||
"No Conversion": "Geen conversie",
|
||||
"Simplified to Traditional": "Vereenvoudigd → Traditioneel",
|
||||
"Traditional to Simplified": "Traditioneel → Vereenvoudigd",
|
||||
"Simplified to Traditional (Taiwan)": "Vereenvoudigd → Traditioneel (Taiwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Vereenvoudigd → Traditioneel (Hong Kong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Vereenvoudigd → Traditioneel (Taiwan • zinnen)",
|
||||
"Traditional (Taiwan) to Simplified": "Traditioneel (Taiwan) → Vereenvoudigd",
|
||||
"Traditional (Hong Kong) to Simplified": "Traditioneel (Hong Kong) → Vereenvoudigd",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Traditioneel (Taiwan • zinnen) → Vereenvoudigd",
|
||||
"Convert Simplified and Traditional Chinese": "Converteer Vereenvoudigd/Traditioneel Chinees",
|
||||
"Convert Mode": "Conversiemodus",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Automatisch opslaan van boekomslag voor vergrendelscherm mislukt: {{error}}",
|
||||
"Download from Cloud": "Downloaden van Cloud",
|
||||
"Upload to Cloud": "Uploaden naar Cloud",
|
||||
"Clear Custom Fonts": "Aangepaste Lettertypen Wissen",
|
||||
"Columns": "Kolommen",
|
||||
"OPDS Catalogs": "OPDS-catalogi",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Het toevoegen van LAN-adressen wordt niet ondersteund in de webversie.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Ongeldige OPDS-catalogus. Controleer de URL.",
|
||||
"Browse and download books from online catalogs": "Blader door en download boeken uit online catalogi",
|
||||
"My Catalogs": "Mijn catalogi",
|
||||
"Add Catalog": "Catalogus toevoegen",
|
||||
"No catalogs yet": "Nog geen catalogi",
|
||||
"Add your first OPDS catalog to start browsing books": "Voeg je eerste OPDS-catalogus toe om boeken te bekijken",
|
||||
"Add Your First Catalog": "Voeg je eerste catalogus toe",
|
||||
"Browse": "Bladeren",
|
||||
"Popular Catalogs": "Populaire catalogi",
|
||||
"Add": "Toevoegen",
|
||||
"Add OPDS Catalog": "OPDS-catalogus toevoegen",
|
||||
"Catalog Name": "Catalogusnaam",
|
||||
"My Calibre Library": "Mijn Calibre-bibliotheek",
|
||||
"OPDS URL": "OPDS-URL",
|
||||
"Username (optional)": "Gebruikersnaam (optioneel)",
|
||||
"Password (optional)": "Wachtwoord (optioneel)",
|
||||
"Description (optional)": "Beschrijving (optioneel)",
|
||||
"A brief description of this catalog": "Een korte beschrijving van deze catalogus",
|
||||
"Validating...": "Valideren...",
|
||||
"View All": "Alles bekijken",
|
||||
"Forward": "Verder",
|
||||
"OPDS Catalog": "OPDS-catalogus",
|
||||
"Home": "Startpagina",
|
||||
"Library": "Bibliotheek",
|
||||
"{{count}} items_one": "{{count}} item",
|
||||
"{{count}} items_other": "{{count}} items",
|
||||
"Download completed": "Download voltooid",
|
||||
"Download failed": "Download mislukt",
|
||||
"Open Access": "Open toegang",
|
||||
"Borrow": "Lenen",
|
||||
"Buy": "Kopen",
|
||||
"Subscribe": "Abonneren",
|
||||
"Sample": "Voorbeeld",
|
||||
"Download": "Downloaden",
|
||||
"Open & Read": "Openen & Lezen",
|
||||
"Tags": "Tags",
|
||||
"Tag": "Tag",
|
||||
"First": "Eerste",
|
||||
"Previous": "Vorige",
|
||||
"Next": "Volgende",
|
||||
"Last": "Laatste",
|
||||
"Cannot Load Page": "Pagina kan niet worden geladen",
|
||||
"An error occurred": "Er is een fout opgetreden",
|
||||
"Online Library": "Online Bibliotheek"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Tryb automatyczny",
|
||||
"Behavior": "Zachowanie",
|
||||
"Book": "Książka",
|
||||
"Book Cover": "Okładka książki",
|
||||
"Bookmark": "Zakładka",
|
||||
"Cancel": "Anuluj",
|
||||
"Chapter": "Rozdział",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Nie znaleziono obsługiwanych plików. Obsługiwane formaty: {{formats}}",
|
||||
"Drop to Import Books": "Upuść, aby zaimportować książki",
|
||||
"Custom": "Niestandardowy",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cały świat jest sceną,\nA wszyscy mężczyźni i kobiety są tylko aktorami;\nMają swoje wyjścia i wejścia,\nI jeden człowiek w swoim czasie gra wiele ról,\nJego czyny to siedem wieków.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cały świat jest sceną,\nA wszyscy mężczyźni i kobiety są tylko aktorami;\nMają swoje wyjścia i wejścia,\nI jeden człowiek w swoim czasie gra wiele ról,\nJego czyny to siedem wieków.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Niestandardowy motyw",
|
||||
"Theme Name": "Nazwa motywu",
|
||||
"Text Color": "Kolor tekstu",
|
||||
@@ -664,5 +663,73 @@
|
||||
"Update email": "Zaktualizuj e-mail",
|
||||
"Current email": "Obecny e-mail",
|
||||
"Update Email": "Zaktualizuj e-mail",
|
||||
"All": "Wszystkie"
|
||||
"All": "Wszystkie",
|
||||
"Unable to open book": "Nie można otworzyć książki",
|
||||
"Punctuation": "Interpunkcja",
|
||||
"Replace Quotation Marks": "Zamień cudzysłowy",
|
||||
"Enabled only in vertical layout.": "Włączone tylko w układzie pionowym.",
|
||||
"No Conversion": "Brak konwersji",
|
||||
"Simplified to Traditional": "Uproszczony → Tradycyjny",
|
||||
"Traditional to Simplified": "Tradycyjny → Uproszczony",
|
||||
"Simplified to Traditional (Taiwan)": "Uproszczony → Tradycyjny (Tajwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Uproszczony → Tradycyjny (Hongkong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Uproszczony → Tradycyjny (Tajwan • frazy)",
|
||||
"Traditional (Taiwan) to Simplified": "Tradycyjny (Tajwan) → Uproszczony",
|
||||
"Traditional (Hong Kong) to Simplified": "Tradycyjny (Hongkong) → Uproszczony",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Tradycyjny (Tajwan • frazy) → Uproszczony",
|
||||
"Convert Simplified and Traditional Chinese": "Konwertuj chiński uproszczony/tradycyjny",
|
||||
"Convert Mode": "Tryb konwersji",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Nie udało się automatycznie zapisać okładki książki dla ekranu blokady: {{error}}",
|
||||
"Download from Cloud": "Pobierz z chmury",
|
||||
"Upload to Cloud": "Prześlij do chmury",
|
||||
"Clear Custom Fonts": "Wyczyść niestandardowe czcionki",
|
||||
"Columns": "Kolumny",
|
||||
"OPDS Catalogs": "Katalogi OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Dodawanie adresów LAN nie jest obsługiwane w wersji webowej.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Nieprawidłowy katalog OPDS. Sprawdź URL.",
|
||||
"Browse and download books from online catalogs": "Przeglądaj i pobieraj książki z katalogów online",
|
||||
"My Catalogs": "Moje katalogi",
|
||||
"Add Catalog": "Dodaj katalog",
|
||||
"No catalogs yet": "Brak katalogów",
|
||||
"Add your first OPDS catalog to start browsing books": "Dodaj pierwszy katalog OPDS, aby rozpocząć przeglądanie książek",
|
||||
"Add Your First Catalog": "Dodaj pierwszy katalog",
|
||||
"Browse": "Przeglądaj",
|
||||
"Popular Catalogs": "Popularne katalogi",
|
||||
"Add": "Dodaj",
|
||||
"Add OPDS Catalog": "Dodaj katalog OPDS",
|
||||
"Catalog Name": "Nazwa katalogu",
|
||||
"My Calibre Library": "Moja biblioteka Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Nazwa użytkownika (opcjonalnie)",
|
||||
"Password (optional)": "Hasło (opcjonalnie)",
|
||||
"Description (optional)": "Opis (opcjonalnie)",
|
||||
"A brief description of this catalog": "Krótki opis katalogu",
|
||||
"Validating...": "Weryfikacja...",
|
||||
"View All": "Pokaż wszystkie",
|
||||
"Forward": "Dalej",
|
||||
"OPDS Catalog": "Katalog OPDS",
|
||||
"Home": "Strona główna",
|
||||
"Library": "Biblioteka",
|
||||
"{{count}} items_one": "{{count}} element",
|
||||
"{{count}} items_few": "{{count}} elementy",
|
||||
"{{count}} items_many": "{{count}} elementów",
|
||||
"{{count}} items_other": "{{count}} elementu",
|
||||
"Download completed": "Pobieranie zakończone",
|
||||
"Download failed": "Pobieranie nie powiodło się",
|
||||
"Open Access": "Dostęp otwarty",
|
||||
"Borrow": "Wypożycz",
|
||||
"Buy": "Kup",
|
||||
"Subscribe": "Subskrybuj",
|
||||
"Sample": "Próbka",
|
||||
"Download": "Pobierz",
|
||||
"Open & Read": "Otwórz i czytaj",
|
||||
"Tags": "Tagi",
|
||||
"Tag": "Tag",
|
||||
"First": "Pierwsza",
|
||||
"Previous": "Poprzednia",
|
||||
"Next": "Następna",
|
||||
"Last": "Ostatnia",
|
||||
"Cannot Load Page": "Nie można załadować strony",
|
||||
"An error occurred": "Wystąpił błąd",
|
||||
"Online Library": "Biblioteka online"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Modo Automático",
|
||||
"Behavior": "Comportamento",
|
||||
"Book": "Livro",
|
||||
"Book Cover": "Capa do Livro",
|
||||
"Bookmark": "Marcador",
|
||||
"Cancel": "Cancelar",
|
||||
"Chapter": "Capítulo",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Nenhum arquivo suportado encontrado. Formatos suportados: {{formats}}",
|
||||
"Drop to Import Books": "Arraste para Importar Livros",
|
||||
"Custom": "Personalizado",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo o mundo é um palco,\nE todos os homens e mulheres são meros atores;\nEles têm suas saídas e suas entradas,\nE um homem em seu tempo desempenha muitos papéis,\nSeus atos sendo sete idades.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Todo o mundo é um palco,\nE todos os homens e mulheres são meros atores;\nEles têm suas saídas e suas entradas,\nE um homem em seu tempo desempenha muitos papéis,\nSeus atos sendo sete idades.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Tema Personalizado",
|
||||
"Theme Name": "Nome do Tema",
|
||||
"Text Color": "Cor do Texto",
|
||||
@@ -660,5 +659,72 @@
|
||||
"Update email": "Atualizar e-mail",
|
||||
"Current email": "E-mail atual",
|
||||
"Update Email": "Atualizar e-mail",
|
||||
"All": "Todos"
|
||||
"All": "Todos",
|
||||
"Unable to open book": "Não foi possível abrir o livro",
|
||||
"Punctuation": "Pontuação",
|
||||
"Replace Quotation Marks": "Substituir Aspas",
|
||||
"Enabled only in vertical layout.": "Habilitado apenas no layout vertical.",
|
||||
"No Conversion": "Sem conversão",
|
||||
"Simplified to Traditional": "Simplificado → Tradicional",
|
||||
"Traditional to Simplified": "Tradicional → Simplificado",
|
||||
"Simplified to Traditional (Taiwan)": "Simplificado → Tradicional (Taiwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Simplificado → Tradicional (Hong Kong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Simplificado → Tradicional (Taiwan • frases)",
|
||||
"Traditional (Taiwan) to Simplified": "Tradicional (Taiwan) → Simplificado",
|
||||
"Traditional (Hong Kong) to Simplified": "Tradicional (Hong Kong) → Simplificado",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Tradicional (Taiwan • frases) → Simplificado",
|
||||
"Convert Simplified and Traditional Chinese": "Converter Chinês Simpl./Trad.",
|
||||
"Convert Mode": "Modo de conversão",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Falha ao salvar automaticamente a capa do livro para a tela de bloqueio: {{error}}",
|
||||
"Download from Cloud": "Baixar da Nuvem",
|
||||
"Upload to Cloud": "Enviar para a Nuvem",
|
||||
"Clear Custom Fonts": "Limpar Fontes Personalizadas",
|
||||
"Columns": "Colunas",
|
||||
"OPDS Catalogs": "Catálogos OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Adicionar endereços LAN não é suportado na versão web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Catálogo OPDS inválido. Verifique o URL.",
|
||||
"Browse and download books from online catalogs": "Navegar e baixar livros de catálogos online",
|
||||
"My Catalogs": "Meus catálogos",
|
||||
"Add Catalog": "Adicionar catálogo",
|
||||
"No catalogs yet": "Nenhum catálogo ainda",
|
||||
"Add your first OPDS catalog to start browsing books": "Adicione seu primeiro catálogo OPDS para começar a navegar pelos livros",
|
||||
"Add Your First Catalog": "Adicione seu primeiro catálogo",
|
||||
"Browse": "Navegar",
|
||||
"Popular Catalogs": "Catálogos populares",
|
||||
"Add": "Adicionar",
|
||||
"Add OPDS Catalog": "Adicionar catálogo OPDS",
|
||||
"Catalog Name": "Nome do catálogo",
|
||||
"My Calibre Library": "Minha biblioteca Calibre",
|
||||
"OPDS URL": "URL do OPDS",
|
||||
"Username (optional)": "Nome de usuário (opcional)",
|
||||
"Password (optional)": "Senha (opcional)",
|
||||
"Description (optional)": "Descrição (opcional)",
|
||||
"A brief description of this catalog": "Uma breve descrição deste catálogo",
|
||||
"Validating...": "Validando...",
|
||||
"View All": "Ver todos",
|
||||
"Forward": "Avançar",
|
||||
"OPDS Catalog": "Catálogo OPDS",
|
||||
"Home": "Início",
|
||||
"Library": "Biblioteca",
|
||||
"{{count}} items_one": "{{count}} item",
|
||||
"{{count}} items_many": "{{count}} itens",
|
||||
"{{count}} items_other": "{{count}} item",
|
||||
"Download completed": "Download concluído",
|
||||
"Download failed": "Falha no download",
|
||||
"Open Access": "Acesso aberto",
|
||||
"Borrow": "Emprestar",
|
||||
"Buy": "Comprar",
|
||||
"Subscribe": "Assinar",
|
||||
"Sample": "Amostra",
|
||||
"Download": "Baixar",
|
||||
"Open & Read": "Abrir e ler",
|
||||
"Tags": "Etiquetas",
|
||||
"Tag": "Etiqueta",
|
||||
"First": "Pierwsza",
|
||||
"Previous": "Poprzednia",
|
||||
"Next": "Następna",
|
||||
"Last": "Ostatnia",
|
||||
"Cannot Load Page": "Nie można załadować strony",
|
||||
"An error occurred": "Wystąpił błąd",
|
||||
"Online Library": "Biblioteka online"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Автоматический режим",
|
||||
"Behavior": "Поведение",
|
||||
"Book": "Книга",
|
||||
"Book Cover": "Обложка книги",
|
||||
"Bookmark": "Закладка",
|
||||
"Cancel": "Отмена",
|
||||
"Chapter": "Глава",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Поддерживаемые файлы не найдены. Поддерживаемые форматы: {{formats}}",
|
||||
"Drop to Import Books": "Перетащите сюда книги для импорта",
|
||||
"Custom": "Пользовательский",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь мир — театр,\nИ все мужчины и женщины — просто актёры;\nУ них есть свои выходы и входы,\nИ один человек в своё время играет много ролей,\nЕго поступки — это семь веков.\n\n— Уильям Шекспир",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Весь мир — театр,\nИ все мужчины и женщины — просто актёры;\nУ них есть свои выходы и входы,\nИ один человек в своё время играет много ролей,\nЕго поступки — это семь веков.\n\n— Уильям Шекспир",
|
||||
"Custom Theme": "Пользовательская тема",
|
||||
"Theme Name": "Название темы",
|
||||
"Text Color": "Цвет текста",
|
||||
@@ -664,5 +663,73 @@
|
||||
"Update email": "Обновить электронную почту",
|
||||
"Current email": "Текущий адрес электронной почты",
|
||||
"Update Email": "Обновить электронную почту",
|
||||
"All": "Все"
|
||||
"All": "Все",
|
||||
"Unable to open book": "Не удалось открыть книгу",
|
||||
"Punctuation": "Пунктуация",
|
||||
"Replace Quotation Marks": "Заменить кавычки",
|
||||
"Enabled only in vertical layout.": "Включено только в вертикальной раскладке.",
|
||||
"No Conversion": "Без преобразования",
|
||||
"Simplified to Traditional": "Упрощённый → Традиционный",
|
||||
"Traditional to Simplified": "Традиционный → Упрощённый",
|
||||
"Simplified to Traditional (Taiwan)": "Упрощённый → Традиционный (Тайвань)",
|
||||
"Simplified to Traditional (Hong Kong)": "Упрощённый → Традиционный (Гонконг)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Упрощённый → Традиционный (Тайвань • фразы)",
|
||||
"Traditional (Taiwan) to Simplified": "Традиционный (Тайвань) → Упрощённый",
|
||||
"Traditional (Hong Kong) to Simplified": "Традиционный (Гонконг) → Упрощённый",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Традиционный (Тайвань • фразы) → Упрощённый",
|
||||
"Convert Simplified and Traditional Chinese": "Преобразовать упрощённый/традиционный китайский",
|
||||
"Convert Mode": "Режим преобразования",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Не удалось автоматически сохранить обложку книги для экрана блокировки: {{error}}",
|
||||
"Download from Cloud": "Скачать из облака",
|
||||
"Upload to Cloud": "Загрузить в облако",
|
||||
"Clear Custom Fonts": "Очистить пользовательские шрифты",
|
||||
"Columns": "Колонки",
|
||||
"OPDS Catalogs": "Каталоги OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Добавление LAN-адресов не поддерживается в веб-версии.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Недействительный каталог OPDS. Пожалуйста, проверьте URL.",
|
||||
"Browse and download books from online catalogs": "Просматривать и скачивать книги из онлайн-каталогов",
|
||||
"My Catalogs": "Мои каталоги",
|
||||
"Add Catalog": "Добавить каталог",
|
||||
"No catalogs yet": "Каталоги отсутствуют",
|
||||
"Add your first OPDS catalog to start browsing books": "Добавьте первый каталог OPDS, чтобы начать просмотр книг",
|
||||
"Add Your First Catalog": "Добавьте первый каталог",
|
||||
"Browse": "Просмотр",
|
||||
"Popular Catalogs": "Популярные каталоги",
|
||||
"Add": "Добавить",
|
||||
"Add OPDS Catalog": "Добавить каталог OPDS",
|
||||
"Catalog Name": "Название каталога",
|
||||
"My Calibre Library": "Моя библиотека Calibre",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Имя пользователя (необязательно)",
|
||||
"Password (optional)": "Пароль (необязательно)",
|
||||
"Description (optional)": "Описание (необязательно)",
|
||||
"A brief description of this catalog": "Краткое описание каталога",
|
||||
"Validating...": "Проверка...",
|
||||
"View All": "Посмотреть все",
|
||||
"Forward": "Вперёд",
|
||||
"OPDS Catalog": "Каталог OPDS",
|
||||
"Home": "Главная",
|
||||
"Library": "Библиотека",
|
||||
"{{count}} items_one": "{{count}} элемент",
|
||||
"{{count}} items_few": "{{count}} элемента",
|
||||
"{{count}} items_many": "{{count}} элементов",
|
||||
"{{count}} items_other": "{{count}} элемента",
|
||||
"Download completed": "Загрузка завершена",
|
||||
"Download failed": "Загрузка не удалась",
|
||||
"Open Access": "Открытый доступ",
|
||||
"Borrow": "Взять в аренду",
|
||||
"Buy": "Купить",
|
||||
"Subscribe": "Подписаться",
|
||||
"Sample": "Образец",
|
||||
"Download": "Скачать",
|
||||
"Open & Read": "Открыть и читать",
|
||||
"Tags": "Теги",
|
||||
"Tag": "Тег",
|
||||
"First": "Первая",
|
||||
"Previous": "Предыдущая",
|
||||
"Next": "Следующая",
|
||||
"Last": "Последняя",
|
||||
"Cannot Load Page": "Не удалось загрузить страницу",
|
||||
"An error occurred": "Произошла ошибка",
|
||||
"Online Library": "Онлайн библиотека"
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
"Color": "වර්ණය",
|
||||
"Behavior": "හැසිරීම",
|
||||
"Reset {{settings}}": "{{settings}} නැවත සකස් කරන්න",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "සියලු ලෝකය වේදිකාවක්,\nසහ සියලු පුරුෂයන් සහ ගැහැණියන් හුදු රංගන ශිල්පීන්;\nඔවුන්ට පිටවීම් සහ ඇතුල්වීම් ඇත,\nසහ එක් මනුෂ්යයෙක් ඔහුගේ කාලයේ බොහෝ භාගයන් ඉටු කරයි,\nඔහුගේ ක්රියා වයස් හතකි.\n\n— විලියම් ශේක්ස්පියර්",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "සියලු ලෝකය වේදිකාවක්,\nසහ සියලු පුරුෂයන් සහ ගැහැණියන් හුදු රංගන ශිල්පීන්;\nඔවුන්ට පිටවීම් සහ ඇතුල්වීම් ඇත,\nසහ එක් මනුෂ්යයෙක් ඔහුගේ කාලයේ බොහෝ භාගයන් ඉටු කරයි,\nඔහුගේ ක්රියා වයස් හතකි.\n\n— විලියම් ශේක්ස්පියර්",
|
||||
"(from 'As You Like It', Act II)": "('ඔබට අවශ්ය ලෙස', දෙවන අංකයෙන්)",
|
||||
"Custom Theme": "අභිරුචි තේමාව",
|
||||
"Theme Name": "තේමා නාමය",
|
||||
@@ -302,7 +302,6 @@
|
||||
"Link Color": "සබැඳි වර්ණය",
|
||||
"Preview": "පෙර දසුන",
|
||||
"Font & Layout": "අකුරු සහ පිරිසැලසුම",
|
||||
"Book Cover": "පොත් ආවරණය",
|
||||
"More Info": "තවත් තොරතුරු",
|
||||
"Parallel Read": "සමාන්තර කියවීම",
|
||||
"Disable": "අබල කරන්න",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "විද්යුත් තැපැල් යාවත්කාලීන කරන්න",
|
||||
"Current email": "වත්මන් විද්යුත් තැපැල්",
|
||||
"Update Email": "විද්යුත් තැපැල් යාවත්කාලීන කරන්න",
|
||||
"All": "සියල්ල"
|
||||
"All": "සියල්ල",
|
||||
"Unable to open book": "පොත විවෘත කිරීමට නොහැක",
|
||||
"Punctuation": "ලකුණු",
|
||||
"Replace Quotation Marks": "උද්ධෘත ලකුණු ප්රතිස්ථාපනය කරන්න",
|
||||
"Enabled only in vertical layout.": "සිරස් ආකෘතියේ පමණක් සක්රීයයි.",
|
||||
"No Conversion": "ප්රතිවර්තනයක් නැත",
|
||||
"Simplified to Traditional": "සරල → සාම්ප්රදායික",
|
||||
"Traditional to Simplified": "සාම්ප්රදායික → සරල",
|
||||
"Simplified to Traditional (Taiwan)": "සරල → සාම්ප්රදායික (තායිවான்)",
|
||||
"Simplified to Traditional (Hong Kong)": "සරල → සාම්ප්රදායික (හොං කොං)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "සරල → සාම්ප්රදායික (තායිවාන • වාක්ය)",
|
||||
"Traditional (Taiwan) to Simplified": "සාම්ප්රදායික (තායිවාන) → සරල",
|
||||
"Traditional (Hong Kong) to Simplified": "සාම්ප්රදායික (හොං කොං) → සරල",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "සාම්ප්රදායික (තායිවාන • වාක්ය) → සරල",
|
||||
"Convert Simplified and Traditional Chinese": "සරල/සාම්ප්රදායික චීන පරිවර්තනය",
|
||||
"Convert Mode": "පරිවර්තන මෝඩියුලය",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "අගුළු තිරය සඳහා පොත් ආවරණය ස්වයංක්රීයව සුරකිීමට අසමත් විය: {{error}}",
|
||||
"Download from Cloud": "කලාඬයෙන් බාගත කරන්න",
|
||||
"Upload to Cloud": "කලාඬයට උඩුගත කරන්න",
|
||||
"Clear Custom Fonts": "අභිරුචි අකුරු පැහැදිලි කරන්න",
|
||||
"Columns": "තීරු",
|
||||
"OPDS Catalogs": "OPDS දත්තසමුදා",
|
||||
"Adding LAN addresses is not supported in the web app version.": "වෙබ් යෙදුම් අනුවාදයේ LAN ලිපින එක් කිරීම සහය නොදක්වයි.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "අවලංගු OPDS දත්තසමුදායකි. කරුණාකර URL පරීක්ෂා කරන්න.",
|
||||
"Browse and download books from online catalogs": "ඔන්ලයින් දත්තසමුදා වලින් පොත් බ්රවුස් සහ බාගන්න",
|
||||
"My Catalogs": "මගේ දත්තසමුදා",
|
||||
"Add Catalog": "දත්තසමුදා එක් කරන්න",
|
||||
"No catalogs yet": "තවම දත්තසමුදා නැත",
|
||||
"Add your first OPDS catalog to start browsing books": "පොත් බ්රවුස් කිරීමට ඔබේ පළමු OPDS දත්තසමුදා එක් කරන්න",
|
||||
"Add Your First Catalog": "ඔබේ පළමු දත්තසමුදා එක් කරන්න",
|
||||
"Browse": "බ්රවුස් කරන්න",
|
||||
"Popular Catalogs": "ප්රසිද්ධ දත්තසමුදා",
|
||||
"Add": "එකතු කරන්න",
|
||||
"Add OPDS Catalog": "OPDS දත්තසමුදා එක් කරන්න",
|
||||
"Catalog Name": "දත්තසමුදා නාමය",
|
||||
"My Calibre Library": "මගේ Calibre පුස්තකාලය",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "පරිශීලක නාමය (අත්යාවශ්ය නොවේ)",
|
||||
"Password (optional)": "මුරපදය (අත්යාවශ්ය නොවේ)",
|
||||
"Description (optional)": "විස්තරය (අත්යාවශ්ය නොවේ)",
|
||||
"A brief description of this catalog": "මෙම දත්තසමුදායේ කෙටි විස්තරයක්",
|
||||
"Validating...": "තහවුරු කරමින්...",
|
||||
"View All": "සියල්ල බලන්න",
|
||||
"Forward": "ඉදිරියට",
|
||||
"OPDS Catalog": "OPDS දත්තසමුදා",
|
||||
"Home": "මුල් පිටුව",
|
||||
"Library": "පුස්තකාලය",
|
||||
"{{count}} items_one": "{{count}} අයිතමය",
|
||||
"{{count}} items_other": "{{count}} අයිතම",
|
||||
"Download completed": "බාගත කිරීම සම්පූර්ණයි",
|
||||
"Download failed": "බාගත කිරීම අසාර්ථකයි",
|
||||
"Open Access": "විවෘත ප්රවේශය",
|
||||
"Borrow": "උधාර ගන්න",
|
||||
"Buy": "ගන්න",
|
||||
"Subscribe": "දායක වන්න",
|
||||
"Sample": "නම්නය",
|
||||
"Download": "බාගන්න",
|
||||
"Open & Read": "විවෘත කර කියවන්න",
|
||||
"Tags": "ටැග්",
|
||||
"Tag": "ටැග්",
|
||||
"First": "පළමුවා",
|
||||
"Previous": "පෙර",
|
||||
"Next": "ඊළඟ",
|
||||
"Last": "අවසාන",
|
||||
"Cannot Load Page": "පිටුවට ප්රවේශ විය නොහැක",
|
||||
"An error occurred": "දෝෂයක් සිදු විය",
|
||||
"Online Library": "ඔන්ලයින් පුස්තකාලය"
|
||||
}
|
||||
|
||||
@@ -241,7 +241,6 @@
|
||||
"{{count}} pages left in chapter_other": "{{count}} sidor kvar i kapitlet",
|
||||
"On {{current}} of {{total}} page": "På {{current}} av {{total}} sidor",
|
||||
"Section Title": "Sektionstitel",
|
||||
"Book Cover": "Bokomslag",
|
||||
"More Info": "Mer info",
|
||||
"Parallel Read": "Parallell läsning",
|
||||
"Disable": "Inaktivera",
|
||||
@@ -541,7 +540,7 @@
|
||||
"Behavior": "Beteende",
|
||||
"Settings Panels": "Inställningspaneler",
|
||||
"Reset {{settings}}": "Återställ {{settings}}",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Hela världen är en scen,\nOch alla män och kvinnor bara spelare;\nDe har sina utgångar och entréer,\nOch en man spelar i sin tid många roller,\nHans akter är sju åldrar.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Hela världen är en scen,\nOch alla män och kvinnor bara spelare;\nDe har sina utgångar och entréer,\nOch en man spelar i sin tid många roller,\nHans akter är sju åldrar.\n\n— William Shakespeare",
|
||||
"(from 'As You Like It', Act II)": "(från 'Som ni behagar', Akt II)",
|
||||
"Custom Theme": "Anpassat tema",
|
||||
"Theme Name": "Temanamn",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "Uppdatera e-post",
|
||||
"Current email": "Nuvarande e-post",
|
||||
"Update Email": "Uppdatera e-post",
|
||||
"All": "Alla"
|
||||
"All": "Alla",
|
||||
"Unable to open book": "Kunde inte öppna bok",
|
||||
"Punctuation": "Interpunktion",
|
||||
"Replace Quotation Marks": "Ersätt citattecken",
|
||||
"Enabled only in vertical layout.": "Aktiverad endast i vertikal layout.",
|
||||
"No Conversion": "Ingen konvertering",
|
||||
"Simplified to Traditional": "Förenklad → Traditionell",
|
||||
"Traditional to Simplified": "Traditionell → Förenklad",
|
||||
"Simplified to Traditional (Taiwan)": "Förenklad → Traditionell (Taiwan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Förenklad → Traditionell (Hongkong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Förenklad → Traditionell (Taiwan • fraser)",
|
||||
"Traditional (Taiwan) to Simplified": "Traditionell (Taiwan) → Förenklad",
|
||||
"Traditional (Hong Kong) to Simplified": "Traditionell (Hongkong) → Förenklad",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Traditionell (Taiwan • fraser) → Förenklad",
|
||||
"Convert Simplified and Traditional Chinese": "Konvertera förenklad/traditionell kinesiska",
|
||||
"Convert Mode": "Konverteringsläge",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Kunde inte autospara bokomslag för låsskärm: {{error}}",
|
||||
"Download from Cloud": "Ladda ner från molnet",
|
||||
"Upload to Cloud": "Ladda upp till molnet",
|
||||
"Clear Custom Fonts": "Rensa anpassade typsnitt",
|
||||
"Columns": "Kolumner",
|
||||
"OPDS Catalogs": "OPDS-kataloger",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Att lägga till LAN-adresser stöds inte i webbappen.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Ogiltig OPDS-katalog. Kontrollera URL:en.",
|
||||
"Browse and download books from online catalogs": "Bläddra och ladda ner böcker från onlinekataloger",
|
||||
"My Catalogs": "Mina kataloger",
|
||||
"Add Catalog": "Lägg till katalog",
|
||||
"No catalogs yet": "Inga kataloger än",
|
||||
"Add your first OPDS catalog to start browsing books": "Lägg till din första OPDS-katalog för att börja bläddra i böcker",
|
||||
"Add Your First Catalog": "Lägg till din första katalog",
|
||||
"Browse": "Bläddra",
|
||||
"Popular Catalogs": "Populära kataloger",
|
||||
"Add": "Lägg till",
|
||||
"Add OPDS Catalog": "Lägg till OPDS-katalog",
|
||||
"Catalog Name": "Katalognamn",
|
||||
"My Calibre Library": "Mitt Calibre-bibliotek",
|
||||
"OPDS URL": "OPDS-URL",
|
||||
"Username (optional)": "Användarnamn (valfritt)",
|
||||
"Password (optional)": "Lösenord (valfritt)",
|
||||
"Description (optional)": "Beskrivning (valfritt)",
|
||||
"A brief description of this catalog": "En kort beskrivning av denna katalog",
|
||||
"Validating...": "Verifierar...",
|
||||
"View All": "Visa alla",
|
||||
"Forward": "Framåt",
|
||||
"OPDS Catalog": "OPDS-katalog",
|
||||
"Home": "Start",
|
||||
"Library": "Bibliotek",
|
||||
"{{count}} items_one": "{{count}} objekt",
|
||||
"{{count}} items_other": "{{count}} objekt",
|
||||
"Download completed": "Nedladdning klar",
|
||||
"Download failed": "Nedladdning misslyckades",
|
||||
"Open Access": "Öppen åtkomst",
|
||||
"Borrow": "Låna",
|
||||
"Buy": "Köp",
|
||||
"Subscribe": "Prenumerera",
|
||||
"Sample": "Prov",
|
||||
"Download": "Ladda ner",
|
||||
"Open & Read": "Öppna & Läs",
|
||||
"Tags": "Taggar",
|
||||
"Tag": "Tagg",
|
||||
"First": "Första",
|
||||
"Previous": "Föregående",
|
||||
"Next": "Nästa",
|
||||
"Last": "Sista",
|
||||
"Cannot Load Page": "Kan inte ladda sidan",
|
||||
"An error occurred": "Ett fel uppstod",
|
||||
"Online Library": "Onlinebibliotek"
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
"Color": "நிறம்",
|
||||
"Behavior": "நடத்தை",
|
||||
"Reset {{settings}}": "{{settings}} மீட்டமைக்கவும்",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "உலகம் முழுவதும் ஒரு மேடை,\nஅதில் ஆண்களும் பெண்களும் வெறும் நடிகர்கள்;\nஅவர்களுக்கு வெளியேறுதலும் நுழைவும் உண்டு,\nஒரு மனிதன் தன் காலத்தில் பல பாத்திரங்களை வகிக்கிறான்,\nஅவனுடைய செயல்கள் ஏழு வயதுகளாக இருக்கின்றன.\n\n— வில்லியம் ஷேக்ஸ்பியர்",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "உலகம் முழுவதும் ஒரு மேடை,\nஅதில் ஆண்களும் பெண்களும் வெறும் நடிகர்கள்;\nஅவர்களுக்கு வெளியேறுதலும் நுழைவும் உண்டு,\nஒரு மனிதன் தன் காலத்தில் பல பாத்திரங்களை வகிக்கிறான்,\nஅவனுடைய செயல்கள் ஏழு வயதுகளாக இருக்கின்றன.\n\n— வில்லியம் ஷேக்ஸ்பியர்",
|
||||
"(from 'As You Like It', Act II)": "('ஆஸ் யூ லைக் இட்', இரண்டாவது அங்கத்திலிருந்து)",
|
||||
"Custom Theme": "தனிப்பயன் தீம்",
|
||||
"Theme Name": "தீம் பெயர்",
|
||||
@@ -302,7 +302,6 @@
|
||||
"Link Color": "இணைப்பு நிறம்",
|
||||
"Preview": "முன்னோட்டம்",
|
||||
"Font & Layout": "எழுத்துரு மற்றும் அமைப்பு",
|
||||
"Book Cover": "புத்தக அட்டை",
|
||||
"More Info": "மேலும் தகவல்",
|
||||
"Parallel Read": "இணை வாசிப்பு",
|
||||
"Disable": "முடக்கவும்",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "மின்னஞ்சல் புதுப்பிக்கவும்",
|
||||
"Current email": "தற்போதைய மின்னஞ்சல்",
|
||||
"Update Email": "மின்னஞ்சல் புதுப்பிக்கவும்",
|
||||
"All": "அனைத்து"
|
||||
"All": "அனைத்து",
|
||||
"Unable to open book": "புத்தகத்தைத் திறக்க முடியவில்லை",
|
||||
"Punctuation": "விருத்தி",
|
||||
"Replace Quotation Marks": "உதயம்சுட்டிகளை மாற்றவும்",
|
||||
"Enabled only in vertical layout.": "செங்குத்து அமைப்பில் மட்டுமே இயலுமைப்படுத்தப்பட்டது.",
|
||||
"No Conversion": "மாற்றம் செய்யவில்லை",
|
||||
"Simplified to Traditional": "எளிய → பாரம்பரிய",
|
||||
"Traditional to Simplified": "பாரம்பரிய → எளிய",
|
||||
"Simplified to Traditional (Taiwan)": "எளிய → பாரம்பரிய (தைவான்)",
|
||||
"Simplified to Traditional (Hong Kong)": "எளிய → பாரம்பரிய (ஹாங்காங்)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "எளிய → பாரம்பரிய (தைவான் • வசனங்கள்)",
|
||||
"Traditional (Taiwan) to Simplified": "பாரம்பரிய (தைவான்) → எளிய",
|
||||
"Traditional (Hong Kong) to Simplified": "பாரம்பரிய (ஹாங்காங்) → எளிய",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "பாரம்பரிய (தைவான் • வசனங்கள்) → எளிய",
|
||||
"Convert Simplified and Traditional Chinese": "எளிய/பாரம்பரிய சீன மொழி மாற்றம்",
|
||||
"Convert Mode": "மாற்ற முறை",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "பூட்டு திரைக்கான புத்தகக் கோப்பை தானாகச் சேமிக்க முடியவில்லை: {{error}}",
|
||||
"Download from Cloud": "மேகத்திலிருந்து பதிவிறக்கு செய்யவும்",
|
||||
"Upload to Cloud": "மேகத்திற்கு பதிவேற்றவும்",
|
||||
"Clear Custom Fonts": "தனிப்பயன் எழுத்துருக்களை அழிக்கவும்",
|
||||
"Columns": "நெடுவரிசைகள்",
|
||||
"OPDS Catalogs": "OPDS பட்டியல்கள்",
|
||||
"Adding LAN addresses is not supported in the web app version.": "LAN முகவரிகளைச் சேர்ப்பது வலை பயன்பாட்டில் ஆதரிக்கப்படவில்லை.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "தவறான OPDS பட்டியல். URL ஐச் சரிபார்க்கவும்.",
|
||||
"Browse and download books from online catalogs": "ஆன்லைன் பட்டியல்களில் இருந்து புத்தகங்களை உலாவவும் மற்றும் பதிவிறக்கவும்",
|
||||
"My Catalogs": "என் பட்டியல்கள்",
|
||||
"Add Catalog": "பட்டியலைச் சேர்க்கவும்",
|
||||
"No catalogs yet": "இன்னும் எந்த பட்டியலும் இல்லை",
|
||||
"Add your first OPDS catalog to start browsing books": "புத்தகங்களை உலாவ தொடங்க உங்கள் முதல் OPDS பட்டியலைச் சேர்க்கவும்",
|
||||
"Add Your First Catalog": "உங்கள் முதல் பட்டியலைச் சேர்க்கவும்",
|
||||
"Browse": "உலாவவும்",
|
||||
"Popular Catalogs": "பிரபல பட்டியல்கள்",
|
||||
"Add": "சேர்க்கவும்",
|
||||
"Add OPDS Catalog": "OPDS பட்டியலைச் சேர்க்கவும்",
|
||||
"Catalog Name": "பட்டியல் பெயர்",
|
||||
"My Calibre Library": "என் Calibre நூலகம்",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "பயனர் பெயர் (விருப்பம்)",
|
||||
"Password (optional)": "கடவுச்சொல் (விருப்பம்)",
|
||||
"Description (optional)": "விவரிப்பு (விருப்பம்)",
|
||||
"A brief description of this catalog": "இந்த பட்டியலின் சுருக்கமான விளக்கம்",
|
||||
"Validating...": "சரிபார்க்கப்படுகிறது...",
|
||||
"View All": "அனைத்தையும் பார்க்கவும்",
|
||||
"Forward": "முன்னேற்று",
|
||||
"OPDS Catalog": "OPDS பட்டியல்",
|
||||
"Home": "முகப்பு",
|
||||
"Library": "நூலகம்",
|
||||
"{{count}} items_one": "{{count}} பொருள்",
|
||||
"{{count}} items_other": "{{count}} பொருட்கள்",
|
||||
"Download completed": "பதிவிறக்கம் முடிந்தது",
|
||||
"Download failed": "பதிவிறக்கம் தோல்வியடைந்தது",
|
||||
"Open Access": "திறந்த அணுகல்",
|
||||
"Borrow": "கடன் எடுக்கவும்",
|
||||
"Buy": "வாங்கவும்",
|
||||
"Subscribe": "சந்தா செய்யவும்",
|
||||
"Sample": "மாதிரிப் புத்தகம்",
|
||||
"Download": "பதிவிறக்கம் செய்யவும்",
|
||||
"Open & Read": "திறந்து வாசிக்கவும்",
|
||||
"Tags": "குறிச்சொற்கள்",
|
||||
"Tag": "குறிச்சொல்",
|
||||
"First": "முதல்",
|
||||
"Previous": "முந்தையது",
|
||||
"Next": "அடுத்தது",
|
||||
"Last": "இறுதி",
|
||||
"Cannot Load Page": "பக்கம் ஏற்ற முடியவில்லை",
|
||||
"An error occurred": "ஒரு பிழை ஏற்பட்டது",
|
||||
"Online Library": "ஆன்லைன் நூலகம்"
|
||||
}
|
||||
|
||||
@@ -255,7 +255,6 @@
|
||||
"Link Color": "สีลิงก์",
|
||||
"Preview": "ดูตัวอย่าง",
|
||||
"Font & Layout": "ฟอนต์และเลย์เอาต์",
|
||||
"Book Cover": "ปกหนังสือ",
|
||||
"More Info": "ข้อมูลเพิ่มเติม",
|
||||
"Parallel Read": "อ่านคู่ขนาน",
|
||||
"Disable": "ปิด",
|
||||
@@ -400,7 +399,7 @@
|
||||
"Reveal in File Explorer": "แสดงใน File Explorer",
|
||||
"Reveal in Folder": "แสดงในโฟลเดอร์",
|
||||
"Don't have an account? Sign up": "ยังไม่มีบัญชี? สมัครสมาชิก",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "โลกทั้งใบเป็นเวที\nและชายหญิงทั้งปวงเป็นนักแสดง\nมีทางเข้าและทางออก\nแต่ละคนในชีวิตเล่นหลายบท\nการกระทำของเขาแบ่งเป็นเจ็ดวัย\n\n— วิลเลียม เชกสเปียร์",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "โลกทั้งใบเป็นเวที\nและชายหญิงทั้งปวงเป็นนักแสดง\nมีทางเข้าและทางออก\nแต่ละคนในชีวิตเล่นหลายบท\nการกระทำของเขาแบ่งเป็นเจ็ดวัย\n\n— วิลเลียม เชกสเปียร์",
|
||||
"What's New in Readest": "มีอะไรใหม่ใน Readest",
|
||||
"Enter book title": "ป้อนชื่อหนังสือ",
|
||||
"Subtitle": "หัวข้อรอง",
|
||||
@@ -652,5 +651,70 @@
|
||||
"Update email": "อัปเดตอีเมล",
|
||||
"Current email": "อีเมลปัจจุบัน",
|
||||
"Update Email": "อัปเดตอีเมล",
|
||||
"All": "ทั้งหมด"
|
||||
"All": "ทั้งหมด",
|
||||
"Unable to open book": "ไม่สามารถเปิดหนังสือได้",
|
||||
"Punctuation": "เครื่องหมายวรรคตอน",
|
||||
"Replace Quotation Marks": "แทนที่เครื่องหมายคำพูด",
|
||||
"Enabled only in vertical layout.": "เปิดใช้งานเฉพาะในเค้าโครงแนวตั้งเท่านั้น",
|
||||
"No Conversion": "ไม่แปลงค่า",
|
||||
"Simplified to Traditional": "ตัวย่อ → ตัวเต็ม",
|
||||
"Traditional to Simplified": "ตัวเต็ม → ตัวย่อ",
|
||||
"Simplified to Traditional (Taiwan)": "ตัวย่อ → ตัวเต็ม (ไต้หวัน)",
|
||||
"Simplified to Traditional (Hong Kong)": "ตัวย่อ → ตัวเต็ม (ฮ่องกง)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "ตัวย่อ → ตัวเต็ม (ไต้หวัน • วลี)",
|
||||
"Traditional (Taiwan) to Simplified": "ตัวเต็ม (ไต้หวัน) → ตัวย่อ",
|
||||
"Traditional (Hong Kong) to Simplified": "ตัวเต็ม (ฮ่องกง) → ตัวย่อ",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "ตัวเต็ม (ไต้หวัน • วลี) → ตัวย่อ",
|
||||
"Convert Simplified and Traditional Chinese": "แปลงจีนตัวย่อ/ตัวเต็ม",
|
||||
"Convert Mode": "โหมดแปลงค่า",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "ไม่สามารถบันทึกปกหนังสือสำหรับหน้าจอล็อกโดยอัตโนมัติ: {{error}}",
|
||||
"Download from Cloud": "ดาวน์โหลดจากคลาวด์",
|
||||
"Upload to Cloud": "อัปโหลดไปยังคลาวด์",
|
||||
"Clear Custom Fonts": "ล้างฟอนต์กำหนดเอง",
|
||||
"Columns": "คอลัมน์",
|
||||
"OPDS Catalogs": "แคตตาล็อก OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "ไม่รองรับการเพิ่มที่อยู่ LAN ในเวอร์ชันเว็บแอป",
|
||||
"Invalid OPDS catalog. Please check the URL.": "แคตตาล็อก OPDS ไม่ถูกต้อง กรุณาตรวจสอบ URL",
|
||||
"Browse and download books from online catalogs": "เรียกดูและดาวน์โหลดหนังสือจากแคตตาล็อกออนไลน์",
|
||||
"My Catalogs": "แคตตาล็อกของฉัน",
|
||||
"Add Catalog": "เพิ่มแคตตาล็อก",
|
||||
"No catalogs yet": "ยังไม่มีแคตตาล็อก",
|
||||
"Add your first OPDS catalog to start browsing books": "เพิ่มแคตตาล็อก OPDS แรกของคุณเพื่อเริ่มเรียกดูหนังสือ",
|
||||
"Add Your First Catalog": "เพิ่มแคตตาล็อกแรกของคุณ",
|
||||
"Browse": "เรียกดู",
|
||||
"Popular Catalogs": "แคตตาล็อกยอดนิยม",
|
||||
"Add": "เพิ่ม",
|
||||
"Add OPDS Catalog": "เพิ่มแคตตาล็อก OPDS",
|
||||
"Catalog Name": "ชื่อแคตตาล็อก",
|
||||
"My Calibre Library": "ห้องสมุด Calibre ของฉัน",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "ชื่อผู้ใช้ (ไม่บังคับ)",
|
||||
"Password (optional)": "รหัสผ่าน (ไม่บังคับ)",
|
||||
"Description (optional)": "คำอธิบาย (ไม่บังคับ)",
|
||||
"A brief description of this catalog": "คำอธิบายสั้น ๆ ของแคตตาล็อกนี้",
|
||||
"Validating...": "กำลังตรวจสอบ...",
|
||||
"View All": "ดูทั้งหมด",
|
||||
"Forward": "ไปข้างหน้า",
|
||||
"OPDS Catalog": "แคตตาล็อก OPDS",
|
||||
"Home": "หน้าแรก",
|
||||
"Library": "ห้องสมุด",
|
||||
"{{count}} items_other": "{{count}} รายการ",
|
||||
"Download completed": "ดาวน์โหลดเสร็จสิ้น",
|
||||
"Download failed": "ดาวน์โหลดล้มเหลว",
|
||||
"Open Access": "เข้าถึงได้ทันที",
|
||||
"Borrow": "ยืม",
|
||||
"Buy": "ซื้อ",
|
||||
"Subscribe": "สมัครสมาชิก",
|
||||
"Sample": "ตัวอย่าง",
|
||||
"Download": "ดาวน์โหลด",
|
||||
"Open & Read": "เปิดและอ่าน",
|
||||
"Tags": "แท็ก",
|
||||
"Tag": "แท็ก",
|
||||
"First": "แรก",
|
||||
"Previous": "ก่อนหน้า",
|
||||
"Next": "ถัดไป",
|
||||
"Last": "สุดท้าย",
|
||||
"Cannot Load Page": "ไม่สามารถโหลดหน้าหน้าได้",
|
||||
"An error occurred": "เกิดข้อผิดพลาด",
|
||||
"Online Library": "ห้องสมุดออนไลน์"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Otomatik Mod",
|
||||
"Behavior": "Davranış",
|
||||
"Book": "Kitap",
|
||||
"Book Cover": "Kitap Kapağı",
|
||||
"Bookmark": "Yer İmi",
|
||||
"Cancel": "İptal",
|
||||
"Chapter": "Bölüm",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Desteklenen dosya bulunamadı. Desteklenen formatlar: {{formats}}",
|
||||
"Drop to Import Books": "Kitapları İçe Aktarmak İçin Bırak",
|
||||
"Custom": "Özel",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tüm dünya bir sahnedir,\nVe tüm erkekler ve kadınlar sadece oyunculardır;\nOnların çıkışları ve girişleri vardır,\nVe bir adam zamanında birçok rol oynar,\nOnun eylemleri yedi yaşındadır.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Tüm dünya bir sahnedir,\nVe tüm erkekler ve kadınlar sadece oyunculardır;\nOnların çıkışları ve girişleri vardır,\nVe bir adam zamanında birçok rol oynar,\nOnun eylemleri yedi yaşındadır.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Özel Tema",
|
||||
"Theme Name": "Tema Adı",
|
||||
"Text Color": "Metin Rengi",
|
||||
@@ -656,5 +655,71 @@
|
||||
"Update email": "E-postayı güncelle",
|
||||
"Current email": "Mevcut e-posta",
|
||||
"Update Email": "E-postayı güncelle",
|
||||
"All": "Tümü"
|
||||
"All": "Tümü",
|
||||
"Unable to open book": "Kitap açılamıyor",
|
||||
"Punctuation": "Noktalama İşaretleri",
|
||||
"Replace Quotation Marks": "Tırnak İşaretlerini Değiştir",
|
||||
"Enabled only in vertical layout.": "Yalnızca dikey düzenle etkinleştirildi.",
|
||||
"No Conversion": "Dönüştürme yok",
|
||||
"Simplified to Traditional": "Basitleştirilmiş → Geleneksel",
|
||||
"Traditional to Simplified": "Geleneksel → Basitleştirilmiş",
|
||||
"Simplified to Traditional (Taiwan)": "Basitleştirilmiş → Geleneksel (Tayvan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Basitleştirilmiş → Geleneksel (Hong Kong)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Basitleştirilmiş → Geleneksel (Tayvan • ifadeler)",
|
||||
"Traditional (Taiwan) to Simplified": "Geleneksel (Tayvan) → Basitleştirilmiş",
|
||||
"Traditional (Hong Kong) to Simplified": "Geleneksel (Hong Kong) → Basitleştirilmiş",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Geleneksel (Tayvan • ifadeler) → Basitleştirilmiş",
|
||||
"Convert Simplified and Traditional Chinese": "Basitleştirilmiş/Geleneksel Çince Dönüştür",
|
||||
"Convert Mode": "Dönüştürme Modu",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Kitap kapağı kilit ekranı için otomatik kaydedilemedi: {{error}}",
|
||||
"Download from Cloud": "Buluttan İndir",
|
||||
"Upload to Cloud": "Buluta Yükle",
|
||||
"Clear Custom Fonts": "Özel Yazı Tiplerini Temizle",
|
||||
"Columns": "Sütunlar",
|
||||
"OPDS Catalogs": "OPDS Katalogları",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Web uygulaması sürümünde LAN adresi ekleme desteklenmiyor.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Geçersiz OPDS kataloğu. Lütfen URL'yi kontrol edin.",
|
||||
"Browse and download books from online catalogs": "Çevrimiçi kataloglardan kitapları görüntüleyin ve indirin",
|
||||
"My Catalogs": "Kataloglarım",
|
||||
"Add Catalog": "Katalog Ekle",
|
||||
"No catalogs yet": "Henüz katalog yok",
|
||||
"Add your first OPDS catalog to start browsing books": "Kitapları görüntülemeye başlamak için ilk OPDS kataloğunuzu ekleyin",
|
||||
"Add Your First Catalog": "İlk Kataloğunuzu Ekleyin",
|
||||
"Browse": "Gözat",
|
||||
"Popular Catalogs": "Popüler Kataloglar",
|
||||
"Add": "Ekle",
|
||||
"Add OPDS Catalog": "OPDS Kataloğu Ekle",
|
||||
"Catalog Name": "Katalog Adı",
|
||||
"My Calibre Library": "Calibre Kütüphanem",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "Kullanıcı Adı (opsiyonel)",
|
||||
"Password (optional)": "Şifre (opsiyonel)",
|
||||
"Description (optional)": "Açıklama (opsiyonel)",
|
||||
"A brief description of this catalog": "Bu katalog hakkında kısa açıklama",
|
||||
"Validating...": "Doğrulanıyor...",
|
||||
"View All": "Tümünü Görüntüle",
|
||||
"Forward": "İleri",
|
||||
"OPDS Catalog": "OPDS Kataloğu",
|
||||
"Home": "Ana Sayfa",
|
||||
"Library": "Kütüphane",
|
||||
"{{count}} items_one": "{{count}} öğe",
|
||||
"{{count}} items_other": "{{count}} öğe",
|
||||
"Download completed": "İndirme tamamlandı",
|
||||
"Download failed": "İndirme başarısız",
|
||||
"Open Access": "Açık Erişim",
|
||||
"Borrow": "Ödünç Al",
|
||||
"Buy": "Satın Al",
|
||||
"Subscribe": "Abone Ol",
|
||||
"Sample": "Örnek",
|
||||
"Download": "İndir",
|
||||
"Open & Read": "Aç ve Oku",
|
||||
"Tags": "Etiketler",
|
||||
"Tag": "Etiket",
|
||||
"First": "İlk",
|
||||
"Previous": "Önceki",
|
||||
"Next": "Sonraki",
|
||||
"Last": "Son",
|
||||
"Cannot Load Page": "Sayfa yüklenemiyor",
|
||||
"An error occurred": "Bir hata oluştu",
|
||||
"Online Library": "Çevrimiçi Kütüphane"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Автоматичний режим",
|
||||
"Behavior": "Поведінка",
|
||||
"Book": "Книга",
|
||||
"Book Cover": "Обкладинка книги",
|
||||
"Bookmark": "Закладка",
|
||||
"Cancel": "Скасувати",
|
||||
"Chapter": "Розділ",
|
||||
@@ -172,7 +171,7 @@
|
||||
"Phone number": "Номер телефону",
|
||||
"Your phone number": "Ваш номер телефону",
|
||||
"Token": "Токен",
|
||||
"Your OTP token": "Ваш OTP токен",
|
||||
"Your OTP token": "Ваш OTP-токен",
|
||||
"Verify token": "Перевірити токен",
|
||||
"Sign in with Google": "Увійти через Google",
|
||||
"Sign in with Apple": "Увійти через Apple",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Не знайдено підтримуваних файлів. Підтримувані формати: {{formats}}",
|
||||
"Drop to Import Books": "Перетягніть для імпорту книг",
|
||||
"Custom": "Користувацький",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Так, світ — театр,\nДе всі чоловіки й жінки — актори.\nТут кожному приписаний свій вихід,\nне одну з них кожне грає роль.\nСім дій в тій п’єсі...\nВільям Шекспір\n(переклад Олександра Мокровольського)",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Так, світ — театр,\nДе всі чоловіки й жінки — актори.\nТут кожному приписаний свій вихід,\nне одну з них кожне грає роль.\nСім дій в тій п’єсі...\nВільям Шекспір\n(переклад Олександра Мокровольського)",
|
||||
"Custom Theme": "Користувацька тема",
|
||||
"Theme Name": "Назва теми",
|
||||
"Text Color": "Колір тексту",
|
||||
@@ -424,7 +423,7 @@
|
||||
"Enter series index": "Введіть номер циклу",
|
||||
"Total in Series": "Всього у циклі",
|
||||
"Enter total books in series": "Введіть загальну кількість книг у циклі",
|
||||
"Enter publisher": "Введіть видавництвця",
|
||||
"Enter publisher": "Введіть видавця",
|
||||
"Publication Date": "Дата видання",
|
||||
"Identifier": "Ідентифікатор",
|
||||
"Enter book description": "Введіть опис книги",
|
||||
@@ -551,8 +550,8 @@
|
||||
"Add Bookmark": "Додати закладку",
|
||||
"Books Content": "Зміст книг",
|
||||
"Jump to Location": "Перейти до місця",
|
||||
"Unpin Notebook": "Відкріпити блокнот",
|
||||
"Pin Notebook": "Закріпити блокнот",
|
||||
"Unpin Notebook": "Відкріпити нотатник",
|
||||
"Pin Notebook": "Закріпити нотатник",
|
||||
"Hide Search Bar": "Сховати панель пошуку",
|
||||
"Show Search Bar": "Показати панель пошуку",
|
||||
"On {{current}} of {{total}} page": "На {{current}} з {{total}} сторінці",
|
||||
@@ -609,8 +608,8 @@
|
||||
"Import Image": "Імпортувати зображення",
|
||||
"Opacity": "Непрозорість",
|
||||
"Size": "Розмір",
|
||||
"Cover": "Обкладинка",
|
||||
"Contain": "Вміщати",
|
||||
"Cover": "Покрити",
|
||||
"Contain": "Втиснути",
|
||||
"{{number}} pages left in chapter": "Залишилось {{number}} сторінок у розділі",
|
||||
"Device": "Пристрій",
|
||||
"E-Ink Mode": "E-Ink режим",
|
||||
@@ -632,8 +631,8 @@
|
||||
"The book file is empty": "Файл книги порожній",
|
||||
"Failed to open the book file": "Не вдалося відкрити файл книги",
|
||||
"On-Demand Purchase": "Покупка за запитом",
|
||||
"Full Customization": "Повна налаштування",
|
||||
"Lifetime Plan": "План на все життя",
|
||||
"Full Customization": "Повна кастомізація",
|
||||
"Lifetime Plan": "На все життя",
|
||||
"One-Time Payment": "Одноразовий платіж",
|
||||
"Make a single payment to enjoy lifetime access to specific features on all devices. Purchase specific features or services only when you need them.": "Зробіть одноразовий платіж, щоб отримати довічний доступ до певних функцій на всіх пристроях. Купуйте конкретні функції або послуги лише тоді, коли вони вам потрібні.",
|
||||
"Expand Cloud Sync Storage": "Розширити сховище синхронізації в хмарі",
|
||||
@@ -658,11 +657,79 @@
|
||||
"Back": "Назад",
|
||||
"Confirmation email sent! Please check your old and new email addresses to confirm the change.": "Лист підтвердження надіслано! Будь ласка, перевірте стару та нову електронну адресу, щоб підтвердити зміну.",
|
||||
"Failed to update email": "Не вдалося оновити електронну пошту",
|
||||
"New Email": "Нова електронна пошта",
|
||||
"New Email": "Нова електронна адреса",
|
||||
"Your new email": "Ваша нова електронна адреса",
|
||||
"Updating email ...": "Оновлення електронної пошти ...",
|
||||
"Update email": "Оновити електронну пошту",
|
||||
"Current email": "Поточна електронна пошта",
|
||||
"Update Email": "Оновити електронну пошту",
|
||||
"All": "Всі"
|
||||
"Updating email ...": "Оновлення електронної адреси ...",
|
||||
"Update email": "Оновити електронну адресу",
|
||||
"Current email": "Поточна електронна адреса",
|
||||
"Update Email": "Оновити Адресу",
|
||||
"All": "Всі",
|
||||
"Unable to open book": "Не вдалося відкрити книгу",
|
||||
"Punctuation": "Пунктуація",
|
||||
"Replace Quotation Marks": "Замінити лапки",
|
||||
"Enabled only in vertical layout.": "Активовано лише у вертикальному макеті.",
|
||||
"No Conversion": "Без конвертації",
|
||||
"Simplified to Traditional": "Спрощена → Традиційна",
|
||||
"Traditional to Simplified": "Традиційна → Спрощена",
|
||||
"Simplified to Traditional (Taiwan)": "Спрощена → Традиційна (Тайвань)",
|
||||
"Simplified to Traditional (Hong Kong)": "Спрощена → Традиційна (Хонкон)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Спрощена → Традиційна (Тайвань • фрази)",
|
||||
"Traditional (Taiwan) to Simplified": "Традиційна (Тайвань) → Спрощена",
|
||||
"Traditional (Hong Kong) to Simplified": "Традиційна (Хонкон) → Спрощена",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Традиційна (Тайвань • фрази) → Спрощена",
|
||||
"Convert Simplified and Traditional Chinese": "Конвертувати спрощену/традиційну китайську",
|
||||
"Convert Mode": "Режим конвертації",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Не вдалося автоматично зберегти обкладинку книги для екрана блокування: {{error}}",
|
||||
"Download from Cloud": "Завантажити з хмари",
|
||||
"Upload to Cloud": "Завантажити в хмару",
|
||||
"Clear Custom Fonts": "Очистити користувацькі шрифти",
|
||||
"Columns": "Стовпці",
|
||||
"OPDS Catalogs": "Каталоги OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Додавання LAN-адрес не підтримується у веб-версії.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Недійсний каталог OPDS. Будь ласка, перевірте URL.",
|
||||
"Browse and download books from online catalogs": "Переглядайте та завантажуйте книги з онлайн-каталогів",
|
||||
"My Catalogs": "Мої каталоги",
|
||||
"Add Catalog": "Додати каталог",
|
||||
"No catalogs yet": "Каталогів поки немає",
|
||||
"Add your first OPDS catalog to start browsing books": "Додайте перший каталог OPDS, щоб почати перегляд книг",
|
||||
"Add Your First Catalog": "Додайте свій перший каталог",
|
||||
"Browse": "Переглянути",
|
||||
"Popular Catalogs": "Популярні каталоги",
|
||||
"Add": "Додати",
|
||||
"Add OPDS Catalog": "Додати каталог OPDS",
|
||||
"Catalog Name": "Назва каталогу",
|
||||
"My Calibre Library": "Моя бібліотека Calibre",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "Ім’я користувача (необов’язково)",
|
||||
"Password (optional)": "Пароль (необов’язково)",
|
||||
"Description (optional)": "Опис (необов’язково)",
|
||||
"A brief description of this catalog": "Короткий опис цього каталогу",
|
||||
"Validating...": "Перевірка...",
|
||||
"View All": "Переглянути все",
|
||||
"Forward": "Вперед",
|
||||
"OPDS Catalog": "Каталог OPDS",
|
||||
"Home": "Головна",
|
||||
"Library": "Бібліотека",
|
||||
"{{count}} items_one": "{{count}} елемент",
|
||||
"{{count}} items_few": "{{count}} елементи",
|
||||
"{{count}} items_many": "{{count}} елементів",
|
||||
"{{count}} items_other": "{{count}} елементів",
|
||||
"Download completed": "Завантаження завершено",
|
||||
"Download failed": "Завантаження не вдалося",
|
||||
"Open Access": "Вільний доступ",
|
||||
"Borrow": "Позичити",
|
||||
"Buy": "Купити",
|
||||
"Subscribe": "Підписатися",
|
||||
"Sample": "Зразок",
|
||||
"Download": "Завантажити",
|
||||
"Open & Read": "Відкрити та читати",
|
||||
"Tags": "Теги",
|
||||
"Tag": "Тег",
|
||||
"First": "Перша",
|
||||
"Previous": "Попередня",
|
||||
"Next": "Наступна",
|
||||
"Last": "Остання",
|
||||
"Cannot Load Page": "Не вдалося завантажити сторінку",
|
||||
"An error occurred": "Сталася помилка",
|
||||
"Online Library": "Онлайн бібліотека"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "Chế độ tự động",
|
||||
"Behavior": "Hành vi",
|
||||
"Book": "Sách",
|
||||
"Book Cover": "Bìa sách",
|
||||
"Bookmark": "Đánh dấu",
|
||||
"Cancel": "Hủy",
|
||||
"Chapter": "Chương",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "Không tìm thấy tệp được hỗ trợ. Định dạng được hỗ trợ: {{formats}}",
|
||||
"Drop to Import Books": "Kéo và thả để nhập sách",
|
||||
"Custom": "Tùy chỉnh",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cả thế giới này là một sân khấu,\nVà tất cả đàn ông và phụ nữ chỉ là những diễn viên;\nHọ có cửa ra và cửa vào của mình,\nVà một người trong cuộc đời mình đóng nhiều vai,\nHành động của mình là bảy tuổi.\n\n— William Shakespeare",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Cả thế giới này là một sân khấu,\nVà tất cả đàn ông và phụ nữ chỉ là những diễn viên;\nHọ có cửa ra và cửa vào của mình,\nVà một người trong cuộc đời mình đóng nhiều vai,\nHành động của mình là bảy tuổi.\n\n— William Shakespeare",
|
||||
"Custom Theme": "Chủ đề tùy chỉnh",
|
||||
"Theme Name": "Tên chủ đề",
|
||||
"Text Color": "Màu văn bản",
|
||||
@@ -652,5 +651,70 @@
|
||||
"Update email": "Cập nhật email",
|
||||
"Current email": "Email hiện tại",
|
||||
"Update Email": "Cập nhật email",
|
||||
"All": "Tất cả"
|
||||
"All": "Tất cả",
|
||||
"Unable to open book": "Không thể mở sách",
|
||||
"Punctuation": "Dấu câu",
|
||||
"Replace Quotation Marks": "Thay thế dấu ngoặc kép",
|
||||
"Enabled only in vertical layout.": "Chỉ bật trong bố cục dọc.",
|
||||
"No Conversion": "Không chuyển đổi",
|
||||
"Simplified to Traditional": "Giản thể → Phồn thể",
|
||||
"Traditional to Simplified": "Phồn thể → Giản thể",
|
||||
"Simplified to Traditional (Taiwan)": "Giản thể → Phồn thể (Đài Loan)",
|
||||
"Simplified to Traditional (Hong Kong)": "Giản thể → Phồn thể (Hồng Kông)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "Giản thể → Phồn thể (Đài Loan • cụm từ)",
|
||||
"Traditional (Taiwan) to Simplified": "Phồn thể (Đài Loan) → Giản thể",
|
||||
"Traditional (Hong Kong) to Simplified": "Phồn thể (Hồng Kông) → Giản thể",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "Phồn thể (Đài Loan • cụm từ) → Giản thể",
|
||||
"Convert Simplified and Traditional Chinese": "Chuyển đổi Trung giản/Phồn thể",
|
||||
"Convert Mode": "Chế độ chuyển đổi",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "Không thể tự động lưu bìa sách cho màn hình khóa: {{error}}",
|
||||
"Download from Cloud": "Tải xuống từ đám mây",
|
||||
"Upload to Cloud": "Tải lên đám mây",
|
||||
"Clear Custom Fonts": "Xóa phông chữ tùy chỉnh",
|
||||
"Columns": "Cột",
|
||||
"OPDS Catalogs": "Danh mục OPDS",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Việc thêm địa chỉ LAN không được hỗ trợ trong phiên bản web.",
|
||||
"Invalid OPDS catalog. Please check the URL.": "Danh mục OPDS không hợp lệ. Vui lòng kiểm tra URL.",
|
||||
"Browse and download books from online catalogs": "Duyệt và tải sách từ các danh mục trực tuyến",
|
||||
"My Catalogs": "Danh mục của tôi",
|
||||
"Add Catalog": "Thêm danh mục",
|
||||
"No catalogs yet": "Chưa có danh mục nào",
|
||||
"Add your first OPDS catalog to start browsing books": "Thêm danh mục OPDS đầu tiên để bắt đầu duyệt sách",
|
||||
"Add Your First Catalog": "Thêm danh mục đầu tiên",
|
||||
"Browse": "Duyệt",
|
||||
"Popular Catalogs": "Danh mục phổ biến",
|
||||
"Add": "Thêm",
|
||||
"Add OPDS Catalog": "Thêm danh mục OPDS",
|
||||
"Catalog Name": "Tên danh mục",
|
||||
"My Calibre Library": "Thư viện Calibre của tôi",
|
||||
"OPDS URL": "URL OPDS",
|
||||
"Username (optional)": "Tên đăng nhập (tùy chọn)",
|
||||
"Password (optional)": "Mật khẩu (tùy chọn)",
|
||||
"Description (optional)": "Mô tả (tùy chọn)",
|
||||
"A brief description of this catalog": "Mô tả ngắn gọn về danh mục này",
|
||||
"Validating...": "Đang xác thực...",
|
||||
"View All": "Xem tất cả",
|
||||
"Forward": "Tiếp",
|
||||
"OPDS Catalog": "Danh mục OPDS",
|
||||
"Home": "Trang chủ",
|
||||
"Library": "Thư viện",
|
||||
"{{count}} items_other": "{{count}} mục",
|
||||
"Download completed": "Tải xuống hoàn tất",
|
||||
"Download failed": "Tải xuống thất bại",
|
||||
"Open Access": "Truy cập mở",
|
||||
"Borrow": "Mượn",
|
||||
"Buy": "Mua",
|
||||
"Subscribe": "Đăng ký",
|
||||
"Sample": "Mẫu",
|
||||
"Download": "Tải xuống",
|
||||
"Open & Read": "Mở & Đọc",
|
||||
"Tags": "Thẻ",
|
||||
"Tag": "Thẻ",
|
||||
"First": "Đầu tiên",
|
||||
"Previous": "Trước",
|
||||
"Next": "Tiếp theo",
|
||||
"Last": "Cuối cùng",
|
||||
"Cannot Load Page": "Không thể tải trang",
|
||||
"An error occurred": "Đã xảy ra lỗi",
|
||||
"Online Library": "Thư viện trực tuyến"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "自动主题",
|
||||
"Behavior": "行为",
|
||||
"Book": "书籍",
|
||||
"Book Cover": "书籍封面",
|
||||
"Bookmark": "书签",
|
||||
"Cancel": "取消",
|
||||
"Chapter": "章节",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "未找到支持的文件。支持的格式:{{formats}}",
|
||||
"Drop to Import Books": "拖放导入书籍",
|
||||
"Custom": "自定义",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是座舞台, \n所有男女都只是演员; \n各有其出场和入场; \n每个人皆扮演许多角色, \n而演出共分为七个阶段。\n\n——威廉·莎士比亚",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是座舞台, \n所有男女都只是演员; \n各有其出场和入场; \n每个人皆扮演许多角色, \n而演出共分为七个阶段。\n\n——威廉·莎士比亚",
|
||||
"Custom Theme": "自定义主题",
|
||||
"Theme Name": "主题名称",
|
||||
"Text Color": "文本颜色",
|
||||
@@ -652,5 +651,70 @@
|
||||
"Update email": "更新邮箱",
|
||||
"Current email": "当前邮箱",
|
||||
"Update Email": "更新邮箱",
|
||||
"All": "全部"
|
||||
"All": "全部",
|
||||
"Unable to open book": "无法打开书籍",
|
||||
"Punctuation": "标点符号",
|
||||
"Replace Quotation Marks": "替换引号",
|
||||
"Enabled only in vertical layout.": "仅在竖排布局中启用",
|
||||
"No Conversion": "不转换",
|
||||
"Simplified to Traditional": "简体 → 繁体",
|
||||
"Traditional to Simplified": "繁体 → 简体",
|
||||
"Simplified to Traditional (Taiwan)": "简体 → 繁体(台湾)",
|
||||
"Simplified to Traditional (Hong Kong)": "简体 → 繁体(香港)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "简体 → 繁体(台湾)习惯用语",
|
||||
"Traditional (Taiwan) to Simplified": "繁体(台湾) → 简体",
|
||||
"Traditional (Hong Kong) to Simplified": "繁体(香港) → 简体",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "繁体(台湾) → 简体习惯用语",
|
||||
"Convert Simplified and Traditional Chinese": "简繁中文转换",
|
||||
"Convert Mode": "转换模式",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "自动保存锁屏书籍封面失败:{{error}}",
|
||||
"Download from Cloud": "从云端下载",
|
||||
"Upload to Cloud": "上传到云端",
|
||||
"Clear Custom Fonts": "清除自定义字体",
|
||||
"Columns": "列数",
|
||||
"OPDS Catalogs": "OPDS 目录",
|
||||
"Adding LAN addresses is not supported in the web app version.": "Web 版不支持添加局域网地址。",
|
||||
"Invalid OPDS catalog. Please check the URL.": "无效的 OPDS 目录。请检查 URL。",
|
||||
"Browse and download books from online catalogs": "浏览并下载在线目录中的书籍",
|
||||
"My Catalogs": "我的目录",
|
||||
"Add Catalog": "添加目录",
|
||||
"No catalogs yet": "尚无目录",
|
||||
"Add your first OPDS catalog to start browsing books": "添加您的第一个 OPDS 目录以开始浏览书籍",
|
||||
"Add Your First Catalog": "添加第一个目录",
|
||||
"Browse": "浏览",
|
||||
"Popular Catalogs": "热门目录",
|
||||
"Add": "添加",
|
||||
"Add OPDS Catalog": "添加 OPDS 目录",
|
||||
"Catalog Name": "目录名称",
|
||||
"My Calibre Library": "我的 Calibre 图书馆",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "用户名(可选)",
|
||||
"Password (optional)": "密码(可选)",
|
||||
"Description (optional)": "描述(可选)",
|
||||
"A brief description of this catalog": "该目录的简要描述",
|
||||
"Validating...": "验证中...",
|
||||
"View All": "查看全部",
|
||||
"Forward": "前进",
|
||||
"OPDS Catalog": "OPDS 目录",
|
||||
"Home": "首页",
|
||||
"Library": "图书馆",
|
||||
"{{count}} items_other": "{{count}} 项",
|
||||
"Download completed": "下载完成",
|
||||
"Download failed": "下载失败",
|
||||
"Open Access": "开放访问",
|
||||
"Borrow": "借阅",
|
||||
"Buy": "购买",
|
||||
"Subscribe": "订阅",
|
||||
"Sample": "样章",
|
||||
"Download": "下载",
|
||||
"Open & Read": "打开并阅读",
|
||||
"Tags": "标签",
|
||||
"Tag": "标签",
|
||||
"First": "首页",
|
||||
"Previous": "上一页",
|
||||
"Next": "下一页",
|
||||
"Last": "末页",
|
||||
"Cannot Load Page": "无法加载页面",
|
||||
"An error occurred": "发生错误",
|
||||
"Online Library": "在线书库"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auto Mode": "自動主題",
|
||||
"Behavior": "行為",
|
||||
"Book": "書籍",
|
||||
"Book Cover": "書籍封面",
|
||||
"Bookmark": "書籤",
|
||||
"Cancel": "取消",
|
||||
"Chapter": "章節",
|
||||
@@ -194,7 +193,7 @@
|
||||
"No supported files found. Supported formats: {{formats}}": "未找到支援的檔案。支援的格式:{{formats}}",
|
||||
"Drop to Import Books": "拖放導入書籍",
|
||||
"Custom": "自定義",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是一個舞台,\n所有的男人和女人都只是演員:\n他們有出口,也有入口;\n一個人在他的時代扮演許多角色。 \n\n——威廉·莎士比亞",
|
||||
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "世界是一個舞台,\n所有的男人和女人都只是演員:\n他們有出口,也有入口;\n一個人在他的時代扮演許多角色。 \n\n——威廉·莎士比亞",
|
||||
"Custom Theme": "自定義主題",
|
||||
"Theme Name": "主題名稱",
|
||||
"Text Color": "文字顏色",
|
||||
@@ -652,5 +651,70 @@
|
||||
"Update email": "更新電子郵件",
|
||||
"Current email": "當前電子郵件",
|
||||
"Update Email": "更新電子郵件",
|
||||
"All": "全部"
|
||||
"All": "全部",
|
||||
"Unable to open book": "無法打開書籍",
|
||||
"Punctuation": "標點符號",
|
||||
"Replace Quotation Marks": "替換引號",
|
||||
"Enabled only in vertical layout.": "僅在直排佈局中啟用",
|
||||
"No Conversion": "不轉換",
|
||||
"Simplified to Traditional": "簡體 → 繁體",
|
||||
"Traditional to Simplified": "繁體 → 簡體",
|
||||
"Simplified to Traditional (Taiwan)": "簡體 → 繁體(台灣)",
|
||||
"Simplified to Traditional (Hong Kong)": "簡體 → 繁體(香港)",
|
||||
"Simplified to Traditional (Taiwan), with phrases": "簡體 → 繁體(台灣)慣用語",
|
||||
"Traditional (Taiwan) to Simplified": "繁體(台灣) → 簡體",
|
||||
"Traditional (Hong Kong) to Simplified": "繁體(香港) → 簡體",
|
||||
"Traditional (Taiwan) to Simplified, with phrases": "繁體(台灣) → 簡體慣用語",
|
||||
"Convert Simplified and Traditional Chinese": "簡繁中文轉換",
|
||||
"Convert Mode": "轉換模式",
|
||||
"Failed to auto-save book cover for lock screen: {{error}}": "自動保存鎖定螢幕書籍封面失敗:{{error}}",
|
||||
"Download from Cloud": "從雲端下載",
|
||||
"Upload to Cloud": "上傳到雲端",
|
||||
"Clear Custom Fonts": "清除自訂字型",
|
||||
"Columns": "欄數",
|
||||
"OPDS Catalogs": "OPDS 目錄",
|
||||
"Adding LAN addresses is not supported in the web app version.": "網頁版不支援新增區域網路位址。",
|
||||
"Invalid OPDS catalog. Please check the URL.": "無效的 OPDS 目錄。請檢查 URL。",
|
||||
"Browse and download books from online catalogs": "瀏覽並下載線上目錄的書籍",
|
||||
"My Catalogs": "我的目錄",
|
||||
"Add Catalog": "新增目錄",
|
||||
"No catalogs yet": "尚無目錄",
|
||||
"Add your first OPDS catalog to start browsing books": "新增第一個 OPDS 目錄以開始瀏覽書籍",
|
||||
"Add Your First Catalog": "新增第一個目錄",
|
||||
"Browse": "瀏覽",
|
||||
"Popular Catalogs": "熱門目錄",
|
||||
"Add": "新增",
|
||||
"Add OPDS Catalog": "新增 OPDS 目錄",
|
||||
"Catalog Name": "目錄名稱",
|
||||
"My Calibre Library": "我的 Calibre 圖書館",
|
||||
"OPDS URL": "OPDS URL",
|
||||
"Username (optional)": "使用者名稱(可選)",
|
||||
"Password (optional)": "密碼(可選)",
|
||||
"Description (optional)": "描述(可選)",
|
||||
"A brief description of this catalog": "此目錄的簡短描述",
|
||||
"Validating...": "驗證中...",
|
||||
"View All": "檢視全部",
|
||||
"Forward": "前往",
|
||||
"OPDS Catalog": "OPDS 目錄",
|
||||
"Home": "首頁",
|
||||
"Library": "圖書館",
|
||||
"{{count}} items_other": "{{count}} 項",
|
||||
"Download completed": "下載完成",
|
||||
"Download failed": "下載失敗",
|
||||
"Open Access": "開放存取",
|
||||
"Borrow": "借閱",
|
||||
"Buy": "購買",
|
||||
"Subscribe": "訂閱",
|
||||
"Sample": "試閱",
|
||||
"Download": "下載",
|
||||
"Open & Read": "開啟並閱讀",
|
||||
"Tags": "標籤",
|
||||
"Tag": "標籤",
|
||||
"First": "第一頁",
|
||||
"Previous": "上一頁",
|
||||
"Next": "下一頁",
|
||||
"Last": "最後一頁",
|
||||
"Cannot Load Page": "無法載入頁面",
|
||||
"An error occurred": "發生錯誤",
|
||||
"Online Library": "線上書庫"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.94": {
|
||||
"date": "2025-12-02",
|
||||
"notes": [
|
||||
"OPDS: You can now browse and download ebooks directly from OPDS catalogs",
|
||||
"PDF: Added a hand-tool mode that makes navigating PDF pages easier",
|
||||
"Covers: You can now choose a folder to save the latest book cover image",
|
||||
"CJK: Added one-tap conversion between Simplified and Traditional Chinese",
|
||||
"Shortcuts: Use Ctrl + mouse wheel to quickly zoom in or out",
|
||||
"Library: Added a new option to adjust cover sizes in grid view",
|
||||
"Windows: Added Ebook thumbnails with book covers in Windows Explorer",
|
||||
"TTS: Fixed an issue where the page would scroll when showing the annotator",
|
||||
"Annotations: Resolved layout shifts when selecting text in paginated mode on Android",
|
||||
"Files: Improved backup handling to prevent file corruption",
|
||||
"Layout: Fixed text clipping caused by negative indents",
|
||||
"E-ink: Improved sidebar and notebook background colors for e-ink devices",
|
||||
"Android: The Back button now works properly in menus, dialogs, and alerts",
|
||||
"iOS: Fixed occasional failures when opening books on older iOS versions (16.4 and below)"
|
||||
]
|
||||
},
|
||||
"0.9.93": {
|
||||
"date": "2025-11-20",
|
||||
"notes": [
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Sync release notes from JSON to XML AppData file
|
||||
# Usage: ./sync_release_notes.sh [json_file] [xml_file]
|
||||
#
|
||||
# Description:
|
||||
# Extracts release information from a JSON file and updates the <releases>
|
||||
# section in an AppData XML file, then calculates the SHA256 checksum.
|
||||
#
|
||||
# Arguments:
|
||||
# json_file: Path to JSON file (default: releases.json)
|
||||
# xml_file: Path to XML file (default: appdata.xml)
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Helper functions
|
||||
log_info() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}⚠${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}✗${NC} $1" >&2
|
||||
}
|
||||
|
||||
# Check if required commands are available
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
log_error "jq is required but not installed."
|
||||
echo "Install it with: apt-get install jq (Debian/Ubuntu) or brew install jq (macOS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
JSON_FILE="${1:-releases.json}"
|
||||
XML_FILE="${2:-appdata.xml}"
|
||||
|
||||
# Validate input files
|
||||
if [ ! -f "$JSON_FILE" ]; then
|
||||
log_error "JSON file '$JSON_FILE' not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$XML_FILE" ]; then
|
||||
log_error "XML file '$XML_FILE' not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate JSON format
|
||||
if ! jq empty "$JSON_FILE" 2>/dev/null; then
|
||||
log_error "Invalid JSON format in '$JSON_FILE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if releases key exists
|
||||
if ! jq -e '.releases' "$JSON_FILE" >/dev/null 2>&1; then
|
||||
log_error "No 'releases' key found in JSON file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "================================================"
|
||||
echo "Release Notes Sync Tool"
|
||||
echo "================================================"
|
||||
echo "Source: $JSON_FILE"
|
||||
echo "Target: $XML_FILE"
|
||||
echo ""
|
||||
|
||||
# Create backup
|
||||
BACKUP_FILE="${XML_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$XML_FILE" "$BACKUP_FILE"
|
||||
log_info "Created backup: $BACKUP_FILE"
|
||||
|
||||
# Create temporary files
|
||||
TEMP_XML=$(mktemp)
|
||||
TEMP_RELEASES=$(mktemp)
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
rm -f "$TEMP_XML" "$TEMP_RELEASES"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Extract releases from JSON and convert to XML format
|
||||
log_info "Extracting releases from JSON..."
|
||||
RELEASE_COUNT=$(jq '.releases | length' "$JSON_FILE")
|
||||
echo " Found $RELEASE_COUNT releases"
|
||||
|
||||
jq -r '
|
||||
.releases | to_entries | .[0:10] | .[] |
|
||||
" <release version=\"\(.key)\" date=\"\(.value.date)\">
|
||||
<description>
|
||||
<ul>
|
||||
" +
|
||||
((.value.notes | map(" <li>\(.)</li>")) | join("\n")) +
|
||||
"
|
||||
</ul>
|
||||
</description>
|
||||
</release>"
|
||||
' "$JSON_FILE" > "$TEMP_RELEASES"
|
||||
|
||||
# Find the releases section in XML and replace it
|
||||
log_info "Updating XML file..."
|
||||
awk -v releases="$TEMP_RELEASES" '
|
||||
BEGIN { in_releases = 0; printed_releases = 0 }
|
||||
/<releases>/ {
|
||||
print $0
|
||||
if (printed_releases == 0) {
|
||||
while ((getline line < releases) > 0) {
|
||||
print line
|
||||
}
|
||||
close(releases)
|
||||
printed_releases = 1
|
||||
}
|
||||
in_releases = 1
|
||||
next
|
||||
}
|
||||
/<\/releases>/ {
|
||||
in_releases = 0
|
||||
print $0
|
||||
next
|
||||
}
|
||||
in_releases { next }
|
||||
{ print }
|
||||
' "$XML_FILE" > "$TEMP_XML"
|
||||
|
||||
# Validate the generated XML
|
||||
if ! grep -q "<releases>" "$TEMP_XML" || ! grep -q "</releases>" "$TEMP_XML"; then
|
||||
log_error "Failed to generate valid XML structure"
|
||||
log_warn "Restoring from backup..."
|
||||
mv "$BACKUP_FILE" "$XML_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove backup if everything is fine
|
||||
rm "$BACKUP_FILE"
|
||||
|
||||
# Replace original file
|
||||
mv "$TEMP_XML" "$XML_FILE"
|
||||
log_info "XML file updated successfully"
|
||||
|
||||
# Calculate SHA256 checksum
|
||||
echo ""
|
||||
log_info "Calculating SHA256 checksum..."
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
CHECKSUM=$(sha256sum "$XML_FILE" | awk '{print $1}')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
CHECKSUM=$(shasum -a 256 "$XML_FILE" | awk '{print $1}')
|
||||
else
|
||||
log_error "Neither sha256sum nor shasum found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Save checksum to file
|
||||
CHECKSUM_FILE="${XML_FILE}.sha256"
|
||||
echo "$CHECKSUM $XML_FILE" > "$CHECKSUM_FILE"
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo "Summary"
|
||||
echo "================================================"
|
||||
echo " Updated file: $XML_FILE"
|
||||
echo " Backup file: $BACKUP_FILE"
|
||||
echo " Checksum file: $CHECKSUM_FILE"
|
||||
echo " SHA256: $CHECKSUM"
|
||||
echo " Releases: $RELEASE_COUNT"
|
||||
echo ""
|
||||
log_info "Sync completed successfully!"
|
||||
echo ""
|
||||
echo "To verify the checksum, run:"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
echo " sha256sum -c $CHECKSUM_FILE"
|
||||
else
|
||||
echo " shasum -a 256 -c $CHECKSUM_FILE"
|
||||
fi
|
||||
@@ -1,3 +1,52 @@
|
||||
use std::{env, fs, path::PathBuf, process::Command};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../extensions/windows-thumbnail/src");
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os == "windows" {
|
||||
build_windows_thumbnail();
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
fn build_windows_thumbnail() {
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let dll_crate_dir = manifest_dir
|
||||
.join("..")
|
||||
.join("extensions")
|
||||
.join("windows-thumbnail");
|
||||
let dll_crate_manifest = dll_crate_dir.join("Cargo.toml");
|
||||
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".into());
|
||||
|
||||
let mut cmd = Command::new(env::var("CARGO").unwrap_or("cargo".into()));
|
||||
cmd.arg("build")
|
||||
.arg("--package")
|
||||
.arg("windows_thumbnail")
|
||||
.arg("--manifest-path")
|
||||
.arg(&dll_crate_manifest);
|
||||
|
||||
if profile == "release" {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
let target_triple = env::var("TARGET").unwrap_or_default();
|
||||
let host_triple = env::var("HOST").unwrap_or_default();
|
||||
if !target_triple.is_empty() && target_triple != host_triple {
|
||||
cmd.arg("--target").arg(&target_triple);
|
||||
}
|
||||
|
||||
let status = cmd
|
||||
.status()
|
||||
.expect("Failed to run cargo build for windows_thumbnail");
|
||||
if !status.success() {
|
||||
panic!("Failed to build windows_thumbnail DLL");
|
||||
}
|
||||
|
||||
let dll_name = "windows_thumbnail.dll";
|
||||
let dll_src = dll_crate_dir.join("target").join(&profile).join(dll_name);
|
||||
let dll_dest = dll_crate_dir.join("target").join(dll_name);
|
||||
|
||||
fs::copy(&dll_src, &dll_dest).expect("Failed to copy windows_thumbnail DLL");
|
||||
println!("cargo:rerun-if-changed={}", dll_dest.display());
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
},
|
||||
{
|
||||
"path": "**/Readest/**/*"
|
||||
},
|
||||
{
|
||||
"path": "**/last-book-cover.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+8
@@ -6,6 +6,7 @@ import androidx.activity.enableEdgeToEdge
|
||||
import android.view.KeyEvent
|
||||
import android.webkit.WebView
|
||||
import android.util.Log
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.app.ActivityManager
|
||||
import android.content.res.Configuration
|
||||
@@ -13,6 +14,7 @@ import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import com.readest.native_bridge.KeyDownInterceptor
|
||||
import com.readest.native_bridge.NativeBridgePlugin
|
||||
|
||||
class MainActivity : TauriActivity(), KeyDownInterceptor {
|
||||
private lateinit var wv: WebView
|
||||
@@ -92,4 +94,10 @@ class MainActivity : TauriActivity(), KeyDownInterceptor {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
NativeBridgePlugin.getInstance()?.handleActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
; Readest NSIS Installer Hooks
|
||||
; Registers/unregisters the thumbnail provider DLL for Windows Explorer thumbnails
|
||||
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
; CLSID for Readest Thumbnail Provider
|
||||
!define CLSID_READEST_THUMBNAIL "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
|
||||
; IThumbnailProvider Shell Extension Handler GUID
|
||||
!define SHELL_THUMBNAIL_HANDLER "{e357fccd-a995-4576-b01f-234630154e96}"
|
||||
|
||||
;------------------------------------------------------------------------------
|
||||
; NSIS_HOOK_POSTINSTALL - Called after files are installed
|
||||
;------------------------------------------------------------------------------
|
||||
!macro NSIS_HOOK_POSTINSTALL
|
||||
DetailPrint "Registering Readest Thumbnail Provider..."
|
||||
|
||||
; Always do manual registration for reliability
|
||||
; regsvr32 may fail silently if DLL can't find dependencies
|
||||
|
||||
; Register CLSID with full DLL path
|
||||
WriteRegStr HKCR "CLSID\${CLSID_READEST_THUMBNAIL}" "" "Readest Thumbnail Provider"
|
||||
WriteRegStr HKCR "CLSID\${CLSID_READEST_THUMBNAIL}\InprocServer32" "" "$INSTDIR\readest_thumbnail.dll"
|
||||
WriteRegStr HKCR "CLSID\${CLSID_READEST_THUMBNAIL}\InprocServer32" "ThreadingModel" "Apartment"
|
||||
|
||||
; CRITICAL: Disable process isolation - required because we use IInitializeWithItem, not IInitializeWithStream
|
||||
; Without this, Windows runs the handler in an isolated process that can't load the DLL properly
|
||||
WriteRegDWORD HKCR "CLSID\${CLSID_READEST_THUMBNAIL}" "DisableProcessIsolation" 1
|
||||
|
||||
; ========== EPUB ==========
|
||||
; Register thumbnail handler directly on extension (this is what Windows Shell uses)
|
||||
WriteRegStr HKCR ".epub\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
; ========== MOBI ==========
|
||||
WriteRegStr HKCR ".mobi\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
; ========== AZW ==========
|
||||
WriteRegStr HKCR ".azw\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
; ========== AZW3 ==========
|
||||
WriteRegStr HKCR ".azw3\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
; ========== KF8 ==========
|
||||
WriteRegStr HKCR ".kf8\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
; ========== FB2 ==========
|
||||
WriteRegStr HKCR ".fb2\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
; ========== CBZ ==========
|
||||
WriteRegStr HKCR ".cbz\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
; ========== CBR ==========
|
||||
WriteRegStr HKCR ".cbr\ShellEx\${SHELL_THUMBNAIL_HANDLER}" "" "${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
DetailPrint "Thumbnail provider registered successfully."
|
||||
|
||||
; Refresh shell to apply changes - SHCNE_ASSOCCHANGED
|
||||
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
|
||||
!macroend
|
||||
|
||||
;------------------------------------------------------------------------------
|
||||
; NSIS_HOOK_PREUNINSTALL - Called before files are removed
|
||||
;------------------------------------------------------------------------------
|
||||
!macro NSIS_HOOK_PREUNINSTALL
|
||||
DetailPrint "Unregistering Readest Thumbnail Provider..."
|
||||
|
||||
; Remove CLSID
|
||||
DeleteRegKey HKCR "CLSID\${CLSID_READEST_THUMBNAIL}"
|
||||
|
||||
; Remove ShellEx from extensions
|
||||
DeleteRegKey HKCR ".epub\ShellEx\${SHELL_THUMBNAIL_HANDLER}"
|
||||
DeleteRegKey HKCR ".mobi\ShellEx\${SHELL_THUMBNAIL_HANDLER}"
|
||||
DeleteRegKey HKCR ".azw\ShellEx\${SHELL_THUMBNAIL_HANDLER}"
|
||||
DeleteRegKey HKCR ".azw3\ShellEx\${SHELL_THUMBNAIL_HANDLER}"
|
||||
DeleteRegKey HKCR ".kf8\ShellEx\${SHELL_THUMBNAIL_HANDLER}"
|
||||
DeleteRegKey HKCR ".fb2\ShellEx\${SHELL_THUMBNAIL_HANDLER}"
|
||||
DeleteRegKey HKCR ".cbz\ShellEx\${SHELL_THUMBNAIL_HANDLER}"
|
||||
DeleteRegKey HKCR ".cbr\ShellEx\${SHELL_THUMBNAIL_HANDLER}"
|
||||
|
||||
; Delete the DLL file
|
||||
Delete "$INSTDIR\readest_thumbnail.dll"
|
||||
|
||||
; Refresh shell
|
||||
System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)'
|
||||
!macroend
|
||||
+92
-1
@@ -8,6 +8,7 @@ import android.util.Log
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.provider.DocumentsContract
|
||||
import android.view.View
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowInsets
|
||||
@@ -25,6 +26,8 @@ import androidx.core.content.FileProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
@@ -128,11 +131,16 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
var pendingInvoke: Invoke? = null
|
||||
private const val REQUEST_MANAGE_STORAGE = 1001
|
||||
private const val FOLDER_PICKER_REQUEST_CODE = 1002
|
||||
var pendingInvoke: Invoke? = null
|
||||
var pendingFolderPickerInvoke: Invoke? = null
|
||||
private var instance: NativeBridgePlugin? = null
|
||||
fun getInstance(): NativeBridgePlugin? = instance
|
||||
}
|
||||
|
||||
override fun load(webView: WebView) {
|
||||
instance = this
|
||||
super.load(webView)
|
||||
handleIntent(activity.intent)
|
||||
}
|
||||
@@ -682,4 +690,87 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
invoke.reject("Failed to open URL: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun select_directory(invoke: Invoke) {
|
||||
pendingFolderPickerInvoke = invoke
|
||||
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
activity.startActivityForResult(intent, FOLDER_PICKER_REQUEST_CODE)
|
||||
} catch (e: Exception) {
|
||||
val result = JSObject()
|
||||
result.put("cancelled", true)
|
||||
result.put("uri", null)
|
||||
result.put("path", null)
|
||||
result.put("error", e.message)
|
||||
invoke.resolve(result)
|
||||
pendingFolderPickerInvoke = null
|
||||
}
|
||||
}
|
||||
|
||||
fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == FOLDER_PICKER_REQUEST_CODE) {
|
||||
val invoke = pendingFolderPickerInvoke
|
||||
if (invoke != null) {
|
||||
handleDirectorySelected(data?.data, invoke)
|
||||
pendingFolderPickerInvoke = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDirectorySelected(uri: Uri?, invoke: Invoke) {
|
||||
val result = JSObject()
|
||||
if (uri == null) {
|
||||
result.put("cancelled", true)
|
||||
result.put("uri", null)
|
||||
result.put("path", null)
|
||||
} else {
|
||||
try {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
activity.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
result.put("cancelled", false)
|
||||
result.put("uri", uri.toString())
|
||||
result.put("path", extractPathFromUri(uri))
|
||||
} catch (e: SecurityException) {
|
||||
result.put("cancelled", true)
|
||||
result.put("uri", uri.toString())
|
||||
result.put("path", extractPathFromUri(uri))
|
||||
result.put("error", "Permission error: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
result.put("cancelled", true)
|
||||
result.put("uri", null)
|
||||
result.put("path", null)
|
||||
result.put("error", "Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
invoke.resolve(result)
|
||||
pendingInvoke = null
|
||||
}
|
||||
|
||||
private fun extractPathFromUri(uri: Uri): String? {
|
||||
val path = uri.path ?: return null
|
||||
return try {
|
||||
when {
|
||||
DocumentsContract.isTreeUri(uri) -> {
|
||||
val treeDocId = DocumentsContract.getTreeDocumentId(uri)
|
||||
val split = treeDocId.split(":")
|
||||
if (split[0].equals("primary", ignoreCase = true)) {
|
||||
if (split.size > 1) {
|
||||
Environment.getExternalStorageDirectory().path + "/" + split[1]
|
||||
} else {
|
||||
Environment.getExternalStorageDirectory().path
|
||||
}
|
||||
} else {
|
||||
"/storage/${split[0]}/" + (if (split.size > 1) split[1] else "")
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const COMMANDS: &[&str] = &[
|
||||
"set_screen_brightness",
|
||||
"get_external_sdcard_path",
|
||||
"open_external_url",
|
||||
"select_directory",
|
||||
"request_manage_storage_permission",
|
||||
"check_permissions",
|
||||
"request_permissions",
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-select-directory"
|
||||
description = "Enables the select_directory command without any pre-configured scope."
|
||||
commands.allow = ["select_directory"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-select-directory"
|
||||
description = "Denies the select_directory command without any pre-configured scope."
|
||||
commands.deny = ["select_directory"]
|
||||
+27
@@ -24,6 +24,7 @@ Default permissions for the plugin
|
||||
- `allow-set-screen-brightness`
|
||||
- `allow-get-external-sdcard-path`
|
||||
- `allow-open-external-url`
|
||||
- `allow-select-directory`
|
||||
- `allow-request-manage-storage-permission`
|
||||
- `allow-check-permissions`
|
||||
- `allow-request-permissions`
|
||||
@@ -666,6 +667,32 @@ Denies the request_permissions command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-select-directory`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the select_directory command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-select-directory`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the select_directory command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-set-screen-brightness`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -21,6 +21,7 @@ permissions = [
|
||||
"allow-set-screen-brightness",
|
||||
"allow-get-external-sdcard-path",
|
||||
"allow-open-external-url",
|
||||
"allow-select-directory",
|
||||
"allow-request-manage-storage-permission",
|
||||
"allow-check-permissions",
|
||||
"allow-request-permissions",
|
||||
|
||||
+14
-2
@@ -582,6 +582,18 @@
|
||||
"const": "deny-request-permissions",
|
||||
"markdownDescription": "Denies the request_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the select_directory command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-select-directory",
|
||||
"markdownDescription": "Enables the select_directory command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the select_directory command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-select-directory",
|
||||
"markdownDescription": "Denies the select_directory command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_screen_brightness command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -619,10 +631,10 @@
|
||||
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-request-manage-storage-permission`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -157,6 +157,13 @@ pub(crate) async fn open_external_url<R: Runtime>(
|
||||
app.native_bridge().open_external_url(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn select_directory<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
) -> Result<SelectDirectoryResponse> {
|
||||
app.native_bridge().select_directory()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn request_manage_storage_permission<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
|
||||
@@ -129,6 +129,10 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
|
||||
Err(crate::Error::UnsupportedPlatformError)
|
||||
}
|
||||
|
||||
pub fn request_manage_storage_permission(
|
||||
&self,
|
||||
) -> crate::Result<RequestManageStoragePermissionResponse> {
|
||||
|
||||
@@ -57,6 +57,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
commands::set_screen_brightness,
|
||||
commands::get_external_sdcard_path,
|
||||
commands::open_external_url,
|
||||
commands::select_directory,
|
||||
commands::request_manage_storage_permission,
|
||||
])
|
||||
.setup(|app, api| {
|
||||
|
||||
@@ -208,6 +208,14 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("select_directory", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn request_manage_storage_permission(
|
||||
&self,
|
||||
|
||||
@@ -215,3 +215,12 @@ pub struct OpenExternalUrlResponse {
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SelectDirectoryResponse {
|
||||
pub cancelled: Option<bool>,
|
||||
pub uri: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ extern crate cocoa;
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
use tauri::utils::config::BackgroundThrottlingPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
@@ -268,7 +271,9 @@ pub fn run() {
|
||||
.initialization_script(
|
||||
r#"
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
document.documentElement.classList.add('edge-to-edge');
|
||||
const isTauriLocal = window.location.protocol === 'tauri:' ||
|
||||
window.location.protocol === 'about:' ||
|
||||
window.location.hostname === 'tauri.localhost';
|
||||
const needsSafeArea = !isTauriLocal;
|
||||
if (needsSafeArea && !document.getElementById('safe-area-style')) {
|
||||
|
||||
@@ -107,6 +107,7 @@ pub async fn download_file(
|
||||
file_path: &str,
|
||||
headers: HashMap<String, String>,
|
||||
body: Option<String>,
|
||||
single_threaded: Option<bool>,
|
||||
on_progress: Channel<ProgressPayload>,
|
||||
) -> Result<()> {
|
||||
use futures::stream::{self, StreamExt};
|
||||
@@ -116,32 +117,23 @@ pub async fn download_file(
|
||||
const PART_SIZE: u64 = 1024 * 1024;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let force_single = single_threaded.unwrap_or(false);
|
||||
|
||||
// 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
|
||||
async fn single_threaded_download(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
file_path: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
body: &Option<String>,
|
||||
on_progress: Channel<ProgressPayload>,
|
||||
) -> Result<()> {
|
||||
let mut request = if let Some(body) = body {
|
||||
client.post(url).body(body)
|
||||
client.post(url).body(body.clone())
|
||||
} else {
|
||||
client.get(url)
|
||||
};
|
||||
|
||||
for (key, value) in headers.iter() {
|
||||
for (key, value) in headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
@@ -168,7 +160,38 @@ pub async fn download_file(
|
||||
});
|
||||
}
|
||||
file.flush().await?;
|
||||
return Ok(());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if force_single {
|
||||
return single_threaded_download(&client, url, file_path, &headers, &body, on_progress)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Check if server supports range requests
|
||||
let mut range_req = client.get(url).header("Range", "bytes=0-0");
|
||||
for (key, value) in headers.iter() {
|
||||
range_req = range_req.header(key, value);
|
||||
}
|
||||
let range_resp = range_req.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 {
|
||||
return single_threaded_download(&client, url, file_path, &headers, &body, on_progress)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Multi-part download with range access
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
},
|
||||
"nsis": {
|
||||
"installerHooks": "nsis/installer-hooks.nsh",
|
||||
"installMode": "both"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"bundle": {
|
||||
"resources": {
|
||||
"../extensions/windows-thumbnail/target/windows_thumbnail.dll": "readest_thumbnail.dll"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export const setupSupabaseMocks = async (
|
||||
eq: vi.fn().mockResolvedValue(customResponses.update || { data: {}, error: null }),
|
||||
})),
|
||||
insert: vi.fn().mockResolvedValue(customResponses.insert || { data: {}, error: null }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(createSupabaseAdminClient).mockReturnValue({
|
||||
@@ -101,5 +102,6 @@ export const setupSupabaseMocks = async (
|
||||
match: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
|
||||
})),
|
||||
})),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, test, expect, beforeAll } from 'vitest';
|
||||
import init, { simplecc } from '@simplecc/simplecc_wasm';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
describe.concurrent('suite', () => {
|
||||
beforeAll(async () => {
|
||||
const wasmPath = join(process.cwd(), 'public/vendor/simplecc/simplecc_wasm_bg.wasm');
|
||||
const wasmBuffer = await readFile(wasmPath);
|
||||
await init(wasmBuffer);
|
||||
});
|
||||
|
||||
test('basic s2t and t2s', () => {
|
||||
expect(simplecc('发财了去植发', 's2t')).toBe('發財了去植髮');
|
||||
expect(simplecc('發財了去植髮', 't2s')).toBe('发财了去植发');
|
||||
});
|
||||
|
||||
test('s2tw - Simplified to Traditional (Taiwan)', () => {
|
||||
expect(simplecc('鼠标', 's2tw')).toBe('鼠標');
|
||||
expect(simplecc('软件', 's2tw')).toBe('軟件');
|
||||
expect(simplecc('信息', 's2tw')).toBe('信息');
|
||||
});
|
||||
|
||||
test('s2twp - Simplified to Traditional (Taiwan) with phrases', () => {
|
||||
expect(simplecc('计算机', 's2twp')).toBe('計算機');
|
||||
expect(simplecc('打印机', 's2twp')).toBe('印表機');
|
||||
expect(simplecc('激光', 's2twp')).toBe('雷射');
|
||||
expect(simplecc('鼠标里面的硅二极管坏了,导致光标分辨率降低。', 's2twp')).toBe(
|
||||
'滑鼠裡面的矽二極體壞了,導致游標解析度降低。',
|
||||
);
|
||||
});
|
||||
|
||||
test('tw2s - Traditional (Taiwan) to Simplified', () => {
|
||||
expect(simplecc('滑鼠', 'tw2s')).toBe('滑鼠');
|
||||
expect(simplecc('軟體', 'tw2s')).toBe('软体');
|
||||
expect(simplecc('資訊', 'tw2s')).toBe('资讯');
|
||||
});
|
||||
|
||||
test('tw2sp - Traditional (Taiwan) to Simplified with phrases', () => {
|
||||
expect(simplecc('電腦', 'tw2sp')).toBe('电脑');
|
||||
expect(simplecc('印表機', 'tw2sp')).toBe('打印机');
|
||||
expect(simplecc('雷射', 'tw2sp')).toBe('激光');
|
||||
expect(
|
||||
simplecc('我們在寮國的伺服器的硬碟需要使用網際網路演算法軟體解決非同步的問題。', 'tw2sp'),
|
||||
).toBe('我们在老挝的服务器的硬盘需要使用互联网算法软件解决异步的问题。');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
|
||||
// Cloudflare Workers incorrectly decodes %26 to & in the url parameter value,
|
||||
// causing query parameters within the proxied URL (like &start_index=26) to be
|
||||
// treated as separate top-level parameters instead of part of the url value.
|
||||
// We work around this by manually extracting the url parameter - capturing everything
|
||||
// from 'url=' until we hit our known parameters (&stream= or &auth=), then decoding it.
|
||||
const fullUrl = request.url;
|
||||
const urlParamStart = fullUrl.indexOf('url=') + 4;
|
||||
const streamParam = fullUrl.lastIndexOf('&stream=');
|
||||
const authParam = fullUrl.lastIndexOf('&auth=');
|
||||
const urlParamEnd = Math.min(...[streamParam, authParam].filter((i) => i > 0), fullUrl.length);
|
||||
const encodedUrl = fullUrl.substring(urlParamStart, urlParamEnd);
|
||||
const url = decodeURIComponent(encodedUrl);
|
||||
const auth = request.nextUrl.searchParams.get('auth');
|
||||
const stream = request.nextUrl.searchParams.get('stream');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing URL parameter. Usage: /api/opds/proxy?url=YOUR_OPDS_URL' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[OPDS Proxy] ${method}: ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const headers: HeadersInit = {
|
||||
'User-Agent': 'Readest/1.0 (OPDS Browser)',
|
||||
Accept: 'application/atom+xml, application/xml, text/xml, application/json, */*',
|
||||
};
|
||||
|
||||
if (auth) {
|
||||
headers['Authorization'] = auth;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[OPDS Proxy] HTTP ${response.status} for ${url}`);
|
||||
if (method === 'HEAD') {
|
||||
console.log(`[OPDS Proxy] Response headers:`, response.headers);
|
||||
if (response.status === 401) {
|
||||
return new NextResponse(null, {
|
||||
status: 403,
|
||||
headers: {
|
||||
...Object.fromEntries(response.headers.entries()),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
if (response.status === 401) {
|
||||
return new NextResponse(data, {
|
||||
status: 403,
|
||||
headers: {
|
||||
...Object.fromEntries(response.headers.entries()),
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || 'text/xml';
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
|
||||
if (method === 'HEAD') {
|
||||
console.log(`[OPDS Proxy] HEAD Success: ${url}`);
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': contentLength || '',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (stream === 'true' || (contentLength && parseInt(contentLength) > 1024 * 1024)) {
|
||||
console.log(`[OPDS Proxy] Streaming: ${url}`);
|
||||
|
||||
return new NextResponse(response.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'X-Content-Length': contentLength || '',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const data = await response.text();
|
||||
console.log(`[OPDS Proxy] Success: ${url} (${data.length} bytes)`);
|
||||
|
||||
return new NextResponse(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OPDS Proxy] Error:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Request timeout - the OPDS server took too long to respond' },
|
||||
{ status: 504 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
url: url,
|
||||
hint: 'Check if the OPDS URL is accessible and returns valid OPDS/Atom/JSON content',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to fetch OPDS feed', url: url }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, 'GET');
|
||||
}
|
||||
|
||||
export async function HEAD(request: NextRequest) {
|
||||
return handleRequest(request, 'HEAD');
|
||||
}
|
||||
|
||||
export async function OPTIONS(_: NextRequest) {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export async function POST(request: NextRequest) {
|
||||
ui_mode: embedded ? 'embedded' : 'hosted',
|
||||
customer: customerId,
|
||||
mode: planType === 'subscription' ? 'subscription' : 'payment',
|
||||
allow_promotion_codes: true,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
|
||||
@@ -204,10 +204,3 @@ async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
|
||||
.eq('id', subscriptionData.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
// This is needed to parse the body as a stream for the webhook signature verification
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -344,7 +344,7 @@ export default function AuthPage() {
|
||||
return isTauriAppPlatform() ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-100 inset-0 flex h-[100vh] select-none flex-col items-center overflow-hidden',
|
||||
'bg-base-100 full-height inset-0 flex select-none flex-col items-center overflow-hidden',
|
||||
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -42,8 +42,8 @@ export default function UpdateEmailPage() {
|
||||
),
|
||||
);
|
||||
setEmail('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || _('Failed to update email'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : _('Failed to update email'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -54,17 +54,27 @@ export default function Error({ error, reset }: ErrorPageProps) {
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className='alert alert-error mb-8'>
|
||||
<div className='flex-col items-start text-left'>
|
||||
<div className='alert alert-error mb-8 overflow-hidden'>
|
||||
<div className='w-full min-w-0 flex-col items-start text-left'>
|
||||
<h3 className='mb-2 font-bold'>{_('Error Details:')}</h3>
|
||||
<p className='break-words font-mono text-sm'>{error.message}</p>
|
||||
{browserInfo && <p className='mt-2 font-mono text-sm'>Browser: {browserInfo}</p>}
|
||||
<p className='overflow-wrap-anywhere w-full break-words font-mono text-sm'>
|
||||
{error.message}
|
||||
</p>
|
||||
{browserInfo && (
|
||||
<p className='overflow-wrap-anywhere mt-2 w-full break-words font-mono text-sm'>
|
||||
Browser: {browserInfo}
|
||||
</p>
|
||||
)}
|
||||
{error.stack && (
|
||||
<p className='mt-2 whitespace-pre-wrap break-all font-mono text-sm'>
|
||||
<p className='overflow-wrap-anywhere mt-2 w-full whitespace-pre-wrap break-words font-mono text-sm'>
|
||||
{error.stack.split('\n').slice(0, 3).join('\n')}
|
||||
</p>
|
||||
)}
|
||||
{error.digest && <p className='mt-2 text-xs opacity-70'>Error ID: {error.digest}</p>}
|
||||
{error.digest && (
|
||||
<p className='overflow-wrap-anywhere mt-2 w-full break-words text-xs opacity-70'>
|
||||
Error ID: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
'book-item flex',
|
||||
mode === 'grid' && 'h-full flex-col justify-end',
|
||||
mode === 'list' && 'h-28 flex-row gap-4 overflow-hidden',
|
||||
mode === 'list' ? 'library-list-item' : 'library-grid-item',
|
||||
appService?.hasContextMenu ? 'cursor-pointer' : '',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -2,8 +2,6 @@ import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, 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';
|
||||
import { Book } from '@/types/book';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
@@ -13,16 +11,17 @@ import { useAutoFocus } from '@/hooks/useAutoFocus';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
|
||||
import { createBookFilter, createBookSorter } from '../utils/libraryUtils';
|
||||
import { formatTitle } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
|
||||
import SelectModeActions from './SelectModeActions';
|
||||
import GroupingModal from './GroupingModal';
|
||||
|
||||
interface BookshelfProps {
|
||||
@@ -60,33 +59,62 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const { appService } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { safeAreaInsets } = useThemeStore();
|
||||
|
||||
const groupId = searchParams?.get('group') || '';
|
||||
const queryTerm = searchParams?.get('q') || null;
|
||||
const viewMode = searchParams?.get('view') || settings.libraryViewMode;
|
||||
const sortBy = searchParams?.get('sort') || settings.librarySortBy;
|
||||
const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
|
||||
const coverFit = searchParams?.get('cover') || settings.libraryCoverFit;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
|
||||
const [bookIdsToDelete, setBookIdsToDelete] = useState<string[]>([]);
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
const [queryTerm, setQueryTerm] = useState<string | null>(null);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const [groupId, setGroupId] = useState(searchParams?.get('group') || '');
|
||||
const [viewMode, setViewMode] = useState(searchParams?.get('view') || settings.libraryViewMode);
|
||||
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || settings.librarySortBy);
|
||||
const [sortOrder, setSortOrder] = useState(
|
||||
searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc'),
|
||||
);
|
||||
const [coverFit, setCoverFit] = useState(searchParams?.get('cover') || settings.libraryCoverFit);
|
||||
|
||||
const isImportingBook = useRef(false);
|
||||
const iconSize15 = useResponsiveSize(15);
|
||||
const autofocusRef = useAutoFocus<HTMLDivElement>();
|
||||
|
||||
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
|
||||
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
|
||||
const { getGroupName } = useLibraryStore();
|
||||
|
||||
const bookFilter = useMemo(() => createBookFilter(queryTerm), [queryTerm]);
|
||||
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
|
||||
const bookSorter = useMemo(() => createBookSorter(sortBy, uiLanguage), [sortBy, uiLanguage]);
|
||||
|
||||
const updateUrlParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === '') {
|
||||
params.delete(key);
|
||||
} else {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
if (params.get('sort') === 'updated') params.delete('sort');
|
||||
if (params.get('order') === 'desc') params.delete('order');
|
||||
if (params.get('cover') === 'crop') params.delete('cover');
|
||||
if (params.get('view') === 'grid') params.delete('view');
|
||||
|
||||
const newParamString = params.toString();
|
||||
const currentParamString = searchParams?.toString() || '';
|
||||
|
||||
if (newParamString !== currentParamString) {
|
||||
navigateToLibrary(router, newParamString);
|
||||
}
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const filteredBooks = useMemo(() => {
|
||||
const bookFilter = createBookFilter(queryTerm);
|
||||
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks;
|
||||
}, [libraryBooks, queryTerm, bookFilter]);
|
||||
}, [libraryBooks, queryTerm]);
|
||||
|
||||
const currentBookshelfItems = useMemo(() => {
|
||||
const groupName = getGroupName(groupId) || '';
|
||||
@@ -97,7 +125,33 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
return items;
|
||||
}, [filteredBooks, groupId, getGroupName]);
|
||||
|
||||
const autofocusRef = useAutoFocus<HTMLDivElement>();
|
||||
useEffect(() => {
|
||||
if (groupId && currentBookshelfItems.length === 0) {
|
||||
updateUrlParams({ group: null });
|
||||
} else {
|
||||
updateUrlParams({});
|
||||
}
|
||||
}, [searchParams, groupId, currentBookshelfItems.length, updateUrlParams]);
|
||||
|
||||
const sortedBookshelfItems = useMemo(() => {
|
||||
const bookSorter = createBookSorter(sortBy, uiLanguage);
|
||||
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
|
||||
return currentBookshelfItems.sort((a, b) => {
|
||||
if (sortBy === 'updated') {
|
||||
return (
|
||||
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
|
||||
);
|
||||
} else if ('name' in a || 'name' in b) {
|
||||
const aName = 'name' in a ? a.name : formatTitle(a.title);
|
||||
const bName = 'name' in b ? b.name : formatTitle(b.title);
|
||||
return aName.localeCompare(bName, uiLanguage || navigator.language) * sortOrderMultiplier;
|
||||
} else if (!('name' in a || 'name' in b)) {
|
||||
return bookSorter(a, b) * sortOrderMultiplier;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [sortOrder, sortBy, uiLanguage, currentBookshelfItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isImportingBook.current) return;
|
||||
@@ -122,62 +176,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
setCurrentBookshelf(currentBookshelfItems);
|
||||
}, [currentBookshelfItems, setCurrentBookshelf]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
const query = searchParams?.get('q') || '';
|
||||
const view = searchParams?.get('view') || settings.libraryViewMode;
|
||||
const sort = searchParams?.get('sort') || settings.librarySortBy;
|
||||
const order = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
|
||||
const cover = searchParams?.get('cover') || settings.libraryCoverFit;
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (query) {
|
||||
params.set('q', query);
|
||||
setQueryTerm(query);
|
||||
} else {
|
||||
params.delete('q');
|
||||
setQueryTerm(null);
|
||||
}
|
||||
if (sort) {
|
||||
params.set('sort', sort);
|
||||
setSortBy(sort);
|
||||
} else {
|
||||
params.delete('sort');
|
||||
}
|
||||
if (order) {
|
||||
params.set('order', order);
|
||||
setSortOrder(order);
|
||||
} else {
|
||||
params.delete('order');
|
||||
}
|
||||
if (view) {
|
||||
params.set('view', view);
|
||||
setViewMode(view);
|
||||
} else {
|
||||
params.delete('view');
|
||||
}
|
||||
setCoverFit(cover);
|
||||
if (cover === 'crop') {
|
||||
params.delete('cover');
|
||||
}
|
||||
if (sort === 'updated' && order === 'desc' && view === 'grid') {
|
||||
params.delete('sort');
|
||||
params.delete('order');
|
||||
params.delete('view');
|
||||
}
|
||||
setGroupId(group);
|
||||
if (group) {
|
||||
if (currentBookshelfItems.length > 0) {
|
||||
params.set('group', group);
|
||||
} else {
|
||||
params.delete('group');
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}
|
||||
} else {
|
||||
params.delete('group');
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}
|
||||
}, [router, settings, searchParams, currentBookshelfItems, showGroupingModal]);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(id: string) => {
|
||||
toggleSelectedBook(id);
|
||||
@@ -248,25 +246,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
setShowDeleteAlert(true);
|
||||
};
|
||||
|
||||
const sortedBookshelfItems = useMemo(() => {
|
||||
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
|
||||
return currentBookshelfItems.sort((a, b) => {
|
||||
if (sortBy === 'updated') {
|
||||
return (
|
||||
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
|
||||
);
|
||||
} else if ('name' in a || 'name' in b) {
|
||||
const aName = 'name' in a ? a.name : formatTitle(a.title);
|
||||
const bName = 'name' in b ? b.name : formatTitle(b.title);
|
||||
return aName.localeCompare(bName, uiLanguage || navigator.language) * sortOrderMultiplier;
|
||||
} else if (!('name' in a || 'name' in b)) {
|
||||
return bookSorter(a, b) * sortOrderMultiplier;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [sortOrder, sortBy, uiLanguage, currentBookshelfItems, bookSorter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectMode) {
|
||||
setShowSelectModeActions(true);
|
||||
@@ -304,6 +283,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
viewMode === 'grid' && 'sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
|
||||
viewMode === 'list' && 'flex flex-col',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
viewMode === 'grid' && !settings.libraryAutoColumns
|
||||
? `repeat(${settings.libraryColumns}, minmax(0, 1fr))`
|
||||
: undefined,
|
||||
}}
|
||||
role='main'
|
||||
aria-label={_('Bookshelf')}
|
||||
>
|
||||
@@ -330,7 +315,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
/>
|
||||
))}
|
||||
{viewMode === 'grid' && currentBookshelfItems.length > 0 && (
|
||||
<div className={clsx('mx-0 my-2 sm:mx-4 sm:my-4')}>
|
||||
<div
|
||||
className={clsx('mx-0 my-2 sm:mx-4 sm:my-4')}
|
||||
style={
|
||||
coverFit === 'fit' && viewMode === 'grid'
|
||||
? {
|
||||
display: 'flex',
|
||||
paddingBottom: `${iconSize15 + 24}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
aria-label={_('Import Books')}
|
||||
className={clsx(
|
||||
@@ -352,73 +347,18 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className='fixed bottom-0 left-0 right-0 z-40'
|
||||
style={{
|
||||
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
|
||||
}}
|
||||
>
|
||||
{isSelectMode && showSelectModeActions && (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-center shadow-lg',
|
||||
'bg-base-300 text-base-content text-xs',
|
||||
'mx-auto w-fit space-x-6 rounded-lg p-4',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={openSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
(!selectedBooks.length || !selectedBooks.every((id) => isMd5(id))) &&
|
||||
'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdOpenInNew />
|
||||
<div>{_('Open')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={groupSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!selectedBooks.length && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<LuFolderPlus />
|
||||
<div>{_('Group')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={openBookDetails}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
(selectedBooks.length !== 1 || !selectedBooks.every((id) => isMd5(id))) &&
|
||||
'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdInfoOutline />
|
||||
<div>{_('Details')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSelectedBooks}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!selectedBooks.length && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdDelete className='text-red-500' />
|
||||
<div className='text-red-500'>{_('Delete')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetSelectMode(false)}
|
||||
className={clsx('flex flex-col items-center justify-center gap-1')}
|
||||
>
|
||||
<MdOutlineCancel />
|
||||
<div>{_('Cancel')}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showGroupingModal && (
|
||||
{!showGroupingModal && isSelectMode && showSelectModeActions && (
|
||||
<SelectModeActions
|
||||
selectedBooks={selectedBooks}
|
||||
safeAreaBottom={safeAreaInsets?.bottom || 0}
|
||||
onOpen={openSelectedBooks}
|
||||
onGroup={groupSelectedBooks}
|
||||
onDetails={openBookDetails}
|
||||
onDelete={deleteSelectedBooks}
|
||||
onCancel={() => handleSetSelectMode(false)}
|
||||
/>
|
||||
)}
|
||||
{showGroupingModal && selectedBooks.length > 0 && (
|
||||
<ModalPortal>
|
||||
<GroupingModal
|
||||
libraryBooks={libraryBooks}
|
||||
|
||||
@@ -20,8 +20,6 @@ import { md5Fingerprint } from '@/utils/md5';
|
||||
import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
|
||||
export type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
export const generateBookshelfItems = (
|
||||
books: Book[],
|
||||
parentGroupName: string,
|
||||
@@ -51,7 +49,7 @@ export const generateBookshelfItems = (
|
||||
booksGroup.books.push(book);
|
||||
booksGroup.updatedAt = Math.max(booksGroup.updatedAt, book.updatedAt);
|
||||
} else {
|
||||
let groupName = fullGroupName;
|
||||
const groupName = fullGroupName;
|
||||
acc.push({
|
||||
id: groupName === parentGroupName ? BOOK_UNGROUPED_ID : md5Fingerprint(groupName),
|
||||
name: groupName === parentGroupName ? BOOK_UNGROUPED_NAME : groupName,
|
||||
@@ -76,7 +74,7 @@ export const generateBookshelfItems = (
|
||||
|
||||
interface BookshelfItemProps {
|
||||
mode: LibraryViewModeType;
|
||||
item: BookshelfItem;
|
||||
item: Book | BooksGroup;
|
||||
coverFit: LibraryCoverFitType;
|
||||
isSelectMode: boolean;
|
||||
itemSelected: boolean;
|
||||
|
||||
@@ -101,7 +101,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
className={clsx(
|
||||
'relative flex overflow-hidden',
|
||||
mode === 'grid' && 'bg-base-100 aspect-[28/41] items-center justify-center shadow-md',
|
||||
mode === 'list' && 'h-32 items-center justify-start gap-4 py-2',
|
||||
mode === 'list' && 'items-center justify-start gap-4 py-2',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -115,6 +115,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
className={clsx(
|
||||
mode === 'grid' && 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden',
|
||||
mode === 'list' && 'flex h-28 gap-2 overflow-x-auto overflow-y-hidden',
|
||||
mode === 'list' ? 'library-list-item' : 'library-grid-item',
|
||||
)}
|
||||
style={
|
||||
mode === 'list'
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { getBreadcrumbs } from '../utils/libraryUtils';
|
||||
|
||||
@@ -39,6 +40,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
const [selectedGroup, setSelectedGroup] = useState<BookGroupType | null>(null);
|
||||
const [newGroup, setNewGroup] = useState<BookGroupType | null>(null);
|
||||
|
||||
const divRef = useKeyDownActions({ onCancel, onConfirm });
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
const iconSize = useResponsiveSize(16);
|
||||
|
||||
@@ -193,6 +195,7 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
return (
|
||||
<div className='fixed inset-0 flex items-center justify-center'>
|
||||
<div
|
||||
ref={divRef}
|
||||
className={clsx(
|
||||
'modal-box bg-base-100 overflow-y-auto rounded-2xl shadow-xl',
|
||||
'max-h-[85%] w-[95%] min-w-64 max-w-[440px] p-6 sm:w-[70%]',
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import clsx from 'clsx';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { IoFileTray } from 'react-icons/io5';
|
||||
import { MdRssFeed } from 'react-icons/md';
|
||||
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Menu from '@/components/Menu';
|
||||
|
||||
interface ImportMenuProps {
|
||||
setIsDropdownOpen?: (open: boolean) => void;
|
||||
onImportBooks: () => void;
|
||||
onOpenCatalogManager: () => void;
|
||||
}
|
||||
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBooks }) => {
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({
|
||||
setIsDropdownOpen,
|
||||
onImportBooks,
|
||||
onOpenCatalogManager,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
|
||||
@@ -18,14 +26,29 @@ const ImportMenu: React.FC<ImportMenuProps> = ({ setIsDropdownOpen, onImportBook
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
const handleOpenCatalogManager = () => {
|
||||
onOpenCatalogManager();
|
||||
setIsDropdownOpen?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={clsx(
|
||||
'dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow',
|
||||
appService?.isMobile ? 'no-triangle' : 'dropdown-center',
|
||||
)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
<MenuItem label={_('From Local File')} onClick={handleImportBooks} />
|
||||
<MenuItem
|
||||
label={_('From Local File')}
|
||||
Icon={<IoFileTray className='h-5 w-5' />}
|
||||
onClick={handleImportBooks}
|
||||
/>
|
||||
<MenuItem
|
||||
label={_('Online Library')}
|
||||
Icon={<MdRssFeed className='h-5 w-5' />}
|
||||
onClick={handleOpenCatalogManager}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
@@ -13,8 +13,8 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
@@ -27,6 +27,7 @@ interface LibraryHeaderProps {
|
||||
isSelectMode: boolean;
|
||||
isSelectAll: boolean;
|
||||
onImportBooks: () => void;
|
||||
onOpenCatalogManager: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
@@ -36,6 +37,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
isSelectMode,
|
||||
isSelectAll,
|
||||
onImportBooks,
|
||||
onOpenCatalogManager,
|
||||
onToggleSelectMode,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
@@ -47,13 +49,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
const { settings } = useSettingsStore();
|
||||
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
const { currentBookshelf } = useLibraryStore();
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
setTrafficLightVisibility,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
|
||||
|
||||
const viewSettings = settings.globalViewSettings;
|
||||
@@ -85,18 +81,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
debouncedUpdateQueryParam(newQuery);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
setTrafficLightVisibility(true);
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService?.hasTrafficLight]);
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
const currentBooksCount = currentBookshelf.reduce(
|
||||
(acc, item) => acc + ('books' in item ? item.books.length : 1),
|
||||
@@ -105,6 +89,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
|
||||
if (!insets) return null;
|
||||
|
||||
const isMobile = appService?.isMobile || window.innerWidth <= 640;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={headerRef}
|
||||
@@ -138,12 +124,12 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
onChange={handleSearchChange}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input rounded-badge h-9 w-full pl-10 pr-10 sm:h-7',
|
||||
'input rounded-badge h-9 w-full pl-10 pr-[30%] sm:h-7',
|
||||
viewSettings?.isEink
|
||||
? 'border-1 border-base-content focus:border-base-content'
|
||||
: 'bg-base-300/45 border-none',
|
||||
'font-sans text-sm font-light',
|
||||
'placeholder:text-base-content/50',
|
||||
'placeholder:text-base-content/50 truncate',
|
||||
'focus:outline-none focus:ring-0',
|
||||
)}
|
||||
/>
|
||||
@@ -167,14 +153,17 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
label={_('Import Books')}
|
||||
className={clsx(
|
||||
'exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center',
|
||||
appService?.isMobile ? 'dropdown-end' : 'dropdown-center',
|
||||
isMobile ? 'dropdown-end' : 'dropdown-center',
|
||||
)}
|
||||
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center'
|
||||
buttonClassName='p-0 h-6 min-h-6 w-6 flex items-center justify-center !bg-transparent'
|
||||
toggleButton={<PiPlus role='none' className='m-0.5 h-5 w-5' />}
|
||||
>
|
||||
<ImportMenu onImportBooks={onImportBooks} />
|
||||
<ImportMenu
|
||||
onImportBooks={onImportBooks}
|
||||
onOpenCatalogManager={onOpenCatalogManager}
|
||||
/>
|
||||
</Dropdown>
|
||||
{appService?.isMobile ? null : (
|
||||
{isMobile ? null : (
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
aria-label={_('Select Books')}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { CatalogManager } from '@/app/opds/CatelogManager';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
interface CatalogDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CatalogDialog({ onClose }: CatalogDialogProps) {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={_('Online Library')}
|
||||
onClose={onClose}
|
||||
bgClassName={'sm:!bg-black/75'}
|
||||
boxClassName='sm:min-w-[520px] sm:w-3/4 sm:h-[85%] sm:!max-w-screen-sm'
|
||||
>
|
||||
<div className={clsx('bg-base-100 relative flex flex-col overflow-y-auto')}>
|
||||
<CatalogManager />
|
||||
</div>
|
||||
<form method='dialog' className='modal-backdrop'>
|
||||
<button onClick={onClose}>{_('Close')}</button>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import clsx from 'clsx';
|
||||
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
|
||||
interface SelectModeActionsProps {
|
||||
selectedBooks: string[];
|
||||
safeAreaBottom: number;
|
||||
onOpen: () => void;
|
||||
onGroup: () => void;
|
||||
onDetails: () => void;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
selectedBooks,
|
||||
safeAreaBottom,
|
||||
onOpen,
|
||||
onGroup,
|
||||
onDetails,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
const hasSelection = selectedBooks.length > 0;
|
||||
const hasValidBooks = selectedBooks.every((id) => isMd5(id));
|
||||
const hasSingleSelection = selectedBooks.length === 1;
|
||||
const divRef = useKeyDownActions({ onCancel });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
className='fixed bottom-0 left-0 right-0 z-40'
|
||||
style={{
|
||||
paddingBottom: `${safeAreaBottom + 16}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-center shadow-lg',
|
||||
'bg-base-300 text-base-content text-xs',
|
||||
'mx-auto w-fit space-x-6 rounded-lg p-4',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
(!hasSelection || !hasValidBooks) && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdOpenInNew />
|
||||
<div>{_('Open')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onGroup}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!hasSelection && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<LuFolderPlus />
|
||||
<div>{_('Group')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDetails}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdInfoOutline />
|
||||
<div>{_('Details')}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
!hasSelection && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<MdDelete className='text-red-500' />
|
||||
<div className='text-red-500'>{_('Delete')}</div>
|
||||
</button>
|
||||
<button onClick={onCancel} className='flex flex-col items-center justify-center gap-1'>
|
||||
<MdOutlineCancel />
|
||||
<div>{_('Cancel')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectModeActions;
|
||||
@@ -21,6 +21,7 @@ import { optInTelemetry, optOutTelemetry } from '@/utils/telemetry';
|
||||
import { setAboutDialogVisible } from '@/components/AboutWindow';
|
||||
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { selectDirectory } from '@/utils/bridge';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Quota from '@/components/Quota';
|
||||
@@ -32,6 +33,7 @@ interface SettingsMenuProps {
|
||||
|
||||
interface Permissions {
|
||||
postNotification: PermissionState;
|
||||
manageStorage: PermissionState;
|
||||
}
|
||||
|
||||
const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
@@ -172,8 +174,22 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
setSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSetSavedBookCoverForLockScreen = () => {
|
||||
const handleSetSavedBookCoverForLockScreen = async () => {
|
||||
let permission = await invoke<Permissions>('plugin:native-bridge|checkPermissions');
|
||||
if (permission.manageStorage !== 'granted') {
|
||||
permission = await invoke<Permissions>(
|
||||
'plugin:native-bridge|request_manage_storage_permission',
|
||||
);
|
||||
}
|
||||
if (permission.manageStorage !== 'granted' && appService?.distChannel === 'readest') return;
|
||||
|
||||
const newValue = settings.savedBookCoverForLockScreen ? '' : 'default';
|
||||
if (newValue) {
|
||||
const response = await selectDirectory();
|
||||
if (response.path) {
|
||||
saveSysSettings(envConfig, 'savedBookCoverForLockScreenPath', response.path);
|
||||
}
|
||||
}
|
||||
saveSysSettings(envConfig, 'savedBookCoverForLockScreen', newValue);
|
||||
setSavedBookCoverForLockScreen(newValue);
|
||||
};
|
||||
@@ -205,12 +221,17 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
? _('Light Mode')
|
||||
: _('Auto Mode');
|
||||
|
||||
const savedBookCoverPath = settings.savedBookCoverForLockScreenPath;
|
||||
const coverDir = savedBookCoverPath ? savedBookCoverPath.split('/').pop() : 'Images';
|
||||
const savedBookCoverDescription = `💾 ${coverDir}/last-book-cover.png`;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={clsx(
|
||||
'settings-menu dropdown-content no-triangle border-base-100',
|
||||
'z-20 mt-2 max-w-[90vw] shadow-2xl',
|
||||
)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
{user ? (
|
||||
<MenuItem
|
||||
@@ -317,7 +338,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
<MenuItem
|
||||
label={_('Save Book Cover')}
|
||||
tooltip={_('Auto-save last book cover')}
|
||||
description={savedBookCoverForLockScreen ? '💾 Images/last-book-cover.png' : ''}
|
||||
description={savedBookCoverForLockScreen ? savedBookCoverDescription : ''}
|
||||
toggled={!!savedBookCoverForLockScreen}
|
||||
onClick={handleSetSavedBookCoverForLockScreen}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { LibraryCoverFitType, LibrarySortByType, LibraryViewModeType } from '@/types/settings';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import NumberInput from '@/components/settings/NumberInput';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Menu from '@/components/Menu';
|
||||
|
||||
@@ -24,6 +25,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const sortBy = settings.librarySortBy;
|
||||
const isAscending = settings.librarySortAscending;
|
||||
const coverFit = settings.libraryCoverFit;
|
||||
const autoColumns = settings.libraryAutoColumns;
|
||||
const columns = settings.libraryColumns;
|
||||
|
||||
const viewOptions = [
|
||||
{ label: _('List'), value: 'list' },
|
||||
@@ -48,8 +51,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
{ label: _('Descending'), value: false },
|
||||
];
|
||||
|
||||
const handleSetViewMode = (value: LibraryViewModeType) => {
|
||||
saveSysSettings(envConfig, 'libraryViewMode', value);
|
||||
const handleSetViewMode = async (value: LibraryViewModeType) => {
|
||||
await saveSysSettings(envConfig, 'libraryViewMode', value);
|
||||
setIsDropdownOpen?.(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
@@ -57,8 +60,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleToggleCropCovers = (value: LibraryCoverFitType) => {
|
||||
saveSysSettings(envConfig, 'libraryCoverFit', value);
|
||||
const handleToggleCropCovers = async (value: LibraryCoverFitType) => {
|
||||
await saveSysSettings(envConfig, 'libraryCoverFit', value);
|
||||
setIsDropdownOpen?.(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
@@ -66,8 +69,18 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSetSortBy = (value: LibrarySortByType) => {
|
||||
saveSysSettings(envConfig, 'librarySortBy', value);
|
||||
const handleToggleAutoColumns = async () => {
|
||||
const newValue = !settings.libraryAutoColumns;
|
||||
await saveSysSettings(envConfig, 'libraryAutoColumns', newValue);
|
||||
};
|
||||
|
||||
const handleSetColumns = async (value: number) => {
|
||||
await saveSysSettings(envConfig, 'libraryColumns', value);
|
||||
await saveSysSettings(envConfig, 'libraryAutoColumns', false);
|
||||
};
|
||||
|
||||
const handleSetSortBy = async (value: LibrarySortByType) => {
|
||||
await saveSysSettings(envConfig, 'librarySortBy', value);
|
||||
setIsDropdownOpen?.(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
@@ -75,8 +88,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSetSortAscending = (value: boolean) => {
|
||||
saveSysSettings(envConfig, 'librarySortAscending', value);
|
||||
const handleSetSortAscending = async (value: boolean) => {
|
||||
await saveSysSettings(envConfig, 'librarySortAscending', value);
|
||||
setIsDropdownOpen?.(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
@@ -85,7 +98,10 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'>
|
||||
<Menu
|
||||
className='view-menu dropdown-content no-triangle border-base-100 z-20 mt-2 shadow-2xl'
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
{viewOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
@@ -96,6 +112,27 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
/>
|
||||
))}
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
<MenuItem label={_('Columns')} buttonClass='h-8' labelClass='text-sm sm:text-xs' disabled />
|
||||
<MenuItem
|
||||
label={_('Auto')}
|
||||
buttonClass='h-10'
|
||||
toggled={autoColumns}
|
||||
disabled={viewMode === 'list'}
|
||||
siblings={
|
||||
<NumberInput
|
||||
className='!h-10 !p-0 !pe-1 !ps-0'
|
||||
inputClassName={`!p-0 text-center text-base sm:text-sm !w-10 !h-6 !pe-0 ${autoColumns ? 'opacity-50' : ''}`}
|
||||
label={''}
|
||||
value={columns}
|
||||
disabled={viewMode === 'list'}
|
||||
onChange={handleSetColumns}
|
||||
min={window.innerWidth < 640 ? 1 : window.innerWidth < 1024 ? 2 : 3}
|
||||
max={window.innerWidth < 640 ? 4 : window.innerWidth < 1024 ? 6 : 12}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleToggleAutoColumns()}
|
||||
/>
|
||||
<hr aria-hidden='true' className='border-base-200 my-1' />
|
||||
<MenuItem
|
||||
label={_('Book Covers')}
|
||||
buttonClass='h-8'
|
||||
|
||||
@@ -129,22 +129,22 @@ export const useBooksSync = () => {
|
||||
if (newBooks.length > 0) {
|
||||
setIsSyncing(true);
|
||||
}
|
||||
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < newBooks.length; i += batchSize) {
|
||||
const batch = newBooks.slice(i, i + batchSize);
|
||||
await appService?.downloadBookCovers(batch);
|
||||
await Promise.all(batch.map(processNewBook));
|
||||
const progress = Math.min((i + batchSize) / newBooks.length, 1);
|
||||
setLibrary([...updatedLibrary]);
|
||||
setSyncProgress(progress);
|
||||
}
|
||||
|
||||
setLibrary(updatedLibrary);
|
||||
appService?.saveLibraryBooks(updatedLibrary);
|
||||
|
||||
if (newBooks.length > 0) {
|
||||
setIsSyncing(false);
|
||||
try {
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < newBooks.length; i += batchSize) {
|
||||
const batch = newBooks.slice(i, i + batchSize);
|
||||
await appService?.downloadBookCovers(batch);
|
||||
await Promise.all(batch.map(processNewBook));
|
||||
const progress = Math.min((i + batchSize) / newBooks.length, 1);
|
||||
setLibrary([...updatedLibrary]);
|
||||
setSyncProgress(progress);
|
||||
}
|
||||
setLibrary(updatedLibrary);
|
||||
appService?.saveLibraryBooks(updatedLibrary);
|
||||
} finally {
|
||||
if (newBooks.length > 0) {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { BookMetadata } from '@/libs/document';
|
||||
import { AboutWindow } from '@/components/AboutWindow';
|
||||
import { BookDetailModal } from '@/components/metadata';
|
||||
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { CatalogDialog } from './components/OPDSDialog';
|
||||
import { MigrateDataWindow } from './components/MigrateDataWindow';
|
||||
import { useDragDropImport } from './hooks/useDragDropImport';
|
||||
import { Toast } from '@/components/Toast';
|
||||
@@ -88,6 +89,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
|
||||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||||
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
|
||||
const [showCatalogManager, setShowCatalogManager] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
@@ -627,7 +629,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
};
|
||||
|
||||
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
|
||||
return <div className={clsx('h-[100vh]', !appService?.isLinuxApp && 'bg-base-200')} />;
|
||||
return <div className={clsx('full-height', !appService?.isLinuxApp && 'bg-base-200')} />;
|
||||
}
|
||||
|
||||
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
|
||||
@@ -637,7 +639,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
ref={pageRef}
|
||||
aria-label='Your Library'
|
||||
className={clsx(
|
||||
'library-page text-base-content flex h-[100vh] select-none flex-col overflow-hidden',
|
||||
'library-page text-base-content full-height flex select-none flex-col overflow-hidden',
|
||||
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
||||
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
||||
)}
|
||||
@@ -652,6 +654,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onImportBooks={handleImportBooks}
|
||||
onOpenCatalogManager={() => setShowCatalogManager(true)}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
@@ -782,6 +785,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<UpdaterWindow />
|
||||
<MigrateDataWindow />
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
|
||||
{showCatalogManager && <CatalogDialog onClose={() => setShowCatalogManager(false)} />}
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
@@ -789,7 +793,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
const LibraryPage = () => {
|
||||
return (
|
||||
<Suspense fallback={<div className='h-[100vh]' />}>
|
||||
<Suspense fallback={<div className='full-height' />}>
|
||||
<LibraryPageWithSearchParams />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoTrash, IoOpenOutline, IoBook, IoEyeOff, IoEye } from 'react-icons/io5';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { OPDSCatalog } from '@/types/opds';
|
||||
import { isLanAddress } from '@/utils/network';
|
||||
import { validateOPDSURL } from './utils/opdsUtils';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
|
||||
const POPULAR_CATALOGS: OPDSCatalog[] = [
|
||||
{
|
||||
id: 'gutenberg',
|
||||
name: 'Project Gutenberg',
|
||||
url: 'https://m.gutenberg.org/ebooks.opds/',
|
||||
description: "World's largest collection of free ebooks",
|
||||
icon: '🏛️',
|
||||
},
|
||||
{
|
||||
id: 'manybooks',
|
||||
name: 'ManyBooks',
|
||||
url: 'https://manybooks.net/opds/index.php',
|
||||
description: 'Over 50,000 free ebooks',
|
||||
icon: '📖',
|
||||
},
|
||||
];
|
||||
|
||||
async function validateOPDSCatalog(
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const result = await validateOPDSURL(url, username, password, isWebAppPlatform());
|
||||
return { valid: result.isValid, error: result.error };
|
||||
}
|
||||
|
||||
export function CatalogManager() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const [catalogs, setCatalogs] = useState<OPDSCatalog[]>(() => settings.opdsCatalogs || []);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [newCatalog, setNewCatalog] = useState({
|
||||
name: '',
|
||||
url: '',
|
||||
description: '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [urlError, setUrlError] = useState('');
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const saveCatalogs = (updatedCatalogs: OPDSCatalog[]) => {
|
||||
setCatalogs(updatedCatalogs);
|
||||
saveSysSettings(envConfig, 'opdsCatalogs', updatedCatalogs);
|
||||
};
|
||||
|
||||
const handleAddCatalog = async () => {
|
||||
if (!newCatalog.name || !newCatalog.url) return;
|
||||
|
||||
if (
|
||||
process.env['NODE_ENV'] === 'production' &&
|
||||
isWebAppPlatform() &&
|
||||
isLanAddress(newCatalog.url)
|
||||
) {
|
||||
setUrlError(_('Adding LAN addresses is not supported in the web app version.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
setUrlError('');
|
||||
|
||||
const validation = await validateOPDSCatalog(
|
||||
newCatalog.url,
|
||||
newCatalog.username || undefined,
|
||||
newCatalog.password || undefined,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
setUrlError(validation.error || _('Invalid OPDS catalog. Please check the URL.'));
|
||||
setIsValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const catalog: OPDSCatalog = {
|
||||
id: Date.now().toString(),
|
||||
name: newCatalog.name,
|
||||
url: newCatalog.url,
|
||||
description: newCatalog.description,
|
||||
username: newCatalog.username || undefined,
|
||||
password: newCatalog.password || undefined,
|
||||
};
|
||||
|
||||
saveCatalogs([catalog, ...catalogs]);
|
||||
setNewCatalog({ name: '', url: '', description: '', username: '', password: '' });
|
||||
setUrlError('');
|
||||
setIsValidating(false);
|
||||
setShowAddDialog(false);
|
||||
};
|
||||
|
||||
const handleAddPopularCatalog = (popularCatalog: OPDSCatalog) => {
|
||||
if (catalogs.some((c) => c.url === popularCatalog.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveCatalogs([...catalogs, { ...popularCatalog }]);
|
||||
};
|
||||
|
||||
const handleRemoveCatalog = (id: string) => {
|
||||
saveCatalogs(catalogs.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const handleOpenCatalog = (catalog: OPDSCatalog) => {
|
||||
const params = new URLSearchParams({ url: catalog.url });
|
||||
if (catalog.username) params.set('username', catalog.username);
|
||||
if (catalog.password) params.set('password', catalog.password);
|
||||
router.push(`/opds?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setShowAddDialog(false);
|
||||
setNewCatalog({ name: '', url: '', description: '', username: '', password: '' });
|
||||
setUrlError('');
|
||||
setShowPassword(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='container max-w-2xl'>
|
||||
<div className='mb-8'>
|
||||
<h1 className='mb-2 text-base font-bold'>{_('OPDS Catalogs')}</h1>
|
||||
<p className='text-base-content/70 text-xs'>
|
||||
{_('Browse and download books from online catalogs')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* My Catalogs */}
|
||||
<section className='mb-12 text-base'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='font-semibold'>{_('My Catalogs')}</h2>
|
||||
<button onClick={() => setShowAddDialog(true)} className='btn btn-primary btn-sm'>
|
||||
<IoAdd className='h-4 w-4' />
|
||||
{_('Add Catalog')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{catalogs.length === 0 ? (
|
||||
<div className='border-base-300 rounded-lg border-2 border-dashed p-12 text-center'>
|
||||
<IoBook className='text-base-content/30 mx-auto mb-4 h-12 w-12' />
|
||||
<h3 className='mb-2 font-semibold'>{_('No catalogs yet')}</h3>
|
||||
<p className='text-base-content/70 mb-4 text-sm'>
|
||||
{_('Add your first OPDS catalog to start browsing books')}
|
||||
</p>
|
||||
<button onClick={() => setShowAddDialog(true)} className='btn btn-primary btn-sm'>
|
||||
{_('Add Your First Catalog')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
{catalogs.map((catalog) => (
|
||||
<div
|
||||
key={catalog.id}
|
||||
className='card bg-base-100 border-base-300 h-full border shadow-sm transition-shadow hover:shadow-md'
|
||||
>
|
||||
<div className='card-body h-full justify-between p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<h3 className='card-title truncate text-sm'>
|
||||
{catalog.icon && <span className=''>{catalog.icon}</span>}
|
||||
{catalog.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleRemoveCatalog(catalog.id)}
|
||||
className='btn btn-ghost btn-xs btn-square'
|
||||
title='Remove'
|
||||
>
|
||||
<IoTrash className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
{catalog.description && (
|
||||
<p className='text-base-content/70 mb-2 line-clamp-1 h-6 text-sm sm:line-clamp-2 sm:h-10'>
|
||||
{catalog.description}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-base-content/50 truncate text-xs'>{catalog.url}</p>
|
||||
{catalog.username && (
|
||||
<p className='text-base-content/50 mt-1 text-xs'>
|
||||
{_('Username')}: {catalog.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='card-actions mt-4 justify-end'>
|
||||
<button
|
||||
onClick={() => handleOpenCatalog(catalog)}
|
||||
className='btn btn-sm btn-primary'
|
||||
>
|
||||
<IoOpenOutline className='h-4 w-4' />
|
||||
{_('Browse')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Popular Catalogs */}
|
||||
<section className='text-base'>
|
||||
<h2 className='mb-4 font-semibold'>{_('Popular Catalogs')}</h2>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
{POPULAR_CATALOGS.map((catalog) => {
|
||||
const isAdded = catalogs.some((c) => c.url === catalog.url);
|
||||
return (
|
||||
<div
|
||||
key={catalog.id}
|
||||
className='card bg-base-100 border-base-300 border shadow-sm transition-shadow hover:shadow-md'
|
||||
>
|
||||
<div className='card-body p-4'>
|
||||
<h3 className='card-title mb-1 text-sm'>
|
||||
{catalog.icon && <span className=''>{catalog.icon}</span>}
|
||||
{catalog.name}
|
||||
</h3>
|
||||
{catalog.description && (
|
||||
<p className='text-base-content/70 line-clamp-2 text-sm'>
|
||||
{catalog.description}
|
||||
</p>
|
||||
)}
|
||||
<div className='card-actions mt-4 justify-end gap-2'>
|
||||
{!isAdded && (
|
||||
<button
|
||||
onClick={() => handleAddPopularCatalog(catalog)}
|
||||
className='btn btn-sm'
|
||||
>
|
||||
<IoAdd className='h-4 w-4' />
|
||||
{_('Add')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOpenCatalog(catalog)}
|
||||
className='btn btn-sm btn-primary'
|
||||
>
|
||||
<IoOpenOutline className='h-4 w-4' />
|
||||
{_('Browse')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Add Catalog Dialog */}
|
||||
{showAddDialog && (
|
||||
<ModalPortal>
|
||||
<dialog className='modal modal-open'>
|
||||
<div className='modal-box'>
|
||||
<h3 className='mb-4 text-lg font-bold'>{_('Add OPDS Catalog')}</h3>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddCatalog();
|
||||
}}
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('Catalog Name')} *</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
value={newCatalog.name}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, name: e.target.value })}
|
||||
placeholder={_('My Calibre Library')}
|
||||
className='input input-bordered placeholder:text-sm'
|
||||
disabled={isValidating}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('OPDS URL')} *</span>
|
||||
</div>
|
||||
<input
|
||||
type='url'
|
||||
value={newCatalog.url}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, url: e.target.value })}
|
||||
placeholder='https://example.com/opds'
|
||||
className='input input-bordered placeholder:text-sm'
|
||||
disabled={isValidating}
|
||||
required
|
||||
/>
|
||||
{urlError && (
|
||||
<div className='label'>
|
||||
<span className='label-text-alt text-error'>{urlError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('Username (optional)')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
value={newCatalog.username}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, username: e.target.value })}
|
||||
placeholder={_('Username')}
|
||||
className='input input-bordered placeholder:text-sm'
|
||||
disabled={isValidating}
|
||||
autoComplete='username'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('Password (optional)')}</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newCatalog.password}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, password: e.target.value })}
|
||||
placeholder={_('Password')}
|
||||
className='input input-bordered w-full pr-10 placeholder:text-sm'
|
||||
disabled={isValidating}
|
||||
autoComplete='current-password'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='btn btn-ghost btn-sm btn-square absolute right-1 top-1/2 -translate-y-1/2'
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IoEyeOff className='h-4 w-4' />
|
||||
) : (
|
||||
<IoEye className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='form-control'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{_('Description (optional)')}</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={newCatalog.description}
|
||||
onChange={(e) => setNewCatalog({ ...newCatalog, description: e.target.value })}
|
||||
placeholder={_('A brief description of this catalog')}
|
||||
className='textarea textarea-bordered text-sm placeholder:text-sm'
|
||||
rows={2}
|
||||
disabled={isValidating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='modal-action'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleCloseDialog}
|
||||
className='btn'
|
||||
disabled={isValidating}
|
||||
>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button type='submit' className='btn btn-primary' disabled={isValidating}>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<span className='loading loading-spinner loading-sm'></span>
|
||||
{_('Validating...')}
|
||||
</>
|
||||
) : (
|
||||
_('Add Catalog')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method='dialog' className='modal-backdrop'>
|
||||
<button onClick={() => setShowAddDialog(false)}>{_('Close')}</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</ModalPortal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { OPDSFeed, OPDSLink } from '@/types/opds';
|
||||
import { PublicationCard } from './PublicationCard';
|
||||
import { NavigationCard } from './NavigationCard';
|
||||
import { groupByArray } from './utils/opdsUtils';
|
||||
|
||||
interface FeedViewProps {
|
||||
feed: OPDSFeed;
|
||||
baseURL: string;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
onNavigate: (url: string) => void;
|
||||
onPublicationSelect: (groupIndex: number, itemIndex: number) => void;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
isOPDSCatalog: (type?: string) => boolean;
|
||||
}
|
||||
|
||||
const gridClassName = 'grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
|
||||
const navigationClassName =
|
||||
'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 max-[450px]:grid-cols-1';
|
||||
|
||||
export function FeedView({
|
||||
feed,
|
||||
baseURL,
|
||||
resolveURL,
|
||||
onNavigate,
|
||||
onPublicationSelect,
|
||||
onGenerateCachedImageUrl,
|
||||
}: FeedViewProps) {
|
||||
const _ = useTranslation();
|
||||
const linksByRel = useMemo(() => groupByArray(feed.links, (link) => link.rel), [feed.links]);
|
||||
|
||||
const pagination = useMemo(() => {
|
||||
return ['first', 'previous', 'next', 'last'].map((rel) => linksByRel.get(rel));
|
||||
}, [linksByRel]);
|
||||
|
||||
const hasFacets = feed.facets && feed.facets.length > 0;
|
||||
|
||||
const handlePaginationClick = (links?: OPDSLink[]) => {
|
||||
if (links && links.length > 0) {
|
||||
const url = resolveURL(links[0]?.href || '', baseURL);
|
||||
onNavigate(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigationClick = (href: string) => {
|
||||
const url = resolveURL(href, baseURL);
|
||||
onNavigate(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='container mx-auto max-w-7xl px-4 py-6'>
|
||||
{/* Header */}
|
||||
<div className='opds-header mb-6'>
|
||||
{feed.metadata?.title && <h1 className='mb-2 text-xl font-bold'>{feed.metadata.title}</h1>}
|
||||
{feed.metadata?.subtitle && (
|
||||
<p className='text-base-content/70 text-sm'>{feed.metadata.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-6'>
|
||||
{/* Facets Sidebar */}
|
||||
{hasFacets && (
|
||||
<aside className='hidden w-64 flex-shrink-0 lg:block'>
|
||||
<div className='sticky top-6'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<IoFilter className='h-5 w-5' />
|
||||
<h2 className='text-lg font-semibold'>Filters</h2>
|
||||
</div>
|
||||
<div className='space-y-6'>
|
||||
{feed.facets?.map((facet, index: number) => (
|
||||
<section key={index}>
|
||||
{facet.metadata?.title && (
|
||||
<h3 className='text-base-content/70 mb-2 text-sm font-medium'>
|
||||
{facet.metadata.title}
|
||||
</h3>
|
||||
)}
|
||||
<ul className='space-y-1'>
|
||||
{facet.links.map((link, linkIndex: number) => {
|
||||
const isActive = link.rel === 'self' || link.rel?.includes('self');
|
||||
const href = resolveURL(link.href, baseURL);
|
||||
return (
|
||||
<li key={linkIndex}>
|
||||
<button
|
||||
onClick={() => handleNavigationClick(href)}
|
||||
className={`w-full rounded px-3 py-1.5 text-left text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-content font-medium'
|
||||
: 'hover:bg-base-200'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='truncate'>{link.title || 'Untitled'}</span>
|
||||
{link.properties?.numberOfItems && (
|
||||
<span className='ml-2 text-xs opacity-70'>
|
||||
{link.properties.numberOfItems}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
{/* Navigation Items */}
|
||||
{feed.navigation && feed.navigation.length > 0 && (
|
||||
<section className='opds-navigation mb-8'>
|
||||
<div className={navigationClassName}>
|
||||
{feed.navigation.map((item, index: number) => (
|
||||
<NavigationCard
|
||||
key={index}
|
||||
item={item}
|
||||
baseURL={baseURL}
|
||||
onClick={handleNavigationClick}
|
||||
resolveURL={resolveURL}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Publications */}
|
||||
{feed.publications && feed.publications.length > 0 && (
|
||||
<section className='opds-publications mb-8'>
|
||||
<div className={gridClassName}>
|
||||
{feed.publications.map((pub, index: number) => (
|
||||
<PublicationCard
|
||||
key={index}
|
||||
publication={pub}
|
||||
baseURL={baseURL}
|
||||
onClick={() => onPublicationSelect(-1, index)}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Groups */}
|
||||
{feed.groups?.map((group, groupIndex: number) => (
|
||||
<section key={groupIndex} className='mb-12'>
|
||||
{group.metadata && (
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-2xl font-bold'>{group.metadata.title}</h2>
|
||||
{group.links && group.links.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const link = group.links[0];
|
||||
const url = resolveURL(link?.href || '', baseURL);
|
||||
handleNavigationClick(url);
|
||||
}}
|
||||
className='btn btn-sm btn-ghost'
|
||||
>
|
||||
{_('View All')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.navigation && (
|
||||
<div className={`opds-navigation ${gridClassName}`}>
|
||||
{group.navigation.map((item, itemIndex: number) => (
|
||||
<NavigationCard
|
||||
key={itemIndex}
|
||||
item={item}
|
||||
baseURL={baseURL}
|
||||
onClick={handleNavigationClick}
|
||||
resolveURL={resolveURL}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.publications && (
|
||||
<div className={`opds-publications ${gridClassName}`}>
|
||||
{group.publications.map((pub, itemIndex: number) => (
|
||||
<PublicationCard
|
||||
key={itemIndex}
|
||||
publication={pub}
|
||||
baseURL={baseURL}
|
||||
onClick={() => onPublicationSelect(groupIndex, itemIndex)}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.some((links) => links && links.length > 0) && (
|
||||
<nav className='mt-8 flex justify-center gap-2'>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[0])}
|
||||
disabled={!pagination[0]}
|
||||
className='btn btn-sm'
|
||||
title={_('First')}
|
||||
>
|
||||
<IoChevronBack className='h-4 w-4' />
|
||||
<IoChevronBack className='-ml-3 h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[1])}
|
||||
disabled={!pagination[1]}
|
||||
className='btn btn-sm'
|
||||
title={_('Previous')}
|
||||
>
|
||||
<IoChevronBack className='h-4 w-4' />
|
||||
{_('Previous')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[2])}
|
||||
disabled={!pagination[2]}
|
||||
className='btn btn-sm'
|
||||
title={_('Next')}
|
||||
>
|
||||
{_('Next')}
|
||||
<IoChevronForward className='h-4 w-4' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePaginationClick(pagination[3])}
|
||||
disabled={!pagination[3]}
|
||||
className='btn btn-sm'
|
||||
title={_('Last')}
|
||||
>
|
||||
<IoChevronForward className='h-4 w-4' />
|
||||
<IoChevronForward className='-ml-3 h-4 w-4' />
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { GiBookshelf } from 'react-icons/gi';
|
||||
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
|
||||
interface NavigationProps {
|
||||
currentURL: string;
|
||||
startURL?: string;
|
||||
onNavigate: (url: string) => void;
|
||||
onBack?: () => void;
|
||||
onForward?: () => void;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
}
|
||||
|
||||
export function Navigation({
|
||||
startURL,
|
||||
onNavigate,
|
||||
onBack,
|
||||
onForward,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
}: NavigationProps) {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
if (startURL) {
|
||||
onNavigate(startURL);
|
||||
}
|
||||
}, [startURL, onNavigate]);
|
||||
|
||||
const handleGoLibrary = useCallback(() => {
|
||||
navigateToLibrary(router, '', {}, true);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={clsx(
|
||||
'navbar min-h-0 px-2',
|
||||
'flex h-[48px] w-full items-center',
|
||||
appService?.isMobile ? '' : 'border-base-300 bg-base-200 border-b',
|
||||
)}
|
||||
>
|
||||
<div className={clsx('navbar-start gap-1', isTrafficLightVisible && '!pl-16')}>
|
||||
{onBack && (
|
||||
<button
|
||||
className='btn btn-ghost btn-sm px-3 disabled:bg-transparent'
|
||||
onClick={onBack}
|
||||
disabled={!canGoBack}
|
||||
title={_('Back')}
|
||||
>
|
||||
<IoChevronBack className='h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
{onForward && (
|
||||
<button
|
||||
className='btn btn-ghost btn-sm disabled:bg-transparent'
|
||||
onClick={onForward}
|
||||
disabled={!canGoForward}
|
||||
title={_('Forward')}
|
||||
>
|
||||
<IoChevronForward className='h-6 w-6' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='navbar-center'>
|
||||
<h1 className='max-w-md truncate text-base font-semibold'>{_('OPDS Catalog')}</h1>
|
||||
</div>
|
||||
|
||||
<div className='navbar-end gap-2'>
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleGoHome} title={_('Home')}>
|
||||
<IoHome className='h-5 w-5' />
|
||||
</button>
|
||||
<button className='btn btn-ghost btn-sm' onClick={handleGoLibrary} title={_('Library')}>
|
||||
<GiBookshelf className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { IoChevronForward } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { OPDSNavigationItem, SYMBOL } from '@/types/opds';
|
||||
|
||||
interface NavigationCardProps {
|
||||
item: OPDSNavigationItem;
|
||||
baseURL: string;
|
||||
onClick: (href: string) => void;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
}
|
||||
|
||||
export function NavigationCard({ item, baseURL, onClick, resolveURL }: NavigationCardProps) {
|
||||
const _ = useTranslation();
|
||||
const href = resolveURL(item.href || '', baseURL);
|
||||
const summary = item[SYMBOL.SUMMARY];
|
||||
|
||||
return (
|
||||
<div
|
||||
role='none'
|
||||
onClick={() => onClick(href)}
|
||||
className='card bg-base-100 border-base-300 hover:bg-base-200/30 cursor-pointer rounded-lg border shadow-sm transition-shadow'
|
||||
>
|
||||
<div className='card-body flex justify-center p-4'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3 className='line-clamp-1 text-sm font-semibold'>{item.title || _('Untitled')}</h3>
|
||||
{item.properties?.numberOfItems && (
|
||||
<p className='text-base-content/60 text-xs'>
|
||||
{_('{{count}} items', { count: item.properties.numberOfItems })}
|
||||
</p>
|
||||
)}
|
||||
{summary && <p className='text-base-content/70 mt-1 line-clamp-1 text-xs'>{summary}</p>}
|
||||
</div>
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<IoChevronForward className='text-base-content/40 h-5 w-5 flex-shrink-0' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { groupByArray } from './utils/opdsUtils';
|
||||
import { CachedImage } from '@/components/CachedImage';
|
||||
import { OPDSPublication, REL } from '@/types/opds';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
interface PublicationCardProps {
|
||||
publication: OPDSPublication;
|
||||
baseURL: string;
|
||||
onClick: () => void;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export function PublicationCard({
|
||||
publication,
|
||||
baseURL,
|
||||
onClick,
|
||||
resolveURL,
|
||||
onGenerateCachedImageUrl,
|
||||
}: PublicationCardProps) {
|
||||
const _ = useTranslation();
|
||||
const linksByRel = useMemo(
|
||||
() => groupByArray(publication.links, (link) => link.rel),
|
||||
[publication.links],
|
||||
);
|
||||
|
||||
const thumbnailImage = useMemo(() => {
|
||||
const thumbnails = publication.images?.filter((img) =>
|
||||
REL.THUMBNAIL.some((rel: string) => img.rel?.includes(rel)),
|
||||
);
|
||||
return thumbnails?.[0] || publication.images?.[0];
|
||||
}, [publication.images]);
|
||||
|
||||
const coverImage = useMemo(() => {
|
||||
const covers = publication.images?.filter((img) =>
|
||||
REL.COVER.some((rel: string) => img.rel?.includes(rel)),
|
||||
);
|
||||
return covers?.[0];
|
||||
}, [publication.images]);
|
||||
|
||||
const imageLink = coverImage || thumbnailImage;
|
||||
const imageUrl = imageLink ? resolveURL(imageLink.href, baseURL) : null;
|
||||
|
||||
const authors = useMemo(() => {
|
||||
return publication.metadata?.author
|
||||
?.map((author) => (typeof author === 'string' ? author : author.name))
|
||||
.join(', ');
|
||||
}, [publication.metadata?.author]);
|
||||
|
||||
const price = useMemo(() => {
|
||||
const priceLink = publication.links?.find((link) => link.properties?.price);
|
||||
if (priceLink?.properties?.price) {
|
||||
const { currency, value } = priceLink.properties.price;
|
||||
return `${currency} ${value}`;
|
||||
}
|
||||
if (linksByRel.has(REL.ACQ + '/open-access')) {
|
||||
return _('Open Access');
|
||||
}
|
||||
return null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [publication.links, linksByRel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='none'
|
||||
onClick={onClick}
|
||||
className='card bg-base-100 cursor-pointer transition-shadow'
|
||||
>
|
||||
<figure className='bg-base-200 relative aspect-[28/41] rounded-none shadow-md'>
|
||||
<CachedImage
|
||||
src={imageUrl}
|
||||
alt={publication.metadata?.title || 'Book cover'}
|
||||
fill
|
||||
className='object-cover'
|
||||
sizes='(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw'
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
</figure>
|
||||
<div className='py-3'>
|
||||
<h3 className='card-title line-clamp-1 text-sm'>
|
||||
{publication.metadata?.title || 'Untitled'}
|
||||
</h3>
|
||||
{authors && <p className='text-base-content/70 line-clamp-1 text-xs'>{authors}</p>}
|
||||
{price && (
|
||||
<div className='card-actions mt-2 justify-end'>
|
||||
<div className='badge badge-outline badge-sm'>{price}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IoPricetag } from 'react-icons/io5';
|
||||
import { Book } from '@/types/book';
|
||||
import { groupByArray } from './utils/opdsUtils';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { formatDate, formatLanguage } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { CachedImage } from '@/components/CachedImage';
|
||||
import { OPDSLink, OPDSPublication, REL, SYMBOL } from '@/types/opds';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
|
||||
interface PublicationViewProps {
|
||||
publication: OPDSPublication;
|
||||
baseURL: string;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
onDownload: (
|
||||
href: string,
|
||||
type?: string,
|
||||
onProgress?: (progress: { progress: number; total: number }) => void,
|
||||
) => Promise<Book | null | undefined>;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export function PublicationView({
|
||||
publication,
|
||||
baseURL,
|
||||
resolveURL,
|
||||
onDownload,
|
||||
onGenerateCachedImageUrl,
|
||||
}: PublicationViewProps) {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
const [downloadedBook, setDownloadedBook] = useState<Book | null>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
|
||||
const linksByRel = useMemo(
|
||||
() => groupByArray(publication.links, (link) => link.rel),
|
||||
[publication.links],
|
||||
);
|
||||
|
||||
const coverImage = useMemo(() => {
|
||||
const covers = publication.images?.filter((img) =>
|
||||
REL.COVER.some((rel: string) => img.rel?.includes(rel)),
|
||||
);
|
||||
return covers?.[0] || publication.images?.[0];
|
||||
}, [publication.images]);
|
||||
|
||||
const imageUrl = coverImage ? resolveURL(coverImage.href, baseURL) : null;
|
||||
|
||||
const authors = useMemo(() => {
|
||||
return publication.metadata?.author?.map((author) =>
|
||||
typeof author === 'string' ? author : author.name,
|
||||
);
|
||||
}, [publication.metadata?.author]);
|
||||
|
||||
const acquisitionLinks = useMemo(() => {
|
||||
const links: Array<{ rel: string; links: OPDSLink[] }> = [];
|
||||
for (const [rel, linkList] of Array.from(linksByRel.entries())) {
|
||||
if (rel?.startsWith(REL.ACQ)) {
|
||||
links.push({ rel, links: linkList });
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}, [linksByRel]);
|
||||
|
||||
const handleActionButton = async (href: string, type?: string) => {
|
||||
if (downloadedBook) {
|
||||
navigateToReader(router, [downloadedBook.hash]);
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloading(true);
|
||||
setDownloaded(false);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
const book = await onDownload(href, type, (prog) => {
|
||||
if (prog.total > 0) {
|
||||
const percentage = Math.floor((prog.progress / prog.total) * 100);
|
||||
setProgress(percentage);
|
||||
}
|
||||
});
|
||||
if (book) {
|
||||
setDownloadedBook(book);
|
||||
}
|
||||
eventDispatcher.dispatch('toast', { type: 'success', message: _('Download completed') });
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Download failed') + `:\n${href}`,
|
||||
});
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getAcquisitionLabel = (rel: string): string => {
|
||||
if (rel === REL.ACQ + '/open-access') return _('Open Access');
|
||||
if (rel === REL.ACQ + '/borrow') return _('Borrow');
|
||||
if (rel === REL.ACQ + '/buy') return _('Buy');
|
||||
if (rel === REL.ACQ + '/subscribe') return _('Subscribe');
|
||||
if (rel === REL.ACQ + '/sample') return _('Sample');
|
||||
return _('Download');
|
||||
};
|
||||
|
||||
const content = publication.metadata?.[SYMBOL.CONTENT];
|
||||
const description = publication.metadata?.description;
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col px-6 py-6'>
|
||||
<div className='mb-6 flex w-full flex-row items-start gap-6 max-[320px]:flex-col'>
|
||||
<div className='h-44 flex-shrink-0 sm:h-56 md:h-64'>
|
||||
<div className='bg-base-200 relative aspect-[28/41] h-full overflow-hidden rounded-none shadow-lg'>
|
||||
<CachedImage
|
||||
src={imageUrl}
|
||||
alt={publication.metadata?.title || 'Book cover'}
|
||||
fill
|
||||
className='object-cover'
|
||||
sizes='(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw'
|
||||
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex h-44 min-w-0 flex-col justify-between max-[320px]:h-32 sm:h-56 md:h-64'>
|
||||
<div className='flex flex-col'>
|
||||
{publication.metadata?.subtitle && (
|
||||
<p className='text-base-content/60 mb-1 text-sm'>{publication.metadata.subtitle}</p>
|
||||
)}
|
||||
<h1 className='mb-2 text-base font-bold'>
|
||||
{publication.metadata?.title || 'Untitled'}
|
||||
</h1>
|
||||
{authors && authors.length > 0 && (
|
||||
<p className='text-base-content/70 text-sm'>{authors.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{acquisitionLinks.length > 0 && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{acquisitionLinks.map(({ rel, links }) => (
|
||||
<div key={rel} className='flex gap-1'>
|
||||
{links.length === 1 ? (
|
||||
<button
|
||||
onClick={() => handleActionButton(links[0]!.href, links[0]!.type)}
|
||||
disabled={downloading}
|
||||
className={clsx(
|
||||
'btn btn-primary min-w-20 rounded-3xl',
|
||||
downloaded && 'btn-success',
|
||||
)}
|
||||
>
|
||||
{downloadedBook ? _('Open & Read') : getAcquisitionLabel(rel)}
|
||||
</button>
|
||||
) : (
|
||||
<Dropdown
|
||||
label={_('Download')}
|
||||
className='dropdown-bottom flex justify-center'
|
||||
buttonClassName='btn btn-ghost p-0 hover:bg-transparent'
|
||||
disabled={downloading}
|
||||
toggleButton={
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={clsx(
|
||||
`btn btn-primary min-w-20 rounded-3xl ${downloading ? 'btn-disabled' : ''}`,
|
||||
downloaded && 'btn-success',
|
||||
)}
|
||||
>
|
||||
{downloadedBook ? _('Open') : getAcquisitionLabel(rel)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'delete-menu dropdown-content dropdown-center no-triangle',
|
||||
'border-base-300 !bg-base-200 z-20 mt-2 max-w-[80vw] shadow-2xl',
|
||||
)}
|
||||
>
|
||||
{links.map((link, idx: number) => (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
noIcon
|
||||
transient
|
||||
label={link.title || link.type || ''}
|
||||
onClick={() => handleActionButton(link.href, link.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className='flex h-12 w-12 items-center justify-center'>
|
||||
{downloading && progress && progress > 0 && (
|
||||
<div
|
||||
className='radial-progress flex items-center justify-center'
|
||||
style={
|
||||
{
|
||||
'--value': progress,
|
||||
'--size': '2.5rem',
|
||||
fontSize: '0.6rem',
|
||||
lineHeight: '0.8rem',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
aria-valuenow={progress || 0}
|
||||
role='progressbar'
|
||||
>
|
||||
{progress}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='max-w-xl items-start space-y-6'>
|
||||
{/* Description */}
|
||||
{(content || description) && (
|
||||
<div className='prose prose-sm max-w-none'>
|
||||
{content ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
content.type === 'html' || content.type === 'xhtml'
|
||||
? content.value
|
||||
: content.value,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata Table */}
|
||||
<div>
|
||||
<style>
|
||||
{`
|
||||
.table :where(th, td) {
|
||||
padding: 10px 0px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<table className='table text-sm'>
|
||||
<tbody>
|
||||
{publication.metadata?.publisher && (
|
||||
<tr>
|
||||
<th className='w-32'>{_('Publisher')}</th>
|
||||
<td>
|
||||
{typeof publication.metadata.publisher === 'string'
|
||||
? publication.metadata.publisher
|
||||
: Array.isArray(publication.metadata.publisher)
|
||||
? publication.metadata.publisher
|
||||
.map((p) => (typeof p === 'string' ? p : p.name))
|
||||
.join(', ')
|
||||
: publication.metadata.publisher.name}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{publication.metadata?.published && (
|
||||
<tr>
|
||||
<th>{_('Published')}</th>
|
||||
<td>{formatDate(publication.metadata.published, true)}</td>
|
||||
</tr>
|
||||
)}
|
||||
{publication.metadata?.language && (
|
||||
<tr>
|
||||
<th>{_('Language')}</th>
|
||||
<td>
|
||||
{Array.isArray(publication.metadata.language)
|
||||
? publication.metadata.language
|
||||
.map((lang: string) => formatLanguage(lang))
|
||||
.join(', ')
|
||||
: formatLanguage(publication.metadata.language)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{publication.metadata?.identifier && (
|
||||
<tr>
|
||||
<th>{_('Identifier')}</th>
|
||||
<td>
|
||||
<code className='text-xs'>{publication.metadata.identifier}</code>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Tags/Subjects */}
|
||||
{publication.metadata?.subject && publication.metadata.subject.length > 0 && (
|
||||
<div>
|
||||
<h2 className='mb-3 text-sm font-semibold'>{_('Tags')}</h2>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{publication.metadata.subject.map((subject, index: number) => {
|
||||
const tag =
|
||||
typeof subject === 'string' ? subject : subject.name || subject.code || _('Tag');
|
||||
return (
|
||||
<div key={index} className='badge badge-outline max-w-full gap-1'>
|
||||
<IoPricetag className='h-3 min-h-3 w-3 min-w-3' />
|
||||
<div className='truncate' title={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { IoSearch } from 'react-icons/io5';
|
||||
import { OPDSSearch } from '@/types/opds';
|
||||
|
||||
interface SearchViewProps {
|
||||
search: OPDSSearch;
|
||||
baseURL: string;
|
||||
onNavigate: (url: string) => void;
|
||||
resolveURL: (url: string, base: string) => string;
|
||||
}
|
||||
|
||||
export function SearchView({ search, baseURL, onNavigate, resolveURL }: SearchViewProps) {
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
search.params?.forEach((param) => {
|
||||
initial[param.name] = param.value || '';
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const map = new Map<string | null, Map<string | null, string>>();
|
||||
|
||||
for (const param of search.params || []) {
|
||||
const value = formData[param.name] || '';
|
||||
const ns = param.ns ?? null;
|
||||
|
||||
if (map.has(ns)) {
|
||||
map.get(ns)!.set(param.name, value);
|
||||
} else {
|
||||
map.set(ns, new Map([[param.name, value]]));
|
||||
}
|
||||
}
|
||||
|
||||
const searchURL = search.search(map);
|
||||
const resolvedURL = resolveURL(searchURL, baseURL);
|
||||
onNavigate(resolvedURL);
|
||||
};
|
||||
|
||||
const handleInputChange = (name: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const getParamLabel = (name: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
searchTerms: 'Search',
|
||||
query: 'Query',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
publisher: 'Publisher',
|
||||
language: 'Language',
|
||||
subject: 'Subject',
|
||||
};
|
||||
return labels[name] || name;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='container mx-auto max-w-2xl px-4 py-12'>
|
||||
<div className='mb-8 text-center'>
|
||||
<h1 className='mb-2 text-3xl font-bold'>{search.metadata?.title || 'Search'}</h1>
|
||||
{search.metadata?.description && (
|
||||
<p className='text-base-content/70'>{search.metadata.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||
{(search.params || []).map((param) => (
|
||||
<div key={`${param.ns || 'default'}-${param.name}`} className='form-control'>
|
||||
<label className='label'>
|
||||
<span className='label-text font-medium'>
|
||||
{getParamLabel(param.name)}
|
||||
{param.required && <span className='text-error ml-1'>*</span>}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type={param.name === 'searchTerms' || param.name === 'query' ? 'search' : 'text'}
|
||||
value={formData[param.name] || ''}
|
||||
onChange={(e) => handleInputChange(param.name, e.target.value)}
|
||||
required={param.required}
|
||||
placeholder={`Enter ${getParamLabel(param.name).toLowerCase()}`}
|
||||
className='input input-bordered w-full'
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={
|
||||
param.name === 'searchTerms' ||
|
||||
param.name === 'query' ||
|
||||
search.params[0]?.name === param.name
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='pt-4'>
|
||||
<button type='submit' className='btn btn-primary w-full'>
|
||||
<IoSearch className='h-5 w-5' />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { isOPDSCatalog, getPublication, getFeed, getOpenSearch } from 'foliate-js/opds.js';
|
||||
import { md5 } from 'js-md5';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { isWebAppPlatform } from '@/services/environment';
|
||||
import { FeedView } from './FeedView';
|
||||
import { PublicationView } from './PublicationView';
|
||||
import { SearchView } from './SearchView';
|
||||
import { Navigation } from './Navigation';
|
||||
import { getBaseFilename } from '@/utils/path';
|
||||
import { downloadFile } from '@/libs/storage';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useLibrary } from '@/hooks/useLibrary';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getFileExtFromMimeType } from '@/libs/document';
|
||||
import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
|
||||
import { MIME, parseMediaType, resolveURL } from './utils/opdsUtils';
|
||||
import { getProxiedURL, fetchWithAuth, probeAuth, needsProxy } from './utils/opdsReq';
|
||||
import clsx from 'clsx';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
|
||||
|
||||
interface OPDSState {
|
||||
feed?: OPDSFeed;
|
||||
publication?: OPDSPublication;
|
||||
search?: OPDSSearch;
|
||||
baseURL: string;
|
||||
currentURL: string;
|
||||
startURL?: string;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
url: string;
|
||||
state: OPDSState;
|
||||
viewMode: ViewMode;
|
||||
selectedPublication: { groupIndex: number; itemIndex: number } | null;
|
||||
}
|
||||
|
||||
export default function BrowserPage() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { libraryLoaded } = useLibrary();
|
||||
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('loading');
|
||||
const [state, setState] = useState<OPDSState>({
|
||||
baseURL: '',
|
||||
currentURL: '',
|
||||
});
|
||||
const [selectedPublication, setSelectedPublication] = useState<{
|
||||
groupIndex: number;
|
||||
itemIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const usernameRef = useRef(searchParams?.get('username'));
|
||||
const passwordRef = useRef(searchParams?.get('password'));
|
||||
const loadingOPDSRef = useRef(false);
|
||||
const startURLRef = useRef<string | undefined>(undefined);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const isNavigatingHistoryRef = useRef(false);
|
||||
|
||||
useTheme({ systemUIVisible: false });
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
startURLRef.current = state.startURL;
|
||||
}, [state.startURL]);
|
||||
|
||||
useEffect(() => {
|
||||
historyIndexRef.current = historyIndex;
|
||||
}, [historyIndex]);
|
||||
|
||||
const addToHistory = useCallback(
|
||||
(
|
||||
url: string,
|
||||
newState: OPDSState,
|
||||
viewMode: ViewMode,
|
||||
selectedPub: { groupIndex: number; itemIndex: number } | null = null,
|
||||
) => {
|
||||
const newEntry: HistoryEntry = {
|
||||
url,
|
||||
state: newState,
|
||||
viewMode,
|
||||
selectedPublication: selectedPub,
|
||||
};
|
||||
setHistory((prev) => [...prev.slice(0, historyIndexRef.current + 1), newEntry]);
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadOPDS = useCallback(
|
||||
async (url: string, skipHistory = false) => {
|
||||
if (loadingOPDSRef.current) return;
|
||||
loadingOPDSRef.current = true;
|
||||
|
||||
setViewMode('loading');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const useProxy = isWebAppPlatform();
|
||||
const username = usernameRef.current || '';
|
||||
const password = passwordRef.current || '';
|
||||
const res = await fetchWithAuth(url, username, password, useProxy);
|
||||
|
||||
if (!res.ok) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: `Failed to load OPDS feed: ${res.status} ${res.statusText}`,
|
||||
timeout: 5000,
|
||||
type: 'error',
|
||||
});
|
||||
setTimeout(() => {
|
||||
router.back();
|
||||
}, 5000);
|
||||
throw new Error(`Failed to load OPDS feed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const currentStartURL = startURLRef.current || url;
|
||||
const responseURL = res.url;
|
||||
const text = await res.text();
|
||||
|
||||
if (text.startsWith('<')) {
|
||||
const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType);
|
||||
const {
|
||||
documentElement: { localName },
|
||||
} = doc;
|
||||
|
||||
if (localName === 'feed') {
|
||||
const feed = getFeed(doc) as OPDSFeed;
|
||||
const newState = {
|
||||
feed,
|
||||
baseURL: responseURL,
|
||||
currentURL: url,
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('feed');
|
||||
setSelectedPublication(null);
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'feed', null);
|
||||
}
|
||||
} else if (localName === 'entry') {
|
||||
const publication = getPublication(doc.documentElement);
|
||||
const newState = {
|
||||
publication,
|
||||
baseURL: responseURL,
|
||||
currentURL: url,
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('publication');
|
||||
setSelectedPublication(null);
|
||||
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'publication', null);
|
||||
}
|
||||
} else if (localName === 'OpenSearchDescription') {
|
||||
const search = getOpenSearch(doc);
|
||||
const newState = {
|
||||
search,
|
||||
baseURL: responseURL,
|
||||
currentURL: url,
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('search');
|
||||
setSelectedPublication(null);
|
||||
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'search', null);
|
||||
}
|
||||
} else {
|
||||
const contentType = res.headers.get('Content-Type') ?? MIME.HTML;
|
||||
const type = parseMediaType(contentType)?.mediaType ?? MIME.HTML;
|
||||
const htmlDoc = new DOMParser().parseFromString(text, type as DOMParserSupportedType);
|
||||
|
||||
if (!htmlDoc.head) {
|
||||
router.back();
|
||||
throw new Error(`Failed to load OPDS feed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const link = Array.from(htmlDoc.head.querySelectorAll('link')).find((link) =>
|
||||
isOPDSCatalog(link.getAttribute('type') ?? ''),
|
||||
);
|
||||
|
||||
if (!link) {
|
||||
router.back();
|
||||
throw new Error('Document has no link to OPDS feeds');
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (href) {
|
||||
const resolvedURL = resolveURL(href, responseURL);
|
||||
loadOPDS(resolvedURL);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const feed = JSON.parse(text);
|
||||
const newState = {
|
||||
feed,
|
||||
baseURL: responseURL,
|
||||
currentURL: url,
|
||||
startURL: currentStartURL || responseURL,
|
||||
};
|
||||
setState(newState);
|
||||
setViewMode('feed');
|
||||
setSelectedPublication(null);
|
||||
|
||||
if (!skipHistory) {
|
||||
addToHistory(url, newState, 'feed', null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(e as Error);
|
||||
setViewMode('error');
|
||||
} finally {
|
||||
loadingOPDSRef.current = false;
|
||||
}
|
||||
},
|
||||
[router, addToHistory],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const url = searchParams?.get('url');
|
||||
const username = searchParams?.get('username');
|
||||
const password = searchParams?.get('password');
|
||||
if (url && !isNavigatingHistoryRef.current) {
|
||||
if (username || password) {
|
||||
usernameRef.current = username;
|
||||
passwordRef.current = password;
|
||||
} else {
|
||||
usernameRef.current = null;
|
||||
passwordRef.current = null;
|
||||
}
|
||||
loadOPDS(url);
|
||||
} else if (isNavigatingHistoryRef.current) {
|
||||
isNavigatingHistoryRef.current = false;
|
||||
} else {
|
||||
setViewMode('error');
|
||||
setError(new Error('No OPDS URL provided'));
|
||||
}
|
||||
}, [searchParams, loadOPDS]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(url: string) => {
|
||||
const newURL = new URL(window.location.href);
|
||||
newURL.searchParams.set('url', url);
|
||||
window.history.pushState({}, '', newURL.toString());
|
||||
loadOPDS(url);
|
||||
},
|
||||
[loadOPDS],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (
|
||||
href: string,
|
||||
type?: string,
|
||||
onProgress?: (progress: { progress: number; total: number }) => void,
|
||||
) => {
|
||||
if (!appService || !libraryLoaded) return;
|
||||
try {
|
||||
const url = resolveURL(href, state.baseURL);
|
||||
const parsed = parseMediaType(type);
|
||||
if (parsed?.mediaType === MIME.HTML) {
|
||||
if (isWebAppPlatform()) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
await openUrl(url);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const ext = parsed?.mediaType ? getFileExtFromMimeType(parsed.mediaType) : '';
|
||||
const basename = getBaseFilename(url);
|
||||
const filename = ext ? `${basename}.${ext}` : basename;
|
||||
const dstFilePath = await appService?.resolveFilePath(filename, 'Cache');
|
||||
if (dstFilePath) {
|
||||
const username = usernameRef.current || '';
|
||||
const password = passwordRef.current || '';
|
||||
const useProxy = needsProxy(url);
|
||||
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
|
||||
const headers: Record<string, string> = {};
|
||||
if (username || password) {
|
||||
const authHeader = await probeAuth(url, username, password, useProxy);
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
|
||||
}
|
||||
}
|
||||
|
||||
await downloadFile({
|
||||
appService,
|
||||
dst: dstFilePath,
|
||||
cfp: '',
|
||||
url: downloadUrl,
|
||||
headers,
|
||||
singleThreaded: true,
|
||||
onProgress,
|
||||
});
|
||||
const { library, setLibrary } = useLibraryStore.getState();
|
||||
const book = await appService.importBook(dstFilePath, library);
|
||||
setLibrary(library);
|
||||
appService.saveLibraryBooks(library);
|
||||
return book;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download error:', e);
|
||||
throw e;
|
||||
}
|
||||
return;
|
||||
},
|
||||
[state.baseURL, appService, libraryLoaded],
|
||||
);
|
||||
|
||||
const handleGenerateCachedImageUrl = useCallback(
|
||||
async (url: string) => {
|
||||
if (!appService) return url;
|
||||
const username = usernameRef.current || '';
|
||||
const password = passwordRef.current || '';
|
||||
if (!username && !password) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const cachedKey = `img_${md5(url)}.png`;
|
||||
const cachePrefix = await appService.resolveFilePath('', 'Cache');
|
||||
const cachedPath = `${cachePrefix}/${cachedKey}`;
|
||||
if (await appService.exists(cachedPath, 'None')) {
|
||||
return await appService.getImageURL(cachedPath);
|
||||
} else {
|
||||
const useProxy = needsProxy(url);
|
||||
let downloadUrl = useProxy ? getProxiedURL(url, '', true) : url;
|
||||
const headers: Record<string, string> = {};
|
||||
if (username || password) {
|
||||
const authHeader = await probeAuth(url, username, password, useProxy);
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true) : url;
|
||||
}
|
||||
}
|
||||
await downloadFile({
|
||||
appService,
|
||||
dst: cachedPath,
|
||||
cfp: '',
|
||||
url: downloadUrl,
|
||||
singleThreaded: true,
|
||||
headers,
|
||||
});
|
||||
return await appService.getImageURL(cachedPath);
|
||||
}
|
||||
},
|
||||
[appService],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
const entry = history[newIndex];
|
||||
if (!entry) return;
|
||||
|
||||
isNavigatingHistoryRef.current = true;
|
||||
setHistoryIndex(newIndex);
|
||||
setState(entry.state);
|
||||
setViewMode(entry.viewMode);
|
||||
setSelectedPublication(entry.selectedPublication);
|
||||
|
||||
const newURL = new URL(window.location.href);
|
||||
newURL.searchParams.set('url', entry.url);
|
||||
window.history.replaceState({}, '', newURL.toString());
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
const entry = history[newIndex];
|
||||
if (!entry) return;
|
||||
|
||||
isNavigatingHistoryRef.current = true;
|
||||
setHistoryIndex(newIndex);
|
||||
setState(entry.state);
|
||||
setViewMode(entry.viewMode);
|
||||
setSelectedPublication(entry.selectedPublication);
|
||||
|
||||
const newURL = new URL(window.location.href);
|
||||
newURL.searchParams.set('url', entry.url);
|
||||
window.history.replaceState({}, '', newURL.toString());
|
||||
}
|
||||
}, [history, historyIndex]);
|
||||
|
||||
const handlePublicationSelect = useCallback((groupIndex: number, itemIndex: number) => {
|
||||
setSelectedPublication({ groupIndex, itemIndex });
|
||||
setViewMode('publication');
|
||||
|
||||
// Add this publication view to history
|
||||
setHistory((prev) => {
|
||||
const currentEntry = prev[historyIndexRef.current];
|
||||
if (!currentEntry) return prev;
|
||||
|
||||
const newEntry: HistoryEntry = {
|
||||
url: currentEntry.url,
|
||||
state: currentEntry.state,
|
||||
viewMode: 'publication',
|
||||
selectedPublication: { groupIndex, itemIndex },
|
||||
};
|
||||
|
||||
return [...prev.slice(0, historyIndexRef.current + 1), newEntry];
|
||||
});
|
||||
setHistoryIndex((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < history.length - 1;
|
||||
|
||||
const publication =
|
||||
selectedPublication && state.feed
|
||||
? state.feed.groups?.[selectedPublication.groupIndex]?.publications?.[
|
||||
selectedPublication.itemIndex
|
||||
] || state.feed.publications?.[selectedPublication.itemIndex]
|
||||
: state.publication;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-base-100 flex h-screen select-none flex-col',
|
||||
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative top-0 z-40 w-full'
|
||||
style={{
|
||||
paddingTop: `${safeAreaInsets?.top || 0}px`,
|
||||
}}
|
||||
>
|
||||
<Navigation
|
||||
currentURL={state.currentURL}
|
||||
startURL={state.startURL}
|
||||
onNavigate={handleNavigate}
|
||||
onBack={handleBack}
|
||||
onForward={handleForward}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
/>
|
||||
</div>
|
||||
<main className='flex-1 overflow-auto'>
|
||||
{viewMode === 'loading' && (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='text-center'>
|
||||
<div className='loading loading-spinner loading-lg mb-4'></div>
|
||||
<h1 className='text-base font-semibold'>{_('Loading...')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'error' && (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='max-w-md text-center'>
|
||||
<h1 className='text-error mb-4 text-xl font-bold'>{_('Cannot Load Page')}</h1>
|
||||
<p className='text-base-content/70 mb-4'>
|
||||
{error?.message || _('An error occurred')}
|
||||
</p>
|
||||
<button className='btn btn-primary' onClick={() => window.location.reload()}>
|
||||
{_('Reload Page')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'feed' && state.feed && (
|
||||
<FeedView
|
||||
feed={state.feed}
|
||||
baseURL={state.baseURL}
|
||||
onNavigate={handleNavigate}
|
||||
onPublicationSelect={handlePublicationSelect}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
|
||||
isOPDSCatalog={isOPDSCatalog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'publication' && publication && (
|
||||
<PublicationView
|
||||
publication={publication}
|
||||
baseURL={state.baseURL}
|
||||
onDownload={handleDownload}
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'search' && state.search && (
|
||||
<SearchView
|
||||
search={state.search}
|
||||
baseURL={state.baseURL}
|
||||
onNavigate={handleNavigate}
|
||||
resolveURL={resolveURL}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { md5 } from 'js-md5';
|
||||
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
/**
|
||||
* Extract username and password from URL credentials
|
||||
*/
|
||||
const extractCredentialsFromURL = (
|
||||
url: string,
|
||||
): { url: string; username?: string; password?: string } => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const username = decodeURIComponent(urlObj.username) || undefined;
|
||||
const password = decodeURIComponent(urlObj.password) || undefined;
|
||||
|
||||
if (username || password) {
|
||||
urlObj.username = '';
|
||||
urlObj.password = '';
|
||||
return {
|
||||
url: urlObj.toString(),
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse URL:', e);
|
||||
}
|
||||
|
||||
return { url };
|
||||
};
|
||||
|
||||
export const needsProxy = (url: string): boolean => {
|
||||
return isWebAppPlatform() && url.startsWith('http');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate proxied URL for OPDS requests
|
||||
*/
|
||||
export const getProxiedURL = (url: string, auth: string = '', stream = false): string => {
|
||||
if (url.startsWith('http')) {
|
||||
const { url: cleanUrl } = extractCredentialsFromURL(url);
|
||||
const params = new URLSearchParams();
|
||||
params.append('url', cleanUrl);
|
||||
params.append('stream', `${stream}`);
|
||||
if (auth) {
|
||||
params.append('auth', auth);
|
||||
}
|
||||
const proxyUrl = `/api/opds/proxy?${params.toString()}`;
|
||||
return proxyUrl;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse Digest authentication challenge from WWW-Authenticate header
|
||||
*/
|
||||
const parseDigestChallenge = (challenge: string): Record<string, string> => {
|
||||
const params: Record<string, string> = {};
|
||||
const regex = /(\w+)=["']?([^"',]+)["']?/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(challenge)) !== null) {
|
||||
params[match[1]!] = match[2]!;
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate Digest authentication response hash
|
||||
*/
|
||||
const generateDigestResponse = (
|
||||
username: string,
|
||||
password: string,
|
||||
params: Record<string, string>,
|
||||
method: string,
|
||||
uri: string,
|
||||
nc: string,
|
||||
cnonce: string,
|
||||
) => {
|
||||
const realm = params['realm'];
|
||||
const nonce = params['nonce'];
|
||||
const qop = params['qop'];
|
||||
const algorithm = params['algorithm'];
|
||||
|
||||
let ha1 = md5(`${username}:${realm}:${password}`);
|
||||
|
||||
if (algorithm && algorithm.toLowerCase() === 'md5-sess') {
|
||||
ha1 = md5(`${ha1}:${nonce}:${cnonce}`);
|
||||
}
|
||||
|
||||
const ha2 = md5(`${method}:${uri}`);
|
||||
|
||||
let response: string;
|
||||
|
||||
if (qop) {
|
||||
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
|
||||
} else {
|
||||
response = md5(`${ha1}:${nonce}:${ha2}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Digest Authorization header
|
||||
*/
|
||||
export const createDigestAuth = async (
|
||||
username: string,
|
||||
password: string,
|
||||
wwwAuthenticate: string,
|
||||
method: string,
|
||||
uri: string,
|
||||
): Promise<string> => {
|
||||
const params = parseDigestChallenge(wwwAuthenticate);
|
||||
const cnonce = Math.random().toString(36).slice(2);
|
||||
const nc = '00000001';
|
||||
const response = await generateDigestResponse(
|
||||
username,
|
||||
password,
|
||||
params,
|
||||
method,
|
||||
uri,
|
||||
nc,
|
||||
cnonce,
|
||||
);
|
||||
|
||||
const parts = [
|
||||
`username="${username}"`,
|
||||
`realm="${params['realm']}"`,
|
||||
`nonce="${params['nonce']}"`,
|
||||
`uri="${uri}"`,
|
||||
`response="${response}"`,
|
||||
];
|
||||
|
||||
if (params['algorithm']) {
|
||||
parts.push(`algorithm="${params['algorithm']}"`);
|
||||
}
|
||||
|
||||
if (params['opaque']) {
|
||||
parts.push(`opaque="${params['opaque']}"`);
|
||||
}
|
||||
|
||||
if (params['qop']) {
|
||||
parts.push(`qop="auth"`);
|
||||
parts.push(`nc=${nc}`);
|
||||
parts.push(`cnonce="${cnonce}"`);
|
||||
}
|
||||
|
||||
return `Digest ${parts.join(', ')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Basic Authorization header
|
||||
*/
|
||||
export const createBasicAuth = (username: string, password: string): string => {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
return `Basic ${credentials}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Probe URL for authentication requirements using HEAD request
|
||||
* Returns auth header if authentication is needed, null otherwise
|
||||
*/
|
||||
export const probeAuth = async (
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
useProxy = false,
|
||||
): Promise<string | null> => {
|
||||
const {
|
||||
url: cleanUrl,
|
||||
username: urlUsername,
|
||||
password: urlPassword,
|
||||
} = extractCredentialsFromURL(url);
|
||||
|
||||
const finalUsername = username || urlUsername;
|
||||
const finalPassword = password || urlPassword;
|
||||
|
||||
// No credentials provided, can't generate auth header
|
||||
if (!finalUsername || !finalPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/atom+xml, application/xml, text/xml',
|
||||
};
|
||||
|
||||
// Probe with HEAD request
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
const res = await fetch(fetchURL, {
|
||||
method: 'HEAD',
|
||||
headers,
|
||||
});
|
||||
|
||||
// Check if authentication is required
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const wwwAuthenticate = res.headers.get('WWW-Authenticate');
|
||||
if (wwwAuthenticate) {
|
||||
if (wwwAuthenticate.toLowerCase().startsWith('digest')) {
|
||||
const urlObj = new URL(cleanUrl);
|
||||
return await createDigestAuth(
|
||||
finalUsername,
|
||||
finalPassword,
|
||||
wwwAuthenticate,
|
||||
'GET',
|
||||
urlObj.pathname + urlObj.search,
|
||||
);
|
||||
} else if (wwwAuthenticate.toLowerCase().startsWith('basic')) {
|
||||
return createBasicAuth(finalUsername, finalPassword);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform authenticated HTTP request with retry logic for Digest/Basic auth
|
||||
*/
|
||||
export const fetchWithAuth = async (
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
useProxy = false,
|
||||
options: RequestInit = {},
|
||||
): Promise<Response> => {
|
||||
const {
|
||||
url: cleanUrl,
|
||||
username: urlUsername,
|
||||
password: urlPassword,
|
||||
} = extractCredentialsFromURL(url);
|
||||
|
||||
const finalUsername = username || urlUsername;
|
||||
const finalPassword = password || urlPassword;
|
||||
|
||||
const fetchURL = useProxy ? getProxiedURL(cleanUrl) : cleanUrl;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/atom+xml, application/xml, text/xml',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
|
||||
let res = await fetch(fetchURL, {
|
||||
...options,
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle authentication if needed
|
||||
if (!res.ok && (res.status === 401 || res.status === 403) && finalUsername && finalPassword) {
|
||||
const wwwAuthenticate = res.headers.get('WWW-Authenticate');
|
||||
if (wwwAuthenticate) {
|
||||
let authHeader: string | null = null;
|
||||
|
||||
if (wwwAuthenticate.toLowerCase().startsWith('digest')) {
|
||||
const urlObj = new URL(cleanUrl);
|
||||
authHeader = await createDigestAuth(
|
||||
finalUsername,
|
||||
finalPassword,
|
||||
wwwAuthenticate,
|
||||
options.method || 'GET',
|
||||
urlObj.pathname + urlObj.search,
|
||||
);
|
||||
} else if (wwwAuthenticate.toLowerCase().startsWith('basic')) {
|
||||
authHeader = createBasicAuth(finalUsername, finalPassword);
|
||||
}
|
||||
|
||||
if (authHeader) {
|
||||
const finalUrl = useProxy ? `${fetchURL}&auth=${encodeURIComponent(authHeader)}` : fetchURL;
|
||||
res = await fetch(finalUrl, {
|
||||
...options,
|
||||
method: options.method || 'GET',
|
||||
headers: useProxy ? headers : { ...headers, Authorization: authHeader },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import { isOPDSCatalog } from 'foliate-js/opds.js';
|
||||
import { fetchWithAuth } from './opdsReq';
|
||||
|
||||
export const groupByArray = <T, K>(arr: T[] | undefined, f: (el: T) => K | K[]): Map<K, T[]> => {
|
||||
const map = new Map<K, T[]>();
|
||||
if (arr) {
|
||||
for (const el of arr) {
|
||||
const keys = f(el);
|
||||
for (const key of [keys].flat()) {
|
||||
const group = map.get(key as K);
|
||||
if (group) group.push(el);
|
||||
else map.set(key as K, [el]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const MIME = {
|
||||
XML: 'application/xml',
|
||||
ATOM: 'application/atom+xml',
|
||||
XHTML: 'application/xhtml+xml',
|
||||
HTML: 'text/html',
|
||||
EPUB: 'application/epub+zip',
|
||||
PDF: 'application/pdf',
|
||||
OPENSEARCH: 'application/opensearchdescription+xml',
|
||||
};
|
||||
|
||||
export const enum VALIDATION_ERROR {
|
||||
INVALID_URL = 'Invalid URL format',
|
||||
LOAD_FAILED = 'Failed to load OPDS feed',
|
||||
NOT_OPDS = 'Invalid OPDS feed URL',
|
||||
NO_OPDS_LINK = 'Document has no link to OPDS feeds',
|
||||
NO_HREF = 'OPDS link has no href attribute',
|
||||
INVALID_HTML = 'Invalid HTML document',
|
||||
INVALID_CONTENT = 'Content is neither valid XML nor JSON',
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: VALIDATION_ERROR | string;
|
||||
data?: {
|
||||
type: 'feed' | 'entry' | 'opensearch' | 'html';
|
||||
doc: Document;
|
||||
text: string;
|
||||
responseURL: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const parseMediaType = (str?: string) => {
|
||||
if (!str) return null;
|
||||
const [mediaType, ...ps] = str.split(/ *; */);
|
||||
if (!mediaType) return null;
|
||||
|
||||
return {
|
||||
mediaType: mediaType.toLowerCase(),
|
||||
parameters: Object.fromEntries(
|
||||
ps
|
||||
.map((p) => {
|
||||
const [name, val] = p.split('=');
|
||||
if (!name) return null;
|
||||
return [name.toLowerCase(), val?.replace(/(^"|"$)/g, '')];
|
||||
})
|
||||
.filter((entry): entry is [string, string] => entry !== null),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveURL = (url: string, relativeTo: string): string => {
|
||||
if (!url) return '';
|
||||
if (relativeTo.includes('/api/opds/proxy?url=')) {
|
||||
const params = new URLSearchParams(relativeTo.split('?')[1]);
|
||||
const proxiedURL = params.get('url') || '';
|
||||
return resolveURL(url, proxiedURL);
|
||||
}
|
||||
try {
|
||||
if (relativeTo.includes(':')) return new URL(url, relativeTo).toString();
|
||||
const root = 'https://invalid.invalid/';
|
||||
const obj = new URL(url, root + relativeTo);
|
||||
obj.search = '';
|
||||
return decodeURI(obj.href.replace(root, ''));
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateOPDSURL = async (
|
||||
url: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
useProxy = false,
|
||||
): Promise<ValidationResult> => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const res = await fetchWithAuth(url, username, password, useProxy, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Authentication required. Please check your username and password.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Failed to load OPDS feed: ${res.status} ${res.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const responseURL = res.url;
|
||||
const text = await res.text();
|
||||
|
||||
// Check if it's XML-based OPDS
|
||||
if (text.startsWith('<')) {
|
||||
const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType);
|
||||
const {
|
||||
documentElement: { localName },
|
||||
} = doc;
|
||||
|
||||
if (localName === 'feed') {
|
||||
return {
|
||||
isValid: true,
|
||||
data: { type: 'feed', doc, text, responseURL },
|
||||
};
|
||||
} else if (localName === 'entry') {
|
||||
return {
|
||||
isValid: true,
|
||||
data: { type: 'entry', doc, text, responseURL },
|
||||
};
|
||||
} else if (localName === 'OpenSearchDescription') {
|
||||
return {
|
||||
isValid: true,
|
||||
data: { type: 'opensearch', doc, text, responseURL },
|
||||
};
|
||||
} else {
|
||||
// Check for HTML with OPDS link
|
||||
const contentType = res.headers.get('Content-Type') ?? MIME.HTML;
|
||||
const type = parseMediaType(contentType)?.mediaType ?? MIME.HTML;
|
||||
const htmlDoc = new DOMParser().parseFromString(text, type as DOMParserSupportedType);
|
||||
|
||||
if (!htmlDoc.head) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
|
||||
const link = Array.from(htmlDoc.head.querySelectorAll('link')).find((link) =>
|
||||
isOPDSCatalog(link.getAttribute('type') ?? ''),
|
||||
);
|
||||
|
||||
if (!link) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'OPDS link has no href attribute',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
data: { type: 'html', doc: htmlDoc, text, responseURL },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Check if it's JSON-based OPDS
|
||||
try {
|
||||
const feed = JSON.parse(text);
|
||||
// Basic validation for OPDS JSON feed
|
||||
if (!feed.metadata && !feed.links && !feed.publications && !feed.navigation) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
data: {
|
||||
type: 'feed',
|
||||
doc: new Document(),
|
||||
text,
|
||||
responseURL,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
error: VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('OPDS validation error:', e);
|
||||
return {
|
||||
isValid: false,
|
||||
error: e instanceof Error ? e.message : VALIDATION_ERROR.NOT_OPDS,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -36,6 +36,7 @@ import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book'
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
import {
|
||||
handleKeydown,
|
||||
handleKeyup,
|
||||
handleMousedown,
|
||||
handleMouseup,
|
||||
handleClick,
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
} from '../utils/iframeEventHandlers';
|
||||
import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
@@ -56,6 +56,8 @@ import { useBookCoverAutoSave } from '../hooks/useAutoSaveBookCover';
|
||||
import { manageSyntaxHighlighting } from '@/utils/highlightjs';
|
||||
import { getViewInsets } from '@/utils/insets';
|
||||
import { removeTabIndex } from '@/utils/a11y';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import KOSyncConflictResolver from './KOSyncResolver';
|
||||
|
||||
@@ -137,6 +139,7 @@ const FoliateViewer: React.FC<{
|
||||
width,
|
||||
height,
|
||||
primaryLanguage: bookData.book?.primaryLanguage,
|
||||
userLocale: getLocale(),
|
||||
content: data,
|
||||
transformers: [
|
||||
'style',
|
||||
@@ -145,6 +148,7 @@ const FoliateViewer: React.FC<{
|
||||
'whitespace',
|
||||
'language',
|
||||
'sanitizer',
|
||||
'simplecc',
|
||||
],
|
||||
};
|
||||
return Promise.resolve(transformContent(ctx));
|
||||
@@ -221,6 +225,7 @@ const FoliateViewer: React.FC<{
|
||||
// and more gesture events can be detected in the iframeEventHandlers
|
||||
detail.doc.isEventListenersAdded = true;
|
||||
detail.doc.addEventListener('keydown', handleKeydown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('keyup', handleKeyup.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mousedown', handleMousedown.bind(null, bookKey));
|
||||
detail.doc.addEventListener('mouseup', handleMouseup.bind(null, bookKey));
|
||||
detail.doc.addEventListener('click', handleClick.bind(null, bookKey, doubleClickDisabled));
|
||||
@@ -395,10 +400,11 @@ const FoliateViewer: React.FC<{
|
||||
const rightMargin = insets.right + moreRightInset;
|
||||
const bottomMargin = (showBottomFooter ? insets.bottom : viewInsets.bottom) + moreBottomInset;
|
||||
const leftMargin = insets.left + moreLeftInset;
|
||||
const viewMargins = viewSettings.showMarginsOnScroll && viewSettings.scrolled;
|
||||
|
||||
viewRef.current?.renderer.setAttribute('margin-top', `${topMargin}px`);
|
||||
viewRef.current?.renderer.setAttribute('margin-top', `${viewMargins ? 0 : topMargin}px`);
|
||||
viewRef.current?.renderer.setAttribute('margin-right', `${rightMargin}px`);
|
||||
viewRef.current?.renderer.setAttribute('margin-bottom', `${bottomMargin}px`);
|
||||
viewRef.current?.renderer.setAttribute('margin-bottom', `${viewMargins ? 0 : bottomMargin}px`);
|
||||
viewRef.current?.renderer.setAttribute('margin-left', `${leftMargin}px`);
|
||||
viewRef.current?.renderer.setAttribute('gap', `${viewSettings.gapPercent}%`);
|
||||
if (viewSettings.scrolled) {
|
||||
@@ -477,6 +483,8 @@ const FoliateViewer: React.FC<{
|
||||
viewState?.ttsEnabled,
|
||||
]);
|
||||
|
||||
const showViewMargins = viewSettings?.showMarginsOnScroll && viewSettings?.scrolled;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -485,6 +493,10 @@ const FoliateViewer: React.FC<{
|
||||
role='document'
|
||||
aria-label={_('Book Content')}
|
||||
className='foliate-viewer h-[100%] w-[100%] focus:outline-none'
|
||||
style={{
|
||||
paddingTop: showViewMargins ? insets.top : 0,
|
||||
paddingBottom: showViewMargins ? insets.bottom : 0,
|
||||
}}
|
||||
{...mouseHandlers}
|
||||
{...touchHandlers}
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const view = getView(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const footnoteHandler = new FootnoteHandler();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [gridRect, setGridRect] = useState<DOMRect | null>(null);
|
||||
const [responsiveWidth, setResponsiveWidth] = useState(popupWidth);
|
||||
@@ -115,6 +116,12 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showPopup) {
|
||||
containerRef.current?.focus();
|
||||
}
|
||||
}, [showPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewSettings.vertical) {
|
||||
setResponsiveWidth(popupHeight);
|
||||
@@ -231,14 +238,16 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
}, [footnoteRef]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={containerRef} role='toolbar' tabIndex={-1}>
|
||||
{showPopup && <Overlay onDismiss={handleDismissPopup} />}
|
||||
<Popup
|
||||
isOpen={showPopup}
|
||||
width={responsiveWidth}
|
||||
height={responsiveHeight}
|
||||
position={showPopup ? popupPosition! : undefined}
|
||||
trianglePosition={showPopup ? trianglePosition! : undefined}
|
||||
className='select-text overflow-y-auto'
|
||||
onDismiss={handleDismissPopup}
|
||||
>
|
||||
<div
|
||||
className=''
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
@@ -39,14 +40,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
isTrafficLightVisible,
|
||||
trafficLightInFullscreen,
|
||||
setTrafficLightVisibility,
|
||||
initializeTrafficLightStore,
|
||||
initializeTrafficLightListeners,
|
||||
cleanupTrafficLightListeners,
|
||||
} = useTrafficLightStore();
|
||||
const { isTrafficLightVisible } = useTrafficLight();
|
||||
const { trafficLightInFullscreen, setTrafficLightVisibility } = useTrafficLightStore();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { bookKeys, hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
||||
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
||||
@@ -60,17 +55,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
if (!isOpen) setHoveredBookKey('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
|
||||
initializeTrafficLightStore(appService);
|
||||
initializeTrafficLightListeners();
|
||||
return () => {
|
||||
cleanupTrafficLightListeners();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appService?.hasTrafficLight) return;
|
||||
if (isSideBarVisible) return;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user