mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-08-13 14:21:57 -07:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39feb4a88 | ||
|
|
b1dec6491d | ||
|
|
1915457562 | ||
|
|
54561e8df3 | ||
|
|
06f6ecc27b | ||
|
|
58932354bd | ||
|
|
47021955d4 | ||
|
|
7a5f12863c | ||
|
|
c64b0ceec2 | ||
|
|
69d7323888 | ||
|
|
640e421e5d | ||
|
|
94400ed2ea | ||
|
|
b5a9411a8d | ||
|
|
096223f808 | ||
|
|
ec0cd48b76 | ||
|
|
45abc21f6d | ||
|
|
9220e5a301 | ||
|
|
28302714df | ||
|
|
cb22cb5291 | ||
|
|
ad30652f52 | ||
|
|
dbcdace3cf | ||
|
|
41b5c2e498 | ||
|
|
cec96908c2 | ||
|
|
529c8a6398 | ||
|
|
e7da410a10 | ||
|
|
217def55bf | ||
|
|
8bffe852fd | ||
|
|
000824f5ca | ||
|
|
66ec7dbe4c | ||
|
|
506ce26d05 | ||
|
|
2a5f3d2a54 | ||
|
|
5acd49fb10 | ||
|
|
b4b4e8fef2 | ||
|
|
af688c56ed | ||
|
|
a41c4ae02e | ||
|
|
0ca93a8653 | ||
|
|
f3d4702fa8 | ||
|
|
2e9e643ab0 | ||
|
|
d992bc0ff4 | ||
|
|
27ec21dbb8 | ||
|
|
d6600ed0a8 | ||
|
|
ac74a60efb | ||
|
|
7a89475c80 | ||
|
|
41922b55c5 | ||
|
|
31f2bb71ae | ||
|
|
c405c223c2 | ||
|
|
b95e0bcd48 | ||
|
|
90581d1ba4 | ||
|
|
5987594b2d | ||
|
|
cd09c9c748 | ||
|
|
41f17f1b5c | ||
|
|
afd6162e6a | ||
|
|
e017d099e2 | ||
|
|
ca8e135276 | ||
|
|
c6a968d0e1 | ||
|
|
cb056d50b6 |
No files matched your search
@@ -31,4 +31,5 @@ lib/EpdFont/scripts/output/
|
||||
/dependencies.lock
|
||||
/sdkconfig.*
|
||||
sdkconfig.sticky
|
||||
sdkconfig.x4pro
|
||||
flake.*
|
||||
@@ -0,0 +1,351 @@
|
||||
# SD-card plugins
|
||||
|
||||
CrossPoint keeps service integrations off the firmware. A plugin is a folder on
|
||||
the SD card; the firmware provides three generic, scheme-neutral surfaces that
|
||||
plugins compose. Adding or updating a plugin never requires a firmware build,
|
||||
and the firmware carries no vendor names, URLs, or file-format knowledge.
|
||||
|
||||
```
|
||||
<root>/<name>/
|
||||
manifest.json web UI card metadata (optional): title, description, mount
|
||||
plugin.js browser-side plugin (optional)
|
||||
device.json on-device catalog screen (optional): title, description, ...
|
||||
README.md usage instructions, shown on-device (optional)
|
||||
...assets
|
||||
```
|
||||
|
||||
**Discovery and the on-device list.** Every plugin folder (anything holding a
|
||||
`manifest.json`, `plugin.js`, or `device.json`) appears under **Settings →
|
||||
System → Plugins** on the reader, showing its `title` and one-line
|
||||
`description`. Selecting a plugin opens an info screen with the description and
|
||||
the plugin's `README.md` as scrollable usage instructions — so even a
|
||||
browser-only plugin (no `device.json`) is listed and can explain how to use it
|
||||
from the web UI. A plugin that ships a `device.json` also gets an **Open**
|
||||
action there to launch its on-device catalog. `title`/`description` are read
|
||||
from `manifest.json`, with `device.json` overriding when present.
|
||||
|
||||
`<root>` is any of `/.crosspoint/plugins`, `/plugins`, or `/.plugins` — the
|
||||
first two-dot-free options exist so plugins are easy to copy onto the card
|
||||
from a computer. All three roots are scanned; on a name collision the earlier
|
||||
root in that order wins.
|
||||
|
||||
A plugin can ship any combination: `plugin.js` alone (web-only),
|
||||
`device.json` alone (on-device only), or both (e.g. sign in from either side,
|
||||
browse on the reader).
|
||||
|
||||
The plugin sources and examples live in the separate `sd-plugins` repository,
|
||||
which also documents the browser-side `plugin.js` API. This document covers the
|
||||
firmware surfaces: the job queue and the `device.json` on-device screens.
|
||||
|
||||
## Surface 1: browser plugins (`plugin.js`)
|
||||
|
||||
JS loaded into the File Manager or Settings web page, backed by generic device
|
||||
endpoints (`/api/relay`, `/api/crypto`, `/api/fetch`, `/api/plugin-fs`). See
|
||||
`docs/webserver-endpoints.md` for the endpoints and the sd-plugins repository
|
||||
for the JS contract.
|
||||
|
||||
## Surface 2: the plugin job queue (external automation)
|
||||
|
||||
External systems (a companion app, a script) can trigger plugin actions
|
||||
without a human clicking the web UI. The firmware stores small opaque
|
||||
`{plugin, action, args}` JSON blobs in a fixed 6-slot pool; it never interprets
|
||||
them. Any open page hosting the plugin — including the headless runner at
|
||||
`GET /plugins-run` — claims jobs, executes the plugin's registered handler in
|
||||
the browser context, and posts the result back.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `POST /api/plugin-jobs` | enqueue `{plugin, action, args?}` → `{id}` (503 when the pool is full) |
|
||||
| `GET /api/plugin-jobs/claim?plugin=` | executor claims the next pending job |
|
||||
| `POST /api/plugin-jobs/complete` | executor posts `{id, ok, result?}` |
|
||||
| `GET /api/plugin-jobs/status?id=` | caller polls: `{id, state, result}`; states: `pending`, `running`, `done`, `error`, `unknown` (recycled) |
|
||||
| `GET /plugins-run` | headless page that loads every plugin (UI hidden) and executes jobs while open |
|
||||
|
||||
Flow for an external system:
|
||||
|
||||
```sh
|
||||
# 1. upload whatever the action needs (e.g. a file, via /upload)
|
||||
# 2. enqueue
|
||||
curl -X POST http://crosspoint.local/api/plugin-jobs \
|
||||
-d '{"plugin":"<name>","action":"<action>","args":{"path":"/folder/file"}}'
|
||||
# -> {"id":7}
|
||||
# 3. open http://crosspoint.local/plugins-run in a browser tab or hidden
|
||||
# webview (jobs only execute while a hosting page is open)
|
||||
# 4. poll
|
||||
curl "http://crosspoint.local/api/plugin-jobs/status?id=7"
|
||||
# -> {"id":7,"state":"done","result":{...}}
|
||||
```
|
||||
|
||||
Limits: plugin/action names < 24 chars, args and result < 192 bytes of JSON
|
||||
each. A job claimed by a runner that dies is reclaimable after 10 minutes.
|
||||
Results persist only until their slot is recycled — poll promptly.
|
||||
|
||||
Plugins register handlers in `plugin.js`:
|
||||
|
||||
```js
|
||||
api.registerAction('myaction', async (args) => {
|
||||
// runs in the hosting page; throw -> state "error" with {error: message}
|
||||
return { anything: 'small' }; // -> state "done" with this result
|
||||
});
|
||||
```
|
||||
|
||||
## Surface 3: on-device catalog screens (`device.json`)
|
||||
|
||||
A declarative manifest the firmware's generic `PluginCatalogActivity` renders
|
||||
under **Settings → System → Plugins**. It expresses "authenticated JSON
|
||||
catalog: sign in, browse, download, sidecar" — enough for most book services —
|
||||
without any code running on the device. Anything beyond this vocabulary
|
||||
belongs in `plugin.js`.
|
||||
|
||||
The firmware pieces (all service-agnostic, in `src/activities/plugins/`):
|
||||
`PluginListActivity` (the Plugins menu), `PluginInfoActivity` (per-plugin
|
||||
description + README + Open action), `PluginCatalogActivity` (browse / download
|
||||
/ sign-in), and `discoverPlugins()` (folder scan when the menu opens; nothing
|
||||
stays resident).
|
||||
|
||||
### Schema
|
||||
|
||||
Every string field is a template. Available substitutions:
|
||||
|
||||
| Variable | Meaning | Available in |
|
||||
|---|---|---|
|
||||
| `{token}` | contents of the token file at `token.path` | everywhere |
|
||||
| `{cfg.KEY}` | value of `KEY` in the flat JSON config file at `config.file` | everywhere |
|
||||
| `{page}` | current 1-based page | browse |
|
||||
| `{limit}` | `page_size + 1` (the extra row detects "more pages") | browse |
|
||||
| `{id}` `{title}` `{author}` `{url}` | fields of the selected item | download, sidecar |
|
||||
| `{md5}` | MD5 hex of the destination file path | sidecar |
|
||||
| `{device_code}` | from the auth request response | auth poll |
|
||||
|
||||
Two browse formats:
|
||||
- **`"json"`** (default) — a paged JSON list, using `items`/`fields` below.
|
||||
- **`"xml"`** — walks a repeating XML element with optional folder navigation
|
||||
(Confirm opens a folder, Back goes up). No paging; the JSON `items`/
|
||||
`page_size` are ignored. The firmware knows no specific protocol: a WebDAV
|
||||
`PROPFIND` multistatus, an OPDS/Atom feed, or any XML list is all manifest
|
||||
config. XML field selectors are `"elem"` (child element text), `"elem@attr"`
|
||||
(child element attribute), or `"@attr"` (attribute on the item element).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"title": "Service Name", // menu label; defaults to folder name
|
||||
|
||||
"token": { // omit for token-less catalogs
|
||||
"file": "/.crosspoint/<name>.json", // written by auth (either side)
|
||||
"path": "token" // dotted JSON path inside the file
|
||||
},
|
||||
|
||||
"config": { // optional: flat JSON of {cfg.KEY} values,
|
||||
"file": "/.crosspoint/<name>-cfg.json" // e.g. a user-entered server URL + credentials
|
||||
},
|
||||
|
||||
"browse": { // required
|
||||
"format": "json", // or "xml"
|
||||
"url": "https://.../search",
|
||||
"method": "POST", // default GET
|
||||
"headers": { "Authorization": "Bearer {token}" },
|
||||
"body": "{\"page\":{page},\"per_page\":{limit}}",
|
||||
|
||||
// --- json format ---
|
||||
"items": "", // dotted path to the item array; "" = response root
|
||||
"fields": { // json: dotted paths (numeric = array index)
|
||||
"title": "title", // required (items without one are dropped)
|
||||
"author": "authors.0.name",
|
||||
"id": "id",
|
||||
"url": "download_url" // when the item carries a direct file URL
|
||||
},
|
||||
"page_size": 8, // 1..16; response should honor {limit}
|
||||
|
||||
"lists": [ // optional named sub-catalogs (json only):
|
||||
{ "title": "All Books" }, // a picker screen precedes browsing;
|
||||
{ "title": "Favorites", // each entry may override url and/or
|
||||
"body": "{\"page\":{page},\"per_page\":{limit},\"list\":\"favorites\"}" }
|
||||
], // omitted keys fall back to browse's
|
||||
|
||||
// --- xml format (ignore the json fields above; fields become selectors) ---
|
||||
"item": "response", // local-name of the repeating element
|
||||
"container_element": "collection", // presence marks a navigable folder (omit = flat)
|
||||
"skip_self": true, // drop the entry equal to the request URL
|
||||
"resolve_urls": true, // resolve url field against the request origin
|
||||
"extensions": [".epub", ".pdf"] // allowed file extensions (omit = all)
|
||||
},
|
||||
|
||||
"download": {
|
||||
"url": "https://.../books/{id}/download",
|
||||
"method": "POST",
|
||||
"headers": { "Authorization": "Bearer {token}" }, // sent on the file GET too
|
||||
"body": "{}",
|
||||
"url_path": "url", // response field with the file URL;
|
||||
// omit to treat "url" itself as the file URL
|
||||
"username": "{cfg.user}", // optional HTTP Basic creds for the file GET
|
||||
"password": "{cfg.pass}", // omit for token/header auth
|
||||
"dest_dir": "/ServiceName", // created if missing; falls back to SD root
|
||||
"filename": "{title}.epub", // {title} is filesystem-sanitized here
|
||||
"sidecar": { // optional per-book metadata file
|
||||
"path": "/.crosspoint/<name>_{md5}.json",
|
||||
"body": "{\"book_id\":{id}}"
|
||||
}
|
||||
},
|
||||
|
||||
"auth": { // optional on-device sign-in
|
||||
"type": "device_code", // or "password"
|
||||
|
||||
// device_code (interactive: shows a code + QR, polls until authorized):
|
||||
"request": { "url": "...", "method": "POST", "headers": { ... }, "body": "..." },
|
||||
"poll": { "url": "...", "method": "POST", "headers": { ... },
|
||||
"body": "{...\"device_code\":\"{device_code}\"}" },
|
||||
// device_code response field paths, with their defaults:
|
||||
"code_path": "user_code", "verify_url_path": "verification_uri",
|
||||
"device_code_path": "device_code", "interval_path": "interval",
|
||||
"expires_path": "expires_in", "token_path": "access_token",
|
||||
"error_path": "error"
|
||||
|
||||
// password (silent: mints a token from stored config credentials — for
|
||||
// OAuth2 password grants and login-returns-token APIs). Only "request" and
|
||||
// "token_path" apply; no user interaction:
|
||||
// "type": "password",
|
||||
// "request": { "url": "{cfg.url}/oauth/v2/token", "method": "POST",
|
||||
// "headers": {...}, "body": "...{cfg.username}...{cfg.password}..." },
|
||||
// "token_path": "access_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- **Sign-in (`device_code`)**: the not-signed-in screen offers Sign in on the
|
||||
device: it shows the verification URL (text + QR) and user code, then polls
|
||||
until authorized, honoring `authorization_pending`, `slow_down`,
|
||||
`expired_token`, and `access_denied`. The token is written to `token.file`
|
||||
in the shape `token.path` expects, so the web plugin and the device share
|
||||
one sign-in.
|
||||
- **Sign-in (`password`)**: no on-device UI — the device silently POSTs the
|
||||
stored config credentials to the token endpoint before browsing, and re-mints
|
||||
a fresh token automatically after any 401 (these tokens are short-lived).
|
||||
Credentials come from a browser `plugin.js` writing the `config.file`. Set it
|
||||
up once on the web page; the reader is then standalone.
|
||||
- Without an `auth` block the not-signed-in screen directs the user to the web
|
||||
plugin.
|
||||
- **Stale tokens**: a 401/403 from browse returns to the sign-in screen rather
|
||||
than an error.
|
||||
- **Pagination** (json): the browse request should return up to `{limit}`
|
||||
items; the firmware displays `page_size` and uses the extra row to know
|
||||
another page exists. Navigation matches the OPDS browser: a "Previous
|
||||
page" row precedes the items past page 1 and a "Next page" row follows
|
||||
them while more pages exist — tappable and button-reachable like any row.
|
||||
- **Lists** (json): with `browse.lists`, opening the catalog shows a picker
|
||||
of the named entries first; each entry browses with its own url/body
|
||||
overrides (server-side categories, shelves, sort orders). Back from the
|
||||
book list returns to the picker.
|
||||
- **XML navigation**: Confirm on a folder (an item carrying `container_element`)
|
||||
descends into it, Back climbs out (Back at the root leaves the screen).
|
||||
With `resolve_urls`, server-relative URLs resolve against the browse URL's
|
||||
origin. A `config.file` lets values like a server URL and credentials be
|
||||
user-entered (via a browser `plugin.js`) rather than baked into the manifest.
|
||||
- **After download**: the book's layout cache is invalidated and the optional
|
||||
sidecar is written (e.g. a service book id keyed by path MD5, for a future
|
||||
progress-sync stage).
|
||||
|
||||
### Heap budget (ESP32-C3, ~380KB total)
|
||||
|
||||
- Manifest ≤ 8KB, token file ≤ 2KB, small API responses (auth, download-url
|
||||
hop) ≤ 48KB (hard caps).
|
||||
- Browse responses (json and xml) stream to an SD temp file and are parsed
|
||||
from it, so the raw body never occupies DRAM — services that inline heavy
|
||||
per-item metadata (a page of BookFusion JSON runs 60+ KB) work unmodified.
|
||||
A 1MB cap bounds a misbehaving server.
|
||||
- Catalog responses are parsed with a dynamically built ArduinoJson filter
|
||||
containing only the declared field paths, so a page parses into a few KB
|
||||
no matter the body size.
|
||||
- One TLS session is kept alive across browse requests: repeated handshakes
|
||||
permanently fragment the heap.
|
||||
- The book download streams to SD via `HttpDownloader`; file size is
|
||||
unconstrained.
|
||||
- An XML list is parsed from the temp file in 2KB chunks; the engine stops
|
||||
after 200 entries per folder/feed. Split very large libraries into
|
||||
subfolders.
|
||||
|
||||
### Testing a new manifest
|
||||
|
||||
1. Copy the plugin folder to `/.crosspoint/plugins/<name>/` on the SD card.
|
||||
2. Settings → System → Plugins → your title. With no token and no `auth`
|
||||
block you should see the not-signed-in screen; with `auth`, the code/QR
|
||||
screen.
|
||||
3. Watch serial (`[PCAT]` tag) for request/parse failures — the log includes
|
||||
HTTP status and truncation flags.
|
||||
4. Token-less services (public JSON APIs) work by omitting the `token` block
|
||||
entirely; that is the quickest way to validate `items`/`fields` paths.
|
||||
|
||||
## Ideas to build
|
||||
|
||||
The two planes decide *how* you build something, so pick by what the idea needs:
|
||||
|
||||
- **On-device (`device.json`)** — standalone on the reader, but only the
|
||||
declarative vocabulary above: an authenticated JSON or XML/OPDS-style catalog,
|
||||
browse, download to SD, optional device-code or password sign-in and per-book
|
||||
sidecar. No arbitrary logic.
|
||||
- **Browser (`plugin.js`)** — anything at all (any protocol, auth, format), but
|
||||
it runs in a connected browser/app. Use it when the idea needs request
|
||||
signing, HTML→EPUB conversion, multi-file writes, or a real UI. The job queue
|
||||
(`/api/plugin-jobs` + `/plugins-run`) lets an external app trigger it.
|
||||
|
||||
Rule of thumb: if the service is "log in, list an authenticated JSON/XML
|
||||
catalog, download files," it's on-device. If it needs computed request bodies
|
||||
(OAuth 1.0a signing, canonicalized signatures) or format conversion, it's
|
||||
browser-side.
|
||||
|
||||
### On-device (`device.json`) ideas
|
||||
|
||||
- **Self-hosted library presets** — Kavita, Komga, Audiobookshelf, Calibre-Web,
|
||||
COPS. Most expose OPDS (native OPDS browsing already covers those — a plugin
|
||||
adds value mainly for their JSON APIs or token auth). Build: `browse.format`
|
||||
`json` against the REST API, or `xml` against the OPDS feed; `download.url`
|
||||
from the entry; bearer/Basic/`{cfg.*}` auth. See `bookfusion` (JSON + token)
|
||||
and `webdav` (XML + config creds).
|
||||
- **Dictionary store** — browse a JSON index of dictionary files, download to
|
||||
the SD dictionary dir. No native equivalent. Build: token-less `json` catalog,
|
||||
`download.dest_dir` = the dictionary folder, no sidecar. ~15 lines.
|
||||
- **Theme store / font store** — same shape as the dictionary store, pointed at
|
||||
the theme or font directory. Asset catalogs OPDS can't express.
|
||||
- **More read-later that exports EPUB** — anything with a token API and an
|
||||
EPUB export URL fits like `wallabag`: `auth.type` `password` (or a stored
|
||||
bearer), `browse` the entries JSON, `download` the export endpoint with a
|
||||
Bearer header.
|
||||
- **A curated free-books catalog** — Standard Ebooks / Gutenberg via their OPDS
|
||||
feed (`xml` format, token-less). Overlaps native OPDS, but ships as a
|
||||
zero-config preset.
|
||||
|
||||
### Browser (`plugin.js`) ideas
|
||||
|
||||
- **Plugin store** — install other plugins from a hosted catalog. Build: fetch
|
||||
a catalog with `api.relay`, `/mkdir` the target folder, `api.writeFile` each
|
||||
file. See `plugin-store/`.
|
||||
- **AO3 / fanfiction downloader** — the site serves per-work EPUB downloads.
|
||||
Build: take a work URL, `api.fetchToSd` (or `api.relay` + `api.writeFile`) the
|
||||
EPUB to SD. Browsing/search is HTML, so parse it in the browser.
|
||||
- **RSS → EPUB digest** — build a "morning edition." Fetch feeds with
|
||||
`api.relay`, assemble an EPUB in the browser with the JSZip the File Manager
|
||||
page already loads, then `api.writeFile` it to SD.
|
||||
- **Read-any-article** — paste a URL, run it through a readability service via
|
||||
`api.relay`, wrap the result as an EPUB, write to SD.
|
||||
- **Metadata / cover editor** — a File Manager plugin: open an EPUB with JSZip,
|
||||
edit the OPF (title/author/cover), rewrite it in place. See
|
||||
`organize-by-author/` for the File-Manager-endpoints pattern.
|
||||
- **Library tidy tools** — dedupe, bulk rename, move-by-metadata. Same
|
||||
same-origin endpoints (`/api/files`, `/mkdir`, `/move`, `/delete`), no device
|
||||
API needed.
|
||||
- **Signed-API services** (Instapaper OAuth 1.0a, etc.) — must be browser-side:
|
||||
do the per-request signing in JS with `crypto.subtle`, relay the calls, and
|
||||
convert the article HTML to EPUB before writing to SD.
|
||||
|
||||
### Building blocks reference
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Outbound HTTP(S), any method (CORS-free) | `api.relay(method, url, headers, body)` |
|
||||
| Download a URL straight to SD | `api.fetchToSd(url, dest, headers)` |
|
||||
| Write a small file to SD | `api.writeFile(path, base64)` |
|
||||
| Crypto (hash, HMAC via SHA, AES, RSA) | `api.crypto(op, fields)` or browser `crypto.subtle` |
|
||||
| Create / delete / move SD files | same-origin `/mkdir`, `/delete`, `/move` |
|
||||
| On-device catalog/browse/download | `device.json` (this document) |
|
||||
| External app triggers an action | `api.registerAction(name, fn)` + `/api/plugin-jobs` |
|
||||
@@ -504,3 +504,43 @@ The final field is the WebSocket upload port.
|
||||
|
||||
Calibre Wireless starts the same web server in STA mode and displays setup
|
||||
instructions plus WebSocket upload progress on the device screen.
|
||||
|
||||
## Plugin Job Queue
|
||||
|
||||
External systems trigger SD-plugin actions without the web UI. The firmware
|
||||
stores opaque `{plugin, action, args}` blobs (fixed 6-slot pool; args/result
|
||||
< 192 bytes JSON); an open page hosting the plugin executes them. See
|
||||
`docs/sd-plugins.md` for the full contract.
|
||||
|
||||
### `POST /api/plugin-jobs`
|
||||
|
||||
```bash
|
||||
curl -X POST http://crosspoint.local/api/plugin-jobs \
|
||||
-d '{"plugin":"<name>","action":"<action>","args":{"path":"/Books/somefile"}}'
|
||||
# -> {"id":3} (503 {"error":"job queue full"} when all slots busy)
|
||||
```
|
||||
|
||||
### `GET /api/plugin-jobs/claim?plugin=<name>`
|
||||
|
||||
Executor-side (used by the plugin host page): returns the next pending job
|
||||
`{"id":3,"action":"<action>","args":{...}}` and marks it running, or `{"id":0}`.
|
||||
|
||||
### `POST /api/plugin-jobs/complete`
|
||||
|
||||
Executor-side: `{"id":3,"ok":true,"result":{...}}` -> `{"ok":true}`.
|
||||
|
||||
### `GET /api/plugin-jobs/status?id=<n>`
|
||||
|
||||
```bash
|
||||
curl "http://crosspoint.local/api/plugin-jobs/status?id=3"
|
||||
# -> {"id":3,"state":"done","result":{"title":"...","dest":"/Books/Book.epub"}}
|
||||
```
|
||||
|
||||
States: `pending`, `running`, `done`, `error`, `unknown` (slot recycled —
|
||||
poll promptly after completion).
|
||||
|
||||
### `GET /plugins-run`
|
||||
|
||||
Headless executor page: loads every plugin with its UI hidden so registered
|
||||
actions run. Keep it open (browser tab or embedded webview) while jobs are
|
||||
queued; jobs enqueued with no page open stay pending until one opens.
|
||||
+1
-1
Submodule freeink-sdk updated: 83d49345d8...58d34b3788.
@@ -0,0 +1,146 @@
|
||||
// SD/HAL binding for the content-protection read path.
|
||||
//
|
||||
// The ContentProtection SDK lib is storage-agnostic (it works against a
|
||||
// ByteSource). This file is the firmware-side glue that backs that seam with
|
||||
// the device's SD storage: a HalStorage-backed ByteSource, the credential
|
||||
// lookup, and the openProtectedBook() entry point the reader calls. It lives in
|
||||
// the firmware — not the SDK lib — so the portable lib carries no HAL dependency.
|
||||
|
||||
#include <ByteSource.h>
|
||||
#include <ContentProtection.h>
|
||||
#include <Credential.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Memory.h>
|
||||
#include <ProtectedBook.h>
|
||||
#include <WolfsslCrypto.h>
|
||||
#include <Zip.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <time.h>
|
||||
|
||||
namespace freeink {
|
||||
namespace content {
|
||||
|
||||
namespace {
|
||||
|
||||
// The access credential is provisioned off-device and dropped here.
|
||||
// Generic path — the reader carries no scheme name.
|
||||
constexpr const char* kCredentialPath = "/.crosspoint/content.key";
|
||||
|
||||
// One shared crypto backend for the whole read path.
|
||||
WolfsslCrypto& crypto() {
|
||||
static WolfsslCrypto instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// ByteSource over an SD file (read-only). One open handle per instance.
|
||||
class SdByteSource : public ByteSource {
|
||||
public:
|
||||
explicit SdByteSource(std::string path) : path_(std::move(path)) {}
|
||||
bool open() {
|
||||
file_ = Storage.open(path_.c_str(), O_RDONLY);
|
||||
return file_ && file_.isOpen();
|
||||
}
|
||||
int32_t readAt(uint64_t offset, void* dst, uint32_t len) override {
|
||||
if (!file_ || !file_.seek64(offset)) return -1;
|
||||
return file_.read(dst, len);
|
||||
}
|
||||
uint64_t size() const override { return file_ ? file_.fileSize64() : 0; }
|
||||
|
||||
private:
|
||||
std::string path_;
|
||||
mutable HalFile file_;
|
||||
};
|
||||
|
||||
// Adapts an opened ProtectedBook to the reader-facing access interface.
|
||||
class ProtectedBookDecryptor : public ContentDecryptor {
|
||||
public:
|
||||
ProtectedBookDecryptor(std::string epubPath, std::unique_ptr<ProtectedBook> book)
|
||||
: epubPath_(std::move(epubPath)), book_(std::move(book)) {}
|
||||
|
||||
bool isEncrypted(const std::string& itemPath) const override { return book_->isEncrypted(itemPath); }
|
||||
|
||||
size_t decryptedSize(const std::string& itemPath) const override { return book_->decryptedSize(itemPath); }
|
||||
|
||||
bool decryptToSink(const std::string& itemPath, ContentChunkSink sink, void* context) override {
|
||||
SdByteSource source(epubPath_);
|
||||
if (!source.open()) return false;
|
||||
return book_->decryptEntryToSink(source, crypto(), itemPath, sink, context);
|
||||
}
|
||||
|
||||
private:
|
||||
std::string epubPath_;
|
||||
std::unique_ptr<ProtectedBook> book_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<ContentDecryptor> openProtectedBook(const std::string& epubPath, std::string& err) {
|
||||
err.clear();
|
||||
if (!Storage.exists(epubPath.c_str())) return nullptr;
|
||||
|
||||
SdByteSource source(epubPath);
|
||||
if (!source.open()) return nullptr;
|
||||
|
||||
// Classify before initializing crypto or loading credentials, then transfer
|
||||
// this same ZIP index into ProtectedBook. Protected EPUBs still scan only
|
||||
// once; plain EPUBs return immediately through the normal reader path.
|
||||
ZipScan scan;
|
||||
if (!scan.open(source) || !scan.find("META-INF/encryption.xml")) return nullptr;
|
||||
|
||||
// A book carrying encryption.xml may only obfuscate its embedded fonts
|
||||
// (not content-protected). The SDK demands the credential only after parsing
|
||||
// the manifest and finding genuinely encrypted entries.
|
||||
SdByteSource credSource(kCredentialPath);
|
||||
Credential credential;
|
||||
const bool haveCredential = credSource.open() && parseCredential(credSource, &credential);
|
||||
|
||||
auto book = makeUniqueNoThrow<ProtectedBook>();
|
||||
if (!book) {
|
||||
err = "out of memory";
|
||||
return nullptr;
|
||||
}
|
||||
// Prefer an out-of-band rights document delivered as a sidecar next to the
|
||||
// EPUB ("<book>.epub.rights"), so the EPUB on disk stays byte-identical to
|
||||
// what the server sent. Falls back to a rights.xml injected into the zip.
|
||||
std::string rightsOverride;
|
||||
{
|
||||
// A real rights document is a few KB; 64KB is a generous ceiling. The
|
||||
// largest-block check keeps the resize below from aborting on OOM (string
|
||||
// growth is a bare allocation under -fno-exceptions).
|
||||
constexpr uint64_t kMaxRightsSize = 64 * 1024;
|
||||
SdByteSource rightsSource(epubPath + ".rights");
|
||||
if (rightsSource.open()) {
|
||||
const uint64_t rsize = rightsSource.size();
|
||||
if (rsize > 0 && rsize <= kMaxRightsSize &&
|
||||
heap_caps_get_largest_free_block(MALLOC_CAP_8BIT) > static_cast<size_t>(rsize) + 8 * 1024) {
|
||||
rightsOverride.resize(static_cast<size_t>(rsize));
|
||||
const int32_t rn = rightsSource.readAt(0, rightsOverride.data(), static_cast<uint32_t>(rsize));
|
||||
if (rn <= 0)
|
||||
rightsOverride.clear();
|
||||
else
|
||||
rightsOverride.resize(static_cast<size_t>(rn));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!book->openFromScan(source, crypto(), credential, std::move(scan), rightsOverride)) {
|
||||
err = haveCredential ? ("cannot open protected content: " + book->lastError())
|
||||
: "no content access key on this device";
|
||||
return nullptr;
|
||||
}
|
||||
// An encryption manifest containing only font obfuscation does not require
|
||||
// this read path; let the reader open it normally.
|
||||
if (!book->isProtected()) return nullptr;
|
||||
|
||||
const int64_t now = static_cast<int64_t>(time(nullptr));
|
||||
if (book->isExpired(now)) {
|
||||
err = "access expired";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto decryptor = makeUniqueNoThrow<ProtectedBookDecryptor>(epubPath, std::move(book));
|
||||
if (!decryptor) err = "out of memory";
|
||||
return decryptor;
|
||||
}
|
||||
|
||||
} // namespace content
|
||||
} // namespace freeink
|
||||
+72
-28
@@ -8,6 +8,10 @@
|
||||
#include <Utf8.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "Epub/parsers/ContainerParser.h"
|
||||
#include "Epub/parsers/ContentOpfParser.h"
|
||||
#include "Epub/parsers/TocNavParser.h"
|
||||
@@ -357,6 +361,22 @@ void Epub::parseCssFiles() const {
|
||||
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
|
||||
|
||||
// Open the optional encrypted-entry accessor. A null result without an error
|
||||
// means normal ZIP reads should be used. A hard error refuses the open with
|
||||
// a user-presentable reason.
|
||||
{
|
||||
std::string err;
|
||||
decryptor = freeink::content::openProtectedBook(filepath, err);
|
||||
if (!err.empty()) {
|
||||
LOG_ERR("EBP", "protected content unavailable: %s", err.c_str());
|
||||
protectionError = err;
|
||||
return false;
|
||||
}
|
||||
if (decryptor) {
|
||||
LOG_DBG("EBP", "protected content; on-read access path open");
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize spine/TOC cache
|
||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||
// Always create CssParser - needed for inline style parsing even without CSS files
|
||||
@@ -580,15 +600,9 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
if (FsHelpers::hasJpgExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
if (!extractItemToFile(coverImageHref, coverJpgTempPath)) return false;
|
||||
|
||||
HalFile coverJpg;
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
coverJpg.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
@@ -614,15 +628,9 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
if (FsHelpers::hasPngExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
if (!extractItemToFile(coverImageHref, coverPngTempPath)) return false;
|
||||
|
||||
HalFile coverPng;
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
@@ -669,15 +677,9 @@ bool Epub::generateThumbBmp(int height) const {
|
||||
} else if (FsHelpers::hasJpgExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
if (!extractItemToFile(coverImageHref, coverJpgTempPath)) return false;
|
||||
|
||||
HalFile coverJpg;
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
coverJpg.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
@@ -706,15 +708,9 @@ bool Epub::generateThumbBmp(int height) const {
|
||||
} else if (FsHelpers::hasPngExtension(coverImageHref)) {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||
if (!extractItemToFile(coverImageHref, coverPngTempPath)) return false;
|
||||
|
||||
HalFile coverPng;
|
||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
||||
// Explicitly close() file before reopening for reading
|
||||
coverPng.close();
|
||||
|
||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||
return false;
|
||||
}
|
||||
@@ -756,6 +752,38 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
|
||||
// Decode encrypted entries on demand in memory.
|
||||
if (decryptor && decryptor->isEncrypted(path)) {
|
||||
const size_t plainSize = decryptor->decryptedSize(path);
|
||||
if (plainSize > SIZE_MAX - (trailingNullByte ? 1 : 0)) return nullptr;
|
||||
const size_t total = plainSize + (trailingNullByte ? 1 : 0);
|
||||
uint8_t* content = static_cast<uint8_t*>(malloc(total > 0 ? total : 1));
|
||||
if (!content) {
|
||||
LOG_ERR("EBP", "insufficient memory for %s (%u bytes)", path.c_str(), static_cast<unsigned>(total));
|
||||
return nullptr;
|
||||
}
|
||||
struct BufferSink {
|
||||
uint8_t* data;
|
||||
size_t capacity;
|
||||
size_t written;
|
||||
} state{content, plainSize, 0};
|
||||
auto append = [](void* context, const uint8_t* data, size_t size) {
|
||||
auto* target = static_cast<BufferSink*>(context);
|
||||
if (size > target->capacity - target->written) return false;
|
||||
memcpy(target->data + target->written, data, size);
|
||||
target->written += size;
|
||||
return true;
|
||||
};
|
||||
if (!decryptor->decryptToSink(path, append, &state) || state.written != plainSize) {
|
||||
free(content);
|
||||
LOG_ERR("EBP", "content read failed for %s", path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
if (trailingNullByte) content[plainSize] = 0;
|
||||
if (size) *size = plainSize;
|
||||
return content;
|
||||
}
|
||||
|
||||
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
|
||||
if (!content) {
|
||||
LOG_DBG("EBP", "Failed to read item %s", path.c_str());
|
||||
@@ -773,6 +801,22 @@ bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, con
|
||||
}
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
|
||||
if (decryptor && decryptor->isEncrypted(path)) {
|
||||
struct PrintSink {
|
||||
Print* output;
|
||||
} state{&out};
|
||||
auto append = [](void* context, const uint8_t* data, size_t size) {
|
||||
auto* target = static_cast<PrintSink*>(context);
|
||||
return target->output->write(data, size) == size;
|
||||
};
|
||||
if (!decryptor->decryptToSink(path, append, &state)) {
|
||||
LOG_ERR("EBP", "content read failed for %s", path.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize, allowEarlyStop);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <ContentProtection.h>
|
||||
#include <Print.h>
|
||||
|
||||
#include <memory>
|
||||
@@ -29,6 +30,11 @@ class Epub {
|
||||
std::unique_ptr<CssParser> cssParser;
|
||||
// CSS files
|
||||
std::vector<std::string> cssFiles;
|
||||
// Optional encrypted-entry accessor. Entries are decoded in memory and stay
|
||||
// encrypted at rest. Null when the accessor is not needed or unavailable.
|
||||
std::unique_ptr<freeink::content::ContentDecryptor> decryptor;
|
||||
// User-presentable reason the encrypted-entry accessor could not be opened.
|
||||
std::string protectionError;
|
||||
|
||||
bool findContentOpfFile(std::string* contentOpfFile) const;
|
||||
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, bool writeSpineEntries = true);
|
||||
@@ -49,6 +55,9 @@ class Epub {
|
||||
void setupCacheDir() const;
|
||||
const std::string& getCachePath() const;
|
||||
const std::string& getPath() const;
|
||||
// Empty unless the encrypted-entry accessor failed to open.
|
||||
const std::string& getProtectionError() const { return protectionError; }
|
||||
bool isProtected() const { return decryptor != nullptr; }
|
||||
const std::string& getTitle() const;
|
||||
const std::string& getAuthor() const;
|
||||
const std::string& getLanguage() const;
|
||||
|
||||
@@ -206,8 +206,8 @@ STR_UNNAMED: "Без імя"
|
||||
STR_NO_SERVER_URL: "URL сервера не наладжаны"
|
||||
STR_FETCH_FEED_FAILED: "Не ўдалося атрымаць стужку"
|
||||
STR_PARSE_FEED_FAILED: "Не ўдалося апрацаваць стужку"
|
||||
STR_NEXT_PAGE: "Наступная старонка »"
|
||||
STR_PREV_PAGE: "« Папярэдняя старонка"
|
||||
STR_NEXT_PAGE: "Наступная старонка"
|
||||
STR_PREV_PAGE: "Папярэдняя старонка"
|
||||
STR_NETWORK_PREFIX: "Сетка:"
|
||||
STR_IP_ADDRESS_PREFIX: "IP-адрас:"
|
||||
STR_ERROR_GENERAL_FAILURE: "Памылка: Агульная памылка"
|
||||
|
||||
@@ -218,8 +218,8 @@ STR_HOLD_OPEN_TO_DELETE: "Drži Otvori za brisanje"
|
||||
STR_NO_SERVER_URL: "Nije podešen URL servera"
|
||||
STR_FETCH_FEED_FAILED: "Dohvatanje feeda nije uspjelo"
|
||||
STR_PARSE_FEED_FAILED: "Obrada feeda nije uspjela"
|
||||
STR_NEXT_PAGE: "Sljedeća stranica »"
|
||||
STR_PREV_PAGE: "« Prethodna stranica"
|
||||
STR_NEXT_PAGE: "Sljedeća stranica"
|
||||
STR_PREV_PAGE: "Prethodna stranica"
|
||||
STR_NETWORK_PREFIX: "Mreža: "
|
||||
STR_IP_ADDRESS_PREFIX: "IP adresa: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Greška: Opšta greška"
|
||||
|
||||
@@ -351,8 +351,8 @@ STR_AUTO_TURN_PAGES_PER_MIN: "Pas automàtic de pàgina (pàg./min)"
|
||||
STR_TILT_PAGE_TURN: "Pas de pàgina per inclinació"
|
||||
STR_FORCE_REFRESH: "Refresca la pantalla"
|
||||
STR_RESTARTING_HINT: "S'està reiniciant... Si el dispositiu no es reinicia, mantén premut el botó d'encesa durant uns segons."
|
||||
STR_NEXT_PAGE: "Pàgina següent »"
|
||||
STR_PREV_PAGE: "« Pàgina anterior"
|
||||
STR_NEXT_PAGE: "Pàgina següent"
|
||||
STR_PREV_PAGE: "Pàgina anterior"
|
||||
STR_XTC_STATUS_BAR: "Barra d'estat XTC"
|
||||
STR_BOTTOM: "Inferior"
|
||||
STR_TOP: "Superior"
|
||||
|
||||
@@ -390,7 +390,7 @@ STR_LONG_PRESS_MENU: "Dlouhé stisknutí Nabídky"
|
||||
STR_LOOKUP: "Vyhledat"
|
||||
STR_MANAGE_FONTS: "Správa písem"
|
||||
STR_NEXT_FIELD: "Další"
|
||||
STR_NEXT_PAGE: "Další stránka »"
|
||||
STR_NEXT_PAGE: "Další stránka"
|
||||
STR_NO_BIN_FILES: "Nenalezeny žádné soubory .bin"
|
||||
STR_NO_FONTS_AVAILABLE: "Žádná písma nejsou k dispozici"
|
||||
STR_NO_FOOTNOTES: "Na této stránce nejsou žádné poznámky pod čarou"
|
||||
@@ -398,7 +398,7 @@ STR_NO_SERVERS: "Nejsou nakonfigurovány žádné OPDS servery"
|
||||
STR_OPDS_SD_ROOT: "Kořen SD karty"
|
||||
STR_OPDS_SERVERS: "OPDS servery"
|
||||
STR_PREVIEW: "Náhled"
|
||||
STR_PREV_PAGE: "« Předchozí stránka"
|
||||
STR_PREV_PAGE: "Předchozí stránka"
|
||||
STR_PROGRESS_BAR: "Ukazatel průběhu"
|
||||
STR_PROGRESS_BAR_MEDIUM: "Střední"
|
||||
STR_PROGRESS_BAR_THICK: "Široký"
|
||||
|
||||
@@ -213,14 +213,25 @@ STR_RESTARTING_HINT: "Restarting... If device does not restart, hold the power b
|
||||
STR_NO_ENTRIES: "No entries found"
|
||||
STR_DOWNLOADING: "Downloading..."
|
||||
STR_DOWNLOAD_FAILED: "Download failed"
|
||||
STR_DOWNLOAD_COMPLETE: "Download complete"
|
||||
STR_ERROR_MSG: "Error:"
|
||||
STR_UNNAMED: "Unnamed"
|
||||
STR_HOLD_OPEN_TO_DELETE: "Hold Open to Delete"
|
||||
STR_NO_SERVER_URL: "No server URL configured"
|
||||
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
|
||||
STR_PARSE_FEED_FAILED: "Failed to parse feed"
|
||||
STR_NEXT_PAGE: "Next Page »"
|
||||
STR_PREV_PAGE: "« Previous Page"
|
||||
STR_PLUGINS: "Plugins"
|
||||
STR_NO_PLUGINS_INSTALLED: "No plugins with device screens installed"
|
||||
STR_PLUGIN_NOT_SIGNED_IN: "Not signed in. Open the web Settings page and sign in via the plugin first."
|
||||
STR_PLUGIN_MANIFEST_INVALID: "Plugin manifest is missing or invalid"
|
||||
STR_PLUGIN_SIGN_IN: "Sign in"
|
||||
STR_PLUGIN_SIGN_IN_HINT: "Not signed in yet"
|
||||
STR_PLUGIN_AUTH_WAITING: "Waiting for authorization..."
|
||||
STR_PLUGIN_AUTH_FAILED: "Sign-in failed or expired. Try again."
|
||||
STR_PLUGIN_WEB_ONLY: "This plugin runs in the web interface. Connect from a browser to use it."
|
||||
STR_DRM_PROTECTED_FILE: "DRM-protected file"
|
||||
STR_NEXT_PAGE: "Next Page"
|
||||
STR_PREV_PAGE: "Previous Page"
|
||||
STR_NETWORK_PREFIX: "Network: "
|
||||
STR_IP_ADDRESS_PREFIX: "IP Address: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Error: General failure"
|
||||
@@ -239,6 +250,7 @@ STR_CANCEL: "Cancel"
|
||||
STR_CONNECT: "Connect"
|
||||
STR_OPEN: "Open"
|
||||
STR_DOWNLOAD: "Download"
|
||||
STR_FETCH: "Fetch"
|
||||
STR_RETRY: "Retry"
|
||||
STR_TAP_TO_RETRY: "Tap to retry"
|
||||
STR_YES: "Yes"
|
||||
|
||||
@@ -219,8 +219,8 @@ STR_HOLD_OPEN_TO_DELETE: "Maintenir Ouvrir pour supprimer"
|
||||
STR_NO_SERVER_URL: "URL serveur non configurée"
|
||||
STR_FETCH_FEED_FAILED: "Échec récupération flux"
|
||||
STR_PARSE_FEED_FAILED: "Échec analyse flux"
|
||||
STR_NEXT_PAGE: "Page suivante »"
|
||||
STR_PREV_PAGE: "« Page précédente"
|
||||
STR_NEXT_PAGE: "Page suivante"
|
||||
STR_PREV_PAGE: "Page précédente"
|
||||
STR_NETWORK_PREFIX: "Réseau : "
|
||||
STR_IP_ADDRESS_PREFIX: "IP : "
|
||||
STR_ERROR_GENERAL_FAILURE: "Erreur : Échec général"
|
||||
|
||||
@@ -181,8 +181,8 @@ STR_HOLD_OPEN_TO_DELETE: "Halte Öffnen zum Löschen"
|
||||
STR_NO_SERVER_URL: "Keine Server-URL konfiguriert"
|
||||
STR_FETCH_FEED_FAILED: "Feedfehler"
|
||||
STR_PARSE_FEED_FAILED: "Feed-Format ungültig"
|
||||
STR_NEXT_PAGE: "Nächste Seite »"
|
||||
STR_PREV_PAGE: "« Vorherige Seite"
|
||||
STR_NEXT_PAGE: "Nächste Seite"
|
||||
STR_PREV_PAGE: "Vorherige Seite"
|
||||
STR_NETWORK_PREFIX: "Netzwerk:"
|
||||
STR_IP_ADDRESS_PREFIX: "IP-Adresse:"
|
||||
STR_ERROR_GENERAL_FAILURE: "Fehler: Allgemeiner Fehler"
|
||||
|
||||
@@ -219,8 +219,8 @@ STR_HOLD_OPEN_TO_DELETE: "Tartsa lenyomva a Megnyitás gombot a törléshez"
|
||||
STR_NO_SERVER_URL: "Nincs konfigurált szerver URL"
|
||||
STR_FETCH_FEED_FAILED: "Hírcsatorna betöltése sikertelen"
|
||||
STR_PARSE_FEED_FAILED: "Hírcsatorna feldolgozása sikertelen"
|
||||
STR_NEXT_PAGE: "Következő oldal »"
|
||||
STR_PREV_PAGE: "« Előző oldal"
|
||||
STR_NEXT_PAGE: "Következő oldal"
|
||||
STR_PREV_PAGE: "Előző oldal"
|
||||
STR_NETWORK_PREFIX: "Hálózat: "
|
||||
STR_IP_ADDRESS_PREFIX: "IP-cím: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Hiba: Általános hiba"
|
||||
|
||||
@@ -219,8 +219,8 @@ STR_HOLD_OPEN_TO_DELETE: "Tahan Buka untuk Menghapus"
|
||||
STR_NO_SERVER_URL: "URL server belum dikonfigurasi"
|
||||
STR_FETCH_FEED_FAILED: "Gagal mengambil umpan"
|
||||
STR_PARSE_FEED_FAILED: "Gagal mengurai umpan"
|
||||
STR_NEXT_PAGE: "Halaman Berikutnya »"
|
||||
STR_PREV_PAGE: "« Halaman Sebelumnya"
|
||||
STR_NEXT_PAGE: "Halaman Berikutnya"
|
||||
STR_PREV_PAGE: "Halaman Sebelumnya"
|
||||
STR_NETWORK_PREFIX: "Jaringan: "
|
||||
STR_IP_ADDRESS_PREFIX: "Alamat IP: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Kesalahan: Kegagalan umum"
|
||||
|
||||
@@ -264,7 +264,7 @@ STR_NEVER: "Mai"
|
||||
STR_NEW_UPDATE: "Nuovo aggiornamento disponibile!"
|
||||
STR_NEW_VERSION: "Nuova versione: "
|
||||
STR_NEXT_FIELD: "Succ."
|
||||
STR_NEXT_PAGE: "Pag. successiva »"
|
||||
STR_NEXT_PAGE: "Pag. successiva"
|
||||
STR_NEXT_PREV: "Succ/Prec"
|
||||
STR_NO: "No"
|
||||
STR_NO_BIN_FILES: "Nessun file .bin trovato"
|
||||
@@ -317,7 +317,7 @@ STR_PORTRAIT: "Verticale"
|
||||
STR_POWER_ON_HINT: "Tenere premuto il tasto di accensione per riavviare"
|
||||
STR_PRESS_OK_SCAN: "Premere OK per ripetere scansione"
|
||||
STR_PREV_NEXT: "Prec/Succ"
|
||||
STR_PREV_PAGE: "« Pag. precedente"
|
||||
STR_PREV_PAGE: "Pag. precedente"
|
||||
STR_PREVIEW: "Anteprima"
|
||||
STR_PROGRESS_BAR: "Barra di avanzamento"
|
||||
STR_PROGRESS_BAR_MEDIUM: "Medio"
|
||||
|
||||
@@ -351,8 +351,8 @@ STR_BOOKMARK_OPTION: "Бетбелгі"
|
||||
STR_DICTIONARY: "Сөздік"
|
||||
STR_DISABLED: "Өшірулі"
|
||||
STR_RESTARTING_HINT: "Қайта іске қосылуда... Құрылғы қайта іске қосылмаса, қуат түймесін бірнеше секунд басып тұрыңыз."
|
||||
STR_NEXT_PAGE: "Келесі бет »"
|
||||
STR_PREV_PAGE: "« Алдыңғы бет"
|
||||
STR_NEXT_PAGE: "Келесі бет"
|
||||
STR_PREV_PAGE: "Алдыңғы бет"
|
||||
STR_TAP_TO_RETRY: "Қайталау үшін түртіңіз"
|
||||
STR_KEY_SHIFT: "Shift"
|
||||
STR_KEY_MODE_SYMBOLS: "?123"
|
||||
|
||||
@@ -219,8 +219,8 @@ STR_HOLD_OPEN_TO_DELETE: "Laikykite Atidaryti, kad ištrintumėte"
|
||||
STR_NO_SERVER_URL: "Nėra serverio URL"
|
||||
STR_FETCH_FEED_FAILED: "Nepavyko gauti duomenų"
|
||||
STR_PARSE_FEED_FAILED: "Apdorojimo klaida"
|
||||
STR_NEXT_PAGE: "Kitas psl. »"
|
||||
STR_PREV_PAGE: "« Ankstesnis psl."
|
||||
STR_NEXT_PAGE: "Kitas psl."
|
||||
STR_PREV_PAGE: "Ankstesnis psl."
|
||||
STR_NETWORK_PREFIX: "Tinklas: "
|
||||
STR_IP_ADDRESS_PREFIX: "IP: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Bendra klaida"
|
||||
|
||||
@@ -219,8 +219,8 @@ STR_HOLD_OPEN_TO_DELETE: "Hold Åpne for å slette"
|
||||
STR_NO_SERVER_URL: "Ingen server-URL konfigurert"
|
||||
STR_FETCH_FEED_FAILED: "Kunne ikke hente feed"
|
||||
STR_PARSE_FEED_FAILED: "Kunne ikke tolke feed"
|
||||
STR_NEXT_PAGE: "Neste side »"
|
||||
STR_PREV_PAGE: "« Forrige side"
|
||||
STR_NEXT_PAGE: "Neste side"
|
||||
STR_PREV_PAGE: "Forrige side"
|
||||
STR_NETWORK_PREFIX: "Nettverk: "
|
||||
STR_IP_ADDRESS_PREFIX: "IP-adresse: "
|
||||
STR_ERROR_GENERAL_FAILURE: "Feil: Generell feil"
|
||||
|
||||
Loaded 20 of 67 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user