Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ba1d5747f | ||
|
|
9fe4dc5e38 | ||
|
|
b1d037569b | ||
|
|
9737cb335c | ||
|
|
fdffc2e5d9 | ||
|
|
a2db43d235 | ||
|
|
63093e606c | ||
|
|
a4ac3b2788 | ||
|
|
35a45f9c1e | ||
|
|
f3cad2f9e1 | ||
|
|
95d8cb712b | ||
|
|
b9867b0d09 | ||
|
|
3dac4446b7 |
+40
-27
@@ -28,8 +28,10 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||||
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||||
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
|
- [Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)](#option-a-crosspoint-sync-server-synccrosspointreadercom-default)
|
||||||
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
|
- [Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)](#option-b-legacy-public-koreader-server-synckoreaderrocks)
|
||||||
|
- [Option C: Self-Hosted Server (Docker Compose)](#option-c-self-hosted-server-docker-compose)
|
||||||
|
- [Syncing While Reading](#syncing-while-reading)
|
||||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||||
- [Cover settings](#cover-settings)
|
- [Cover settings](#cover-settings)
|
||||||
- [Custom images](#custom-images)
|
- [Custom images](#custom-images)
|
||||||
@@ -297,7 +299,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
|||||||
|
|
||||||
- **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates.
|
- **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates.
|
||||||
|
|
||||||
- **KOReader Sync**: Options for setting up KOReader for syncing book progress.
|
- **KOReader Sync**: Options for setting up KOReader for syncing book progress. **Smart sync** is the default for new configurations and auto-resolves simple push/pull decisions. Existing credential files retain **Ask every time** when migrated; you can switch Sync Behavior at any time if you prefer manual confirmation.
|
||||||
|
|
||||||
- **OPDS Servers**: Manage one or more OPDS [(Open Publication Distribution System)](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below.
|
- **OPDS Servers**: Manage one or more OPDS [(Open Publication Distribution System)](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below.
|
||||||
|
|
||||||
@@ -360,9 +362,37 @@ Behavior notes:
|
|||||||
CrossPoint can sync reading progress with KOReader-compatible sync servers.
|
CrossPoint can sync reading progress with KOReader-compatible sync servers.
|
||||||
It also interoperates with KOReader apps/devices when they use the same server and credentials.
|
It also interoperates with KOReader apps/devices when they use the same server and credentials.
|
||||||
|
|
||||||
##### Option A: Free Public Server (`sync.koreader.rocks`)
|
##### Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)
|
||||||
|
|
||||||
1. Register a user once (only if needed):
|
When **Sync Server URL** is left empty, CrossPoint uses the free CrossPoint sync server at `https://sync.crosspointreader.com`. It speaks the standard KOReader sync protocol (so KOReader apps can use it too) and additionally stores an exact spine/page position for lossless CrossPoint-to-CrossPoint sync.
|
||||||
|
|
||||||
|
1. On each CrossPoint device:
|
||||||
|
|
||||||
|
- Go to **Settings -> System -> KOReader Sync**.
|
||||||
|
|
||||||
|
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
|
||||||
|
|
||||||
|
- Leave **Sync Server URL** empty (or set it to `https://sync.crosspointreader.com`).
|
||||||
|
|
||||||
|
- On the first device, run **Sign Up** once to create the account directly from the device. On every other device, just run **Authenticate**.
|
||||||
|
|
||||||
|
Accounts are per server. Existing `sync.koreader.rocks` credentials do not exist on the CrossPoint server; either sign up again with the same username/password or use Option B to keep using the legacy server.
|
||||||
|
|
||||||
|
##### Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)
|
||||||
|
|
||||||
|
Use this if you already sync KOReader devices against the official public server.
|
||||||
|
|
||||||
|
1. On each CrossPoint device:
|
||||||
|
|
||||||
|
- Go to **Settings -> System -> KOReader Sync**.
|
||||||
|
|
||||||
|
- Set **Sync Server URL** to `https://sync.koreader.rocks` (required; an empty URL now points at the CrossPoint server instead).
|
||||||
|
|
||||||
|
- Set **Username** and **Password** to your existing KOReader Sync credentials.
|
||||||
|
|
||||||
|
- Run **Authenticate**.
|
||||||
|
|
||||||
|
2. If you do not have an account yet, run **Sign Up** on the device, or register once with curl:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
USERNAME="user"
|
USERNAME="user"
|
||||||
@@ -375,27 +405,9 @@ curl -i "https://sync.koreader.rocks/users/create" \
|
|||||||
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
|
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Already have KOReader Sync credentials? Skip registration; basic sync only requires using the same existing username/password on all devices.
|
|
||||||
|
|
||||||
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
|
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
|
||||||
|
|
||||||
2. On each CrossPoint device:
|
##### Option C: Self-Hosted Server (Docker Compose)
|
||||||
|
|
||||||
- Go to **Settings -> System -> KOReader Sync**.
|
|
||||||
|
|
||||||
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
|
|
||||||
|
|
||||||
- Set **Sync Server URL** to `https://sync.koreader.rocks`, or leave it empty (both use the same default KOReader sync server).
|
|
||||||
|
|
||||||
- Run **Authenticate**.
|
|
||||||
|
|
||||||
3. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
|
|
||||||
|
|
||||||
- Choose **Apply Remote** to jump to remote progress.
|
|
||||||
|
|
||||||
- Choose **Upload Local** to push current progress.
|
|
||||||
|
|
||||||
##### Option B: Self-Hosted Server (Docker Compose)
|
|
||||||
|
|
||||||
1. Start a sync server:
|
1. Start a sync server:
|
||||||
|
|
||||||
@@ -468,11 +480,12 @@ If this returns `HTTP 402` with `{"code":2002,"message":"Username is already reg
|
|||||||
|
|
||||||
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
|
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
|
||||||
|
|
||||||
5. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
|
##### Syncing While Reading
|
||||||
|
|
||||||
- Choose **Apply Remote** to jump to remote progress.
|
Once any of the options above is set up, press **Confirm** while reading to open the reader menu, then select **Sync Progress**. Alternatively, set **Settings -> Controls -> Long-press Menu** to **KOSync** and hold Confirm to launch sync directly.
|
||||||
|
|
||||||
- Choose **Upload Local** to push current progress.
|
- With **Sync Behavior** set to **Ask every time**, choose **Apply Remote** to jump to remote progress or **Upload Local** to push current progress.
|
||||||
|
- With **Sync Behavior** set to **Smart sync**, CrossPoint auto-resolves simple cases: upload when no remote progress exists, confirm and leave both unchanged when local and remote progress are already synchronized, upload when local progress is further ahead, or apply remote when remote progress is further ahead.
|
||||||
|
|
||||||
### 3.7 Sleep Screen
|
### 3.7 Sleep Screen
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
#include "BookPages.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cmath>
|
|
||||||
#include <limits>
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
// Coarse seed used only before any section has an exact or live count; replaced
|
|
||||||
// by the calibrated average as soon as one is available.
|
|
||||||
constexpr double DEFAULT_BYTES_PER_PAGE = 2000.0;
|
|
||||||
// A section's serialized pageCount is a uint16_t, so no estimate needs to exceed this.
|
|
||||||
constexpr int MAX_SECTION_PAGES = std::numeric_limits<uint16_t>::max();
|
|
||||||
|
|
||||||
int clampToInt(const uint64_t v) {
|
|
||||||
return v > static_cast<uint64_t>(std::numeric_limits<int>::max()) ? std::numeric_limits<int>::max()
|
|
||||||
: static_cast<int>(v);
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
BookPagePosition computeBookPagePosition(const BookPageEntry* entries, const int sectionCount, const int spineIndex,
|
|
||||||
const int pageInSection, const int liveSectionPages) {
|
|
||||||
BookPagePosition pos;
|
|
||||||
if (!entries || sectionCount <= 0) {
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calibrate bytes-per-page from every section with an exact count (empty
|
|
||||||
// known-0 chapters excluded), plus the live estimate for spineIndex when it
|
|
||||||
// has no exact count yet.
|
|
||||||
uint64_t knownBytes = 0;
|
|
||||||
uint64_t knownPages = 0;
|
|
||||||
for (int i = 0; i < sectionCount; ++i) {
|
|
||||||
if (entries[i].pages > 0) {
|
|
||||||
knownBytes += entries[i].bytes;
|
|
||||||
knownPages += static_cast<uint32_t>(entries[i].pages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const bool useLive = spineIndex >= 0 && spineIndex < sectionCount && entries[spineIndex].pages < 0 &&
|
|
||||||
liveSectionPages > 0 && entries[spineIndex].bytes > 0;
|
|
||||||
if (useLive) {
|
|
||||||
knownBytes += entries[spineIndex].bytes;
|
|
||||||
knownPages += static_cast<uint32_t>(liveSectionPages);
|
|
||||||
}
|
|
||||||
const double bytesPerPage = (knownBytes > 0 && knownPages > 0)
|
|
||||||
? static_cast<double>(knownBytes) / static_cast<double>(knownPages)
|
|
||||||
: DEFAULT_BYTES_PER_PAGE;
|
|
||||||
|
|
||||||
uint64_t total = 0;
|
|
||||||
uint64_t before = 0;
|
|
||||||
bool exact = true;
|
|
||||||
for (int i = 0; i < sectionCount; ++i) {
|
|
||||||
uint32_t pages;
|
|
||||||
if (entries[i].pages >= 0) {
|
|
||||||
pages = static_cast<uint32_t>(entries[i].pages); // exact (0 = genuinely empty chapter)
|
|
||||||
} else if (i == spineIndex && liveSectionPages > 0) {
|
|
||||||
pages = static_cast<uint32_t>(std::min(liveSectionPages, MAX_SECTION_PAGES));
|
|
||||||
exact = false;
|
|
||||||
} else {
|
|
||||||
const double raw = std::floor(static_cast<double>(entries[i].bytes) / bytesPerPage + 0.5);
|
|
||||||
pages = static_cast<uint32_t>(std::clamp(raw, 1.0, static_cast<double>(MAX_SECTION_PAGES)));
|
|
||||||
exact = false;
|
|
||||||
}
|
|
||||||
total += pages;
|
|
||||||
if (i < spineIndex) {
|
|
||||||
before += pages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pos.totalPages = clampToInt(total);
|
|
||||||
pos.currentPage = clampToInt(before + static_cast<uint64_t>(std::max(0, pageInSection)) + 1);
|
|
||||||
pos.isEstimate = !exact;
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
// Whole-book page accounting for book-global "page X of Y".
|
|
||||||
//
|
|
||||||
// The finalized section cache files (sections/*.bin) are the single source of
|
|
||||||
// truth for exact per-section counts; the caller harvests them into an array of
|
|
||||||
// BookPageEntry (nothing is persisted separately). Sections without an exact
|
|
||||||
// count are estimated from their byte size, calibrated against the sections
|
|
||||||
// whose counts are known — so the total gets more accurate as more of the book
|
|
||||||
// is paginated, and becomes exact once every section is.
|
|
||||||
|
|
||||||
struct BookPageEntry {
|
|
||||||
uint32_t bytes = 0; // uncompressed XHTML size, for estimating unknown sections
|
|
||||||
int32_t pages = -1; // exact count from a finalized section cache; -1 = unknown (0 = empty chapter)
|
|
||||||
};
|
|
||||||
|
|
||||||
struct BookPagePosition {
|
|
||||||
int currentPage = 0;
|
|
||||||
int totalPages = 0;
|
|
||||||
bool isEstimate = true; // true until every section has an exact count
|
|
||||||
};
|
|
||||||
|
|
||||||
// Book-global position: currentPage = pages before spineIndex + pageInSection + 1.
|
|
||||||
// liveSectionPages is the in-progress build's estimate for spineIndex (see
|
|
||||||
// Section::estimatedTotalPages); it is used for that section when it has no exact
|
|
||||||
// count yet and folded into the bytes-per-page calibration. Pure function: no I/O.
|
|
||||||
BookPagePosition computeBookPagePosition(const BookPageEntry* entries, int sectionCount, int spineIndex,
|
|
||||||
int pageInSection, int liveSectionPages);
|
|
||||||
@@ -287,7 +287,30 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
|||||||
effectiveNoSpaceBefore = true;
|
effectiveNoSpaceBefore = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto ensureTokenCapacity = [&](const size_t additionalTokens) {
|
||||||
|
if (additionalTokens == 0) return;
|
||||||
|
const size_t requiredSize = words.size() + additionalTokens;
|
||||||
|
if (words.capacity() >= requiredSize) return;
|
||||||
|
|
||||||
|
size_t newCapacity = words.capacity();
|
||||||
|
if (newCapacity < 16) {
|
||||||
|
newCapacity = 16;
|
||||||
|
}
|
||||||
|
while (newCapacity < requiredSize) {
|
||||||
|
newCapacity *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
words.reserve(newCapacity);
|
||||||
|
wordStyles.reserve(newCapacity);
|
||||||
|
wordContinues.reserve(newCapacity);
|
||||||
|
wordNoSpaceBefore.reserve(newCapacity);
|
||||||
|
wordIsFocusSuffix.reserve(newCapacity);
|
||||||
|
};
|
||||||
|
|
||||||
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
|
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
|
||||||
|
// CJK-heavy paragraphs can push hundreds of tiny tokens quickly when CSS toggles
|
||||||
|
// inline styles. Reserve once up front to avoid repeated vector growth reallocations.
|
||||||
|
ensureTokenCapacity(breakOffsets.size() + 1);
|
||||||
bool firstToken = true;
|
bool firstToken = true;
|
||||||
size_t tokenStart = 0;
|
size_t tokenStart = 0;
|
||||||
for (const size_t breakOffset : breakOffsets) {
|
for (const size_t breakOffset : breakOffsets) {
|
||||||
@@ -326,29 +349,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
|||||||
|
|
||||||
// --- FOCUS READING LOGIC BELOW ---
|
// --- FOCUS READING LOGIC BELOW ---
|
||||||
|
|
||||||
// Pre-reserve capacity to prevent mid-word heap reallocations.
|
// Worst case: a segment boundary on each byte (highly punctuated UTF-8 text).
|
||||||
size_t maxPossibleNewTokens = word.length();
|
ensureTokenCapacity(word.length());
|
||||||
size_t requiredSize = words.size() + maxPossibleNewTokens;
|
|
||||||
|
|
||||||
if (words.capacity() < requiredSize) {
|
|
||||||
// Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word.
|
|
||||||
size_t newCapacity = words.capacity() * 2;
|
|
||||||
|
|
||||||
// Ensure the doubled capacity is actually enough for this specific word
|
|
||||||
if (newCapacity < requiredSize) {
|
|
||||||
newCapacity = requiredSize;
|
|
||||||
}
|
|
||||||
// Set a sensible minimum starting size so the first few words don't trigger tiny reallocations
|
|
||||||
if (newCapacity < 16) {
|
|
||||||
newCapacity = 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
words.reserve(newCapacity);
|
|
||||||
wordStyles.reserve(newCapacity);
|
|
||||||
wordContinues.reserve(newCapacity);
|
|
||||||
wordNoSpaceBefore.reserve(newCapacity);
|
|
||||||
wordIsFocusSuffix.reserve(newCapacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lambda helper to process and push individual sub-segments of the string
|
// Lambda helper to process and push individual sub-segments of the string
|
||||||
// Use std::string_view to avoid heap allocations when slicing
|
// Use std::string_view to avoid heap allocations when slicing
|
||||||
|
|||||||
+26
-38
@@ -39,34 +39,8 @@ constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) +
|
|||||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||||
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||||
sizeof(uint32_t) + sizeof(uint32_t);
|
sizeof(uint32_t) + sizeof(uint32_t);
|
||||||
|
|
||||||
// Read the render-parameter block of the header (everything between the version
|
|
||||||
// byte and pageCount), in writeSectionFileHeader order. The file cursor must sit
|
|
||||||
// just past the version byte.
|
|
||||||
Section::RenderParams readHeaderRenderParams(HalFile& f) {
|
|
||||||
Section::RenderParams p;
|
|
||||||
serialization::readPod(f, p.fontId);
|
|
||||||
serialization::readPod(f, p.lineCompression);
|
|
||||||
serialization::readPod(f, p.extraParagraphSpacing);
|
|
||||||
serialization::readPod(f, p.paragraphAlignment);
|
|
||||||
serialization::readPod(f, p.viewportWidth);
|
|
||||||
serialization::readPod(f, p.viewportHeight);
|
|
||||||
serialization::readPod(f, p.hyphenationEnabled);
|
|
||||||
serialization::readPod(f, p.embeddedStyle);
|
|
||||||
serialization::readPod(f, p.imageRendering);
|
|
||||||
serialization::readPod(f, p.focusReadingEnabled);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool Section::RenderParams::operator==(const RenderParams& o) const {
|
|
||||||
return fontId == o.fontId && lineCompression == o.lineCompression &&
|
|
||||||
extraParagraphSpacing == o.extraParagraphSpacing && paragraphAlignment == o.paragraphAlignment &&
|
|
||||||
viewportWidth == o.viewportWidth && viewportHeight == o.viewportHeight &&
|
|
||||||
hyphenationEnabled == o.hyphenationEnabled && embeddedStyle == o.embeddedStyle &&
|
|
||||||
imageRendering == o.imageRendering && focusReadingEnabled == o.focusReadingEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Out-of-line so the unique_ptr<ChapterHtmlSlimParser> in BuildContext can be
|
// Out-of-line so the unique_ptr<ChapterHtmlSlimParser> in BuildContext can be
|
||||||
// constructed/destroyed where the parser's full definition is visible.
|
// constructed/destroyed where the parser's full definition is visible.
|
||||||
Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
||||||
@@ -159,11 +133,31 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
}
|
}
|
||||||
filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
||||||
|
|
||||||
const RenderParams fileParams = readHeaderRenderParams(file);
|
int fileFontId;
|
||||||
const RenderParams params = {fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
|
uint16_t fileViewportWidth, fileViewportHeight;
|
||||||
viewportWidth, viewportHeight, hyphenationEnabled, embeddedStyle,
|
float fileLineCompression;
|
||||||
imageRendering, focusReadingEnabled};
|
bool fileExtraParagraphSpacing;
|
||||||
if (!(fileParams == params)) {
|
uint8_t fileParagraphAlignment;
|
||||||
|
bool fileHyphenationEnabled;
|
||||||
|
bool fileEmbeddedStyle;
|
||||||
|
uint8_t fileImageRendering;
|
||||||
|
bool fileFocusReadingEnabled;
|
||||||
|
serialization::readPod(file, fileFontId);
|
||||||
|
serialization::readPod(file, fileLineCompression);
|
||||||
|
serialization::readPod(file, fileExtraParagraphSpacing);
|
||||||
|
serialization::readPod(file, fileParagraphAlignment);
|
||||||
|
serialization::readPod(file, fileViewportWidth);
|
||||||
|
serialization::readPod(file, fileViewportHeight);
|
||||||
|
serialization::readPod(file, fileHyphenationEnabled);
|
||||||
|
serialization::readPod(file, fileEmbeddedStyle);
|
||||||
|
serialization::readPod(file, fileImageRendering);
|
||||||
|
serialization::readPod(file, fileFocusReadingEnabled);
|
||||||
|
|
||||||
|
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
||||||
|
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
|
||||||
|
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
|
||||||
|
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
|
||||||
|
imageRendering != fileImageRendering || focusReadingEnabled != fileFocusReadingEnabled) {
|
||||||
file.close();
|
file.close();
|
||||||
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
|
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
|
||||||
clearCache();
|
clearCache();
|
||||||
@@ -760,7 +754,7 @@ std::string Section::getTextFromSectionFile() {
|
|||||||
return fullText;
|
return fullText;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<uint16_t> Section::getCachedPageCount(const RenderParams* mustMatch) const {
|
std::optional<uint16_t> Section::getCachedPageCount() const {
|
||||||
HalFile f;
|
HalFile f;
|
||||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
@@ -780,12 +774,6 @@ std::optional<uint16_t> Section::getCachedPageCount(const RenderParams* mustMatc
|
|||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A file cached under other render settings paginates differently; its count is
|
|
||||||
// only stale-valid for rough mapping, so reject it when the caller needs a match.
|
|
||||||
if (mustMatch && !(readHeaderRenderParams(f) == *mustMatch)) {
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
|
f.seek(HEADER_SIZE - sizeof(uint32_t) * 4 - sizeof(uint16_t));
|
||||||
uint16_t count;
|
uint16_t count;
|
||||||
serialization::readPod(f, count);
|
serialization::readPod(f, count);
|
||||||
|
|||||||
+1
-21
@@ -78,23 +78,6 @@ class Section {
|
|||||||
std::unique_ptr<Page> loadPageDuringBuild(int page);
|
std::unique_ptr<Page> loadPageDuringBuild(int page);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// The render parameters that determine pagination, in section cache header order
|
|
||||||
// (see writeSectionFileHeader). A cached page count is only valid for one set.
|
|
||||||
struct RenderParams {
|
|
||||||
int fontId = 0;
|
|
||||||
float lineCompression = 0.0f;
|
|
||||||
bool extraParagraphSpacing = false;
|
|
||||||
uint8_t paragraphAlignment = 0;
|
|
||||||
uint16_t viewportWidth = 0;
|
|
||||||
uint16_t viewportHeight = 0;
|
|
||||||
bool hyphenationEnabled = false;
|
|
||||||
bool embeddedStyle = false;
|
|
||||||
uint8_t imageRendering = 0;
|
|
||||||
bool focusReadingEnabled = false;
|
|
||||||
|
|
||||||
bool operator==(const RenderParams& o) const;
|
|
||||||
};
|
|
||||||
|
|
||||||
uint16_t pageCount = 0;
|
uint16_t pageCount = 0;
|
||||||
int currentPage = 0;
|
int currentPage = 0;
|
||||||
|
|
||||||
@@ -161,10 +144,7 @@ class Section {
|
|||||||
std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
|
std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
|
||||||
|
|
||||||
// Get the page count from the section cache file without fully loading it.
|
// Get the page count from the section cache file without fully loading it.
|
||||||
// Finalized files only (a partial's count is just a build watermark). When
|
std::optional<uint16_t> getCachedPageCount() const;
|
||||||
// `mustMatch` is given, the header's render parameters must equal it, so a
|
|
||||||
// count cached under different settings is never trusted.
|
|
||||||
std::optional<uint16_t> getCachedPageCount(const RenderParams* mustMatch = nullptr) const;
|
|
||||||
|
|
||||||
// Look up the page number for a synthetic paragraph index from XPath p[N].
|
// Look up the page number for a synthetic paragraph index from XPath p[N].
|
||||||
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
|
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
|
||||||
|
|||||||
@@ -22,6 +22,17 @@
|
|||||||
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
|
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
|
||||||
constexpr size_t PARSE_BUFFER_SIZE = 1024;
|
constexpr size_t PARSE_BUFFER_SIZE = 1024;
|
||||||
|
|
||||||
|
// This number comes from PR #73
|
||||||
|
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
|
||||||
|
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
|
||||||
|
// memory.
|
||||||
|
// Spotted when reading Intermezzo, there are some really long text blocks in there.
|
||||||
|
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS = 750;
|
||||||
|
|
||||||
|
// When CSS is enabled, flush earlier to save RAM. 320 is still more than enough to build a CJK
|
||||||
|
// page at font size 14
|
||||||
|
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS = 320;
|
||||||
|
|
||||||
// Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation
|
// Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation
|
||||||
// anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per
|
// anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per
|
||||||
// chapter. A runaway count usually means a converter injected machine-generated IDs on
|
// chapter. A runaway count usually means a converter injected machine-generated IDs on
|
||||||
@@ -1155,12 +1166,14 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
|||||||
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
|
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
|
// Keep token growth bounded: CSS-heavy spans can fragment text into many tiny
|
||||||
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
|
// words, so flush earlier when embedded CSS is active. We still keep the
|
||||||
// memory.
|
// "exclude last line" behavior to preserve paragraph flow across chunks.
|
||||||
// Spotted when reading Intermezzo, there are some really long text blocks in there.
|
const size_t blockWordCount = self->currentTextBlock->size();
|
||||||
if (self->currentTextBlock->size() > 750) {
|
const size_t softFlushThreshold =
|
||||||
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
|
self->embeddedStyle ? TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS : TEXT_BLOCK_SOFT_FLUSH_WORDS;
|
||||||
|
if (blockWordCount > softFlushThreshold) {
|
||||||
|
LOG_DBG("EHP", "Text block soft flush (%u words)", static_cast<unsigned>(blockWordCount));
|
||||||
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
|
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
|
||||||
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
|
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
|
||||||
? static_cast<uint16_t>(self->viewportWidth - horizontalInset)
|
? static_cast<uint16_t>(self->viewportWidth - horizontalInset)
|
||||||
|
|||||||
@@ -298,6 +298,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
|||||||
STR_SLEEP_NEVER: "Ніколі"
|
STR_SLEEP_NEVER: "Ніколі"
|
||||||
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
|
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
|
||||||
STR_STEP_HINT_SIDE: "Бакавыя кнопкі:"
|
STR_STEP_HINT_SIDE: "Бакавыя кнопкі:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Папка для спампоўкі"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Фармат назвы файла"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Аўтар - Назва"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Назва - Аўтар"
|
||||||
|
STR_FMT_TITLE: "Назва"
|
||||||
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
STR_AUTO_TURN_ENABLED: "Аўтаперагортванне: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
||||||
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
_language_name: "Bosanski"
|
||||||
|
_language_code: "BS"
|
||||||
|
_order: "29"
|
||||||
|
STR_CROSSPOINT: "CrossPoint"
|
||||||
|
STR_BOOTING: "POKRETANJE"
|
||||||
|
STR_SLEEPING: "REŽIM MIROVANJA"
|
||||||
|
STR_ENTERING_SLEEP: "Prelazak u režim mirovanja"
|
||||||
|
STR_BROWSE_FILES: "Pregled datoteka"
|
||||||
|
STR_FILE_TRANSFER: "Prijenos datoteka"
|
||||||
|
STR_SETTINGS_TITLE: "Postavke"
|
||||||
|
STR_CONTINUE_READING: "Nastavi čitanje"
|
||||||
|
STR_NO_OPEN_BOOK: "Nema otvorene knjige"
|
||||||
|
STR_START_READING: "Počni čitati ispod"
|
||||||
|
STR_NO_FILES_FOUND: "Nema pronađenih datoteka"
|
||||||
|
STR_SELECT_CHAPTER: "Odaberi poglavlje"
|
||||||
|
STR_NO_CHAPTERS: "Nema poglavlja"
|
||||||
|
STR_END_OF_BOOK: "Kraj knjige"
|
||||||
|
STR_EMPTY_CHAPTER: "Prazno poglavlje"
|
||||||
|
STR_INDEXING: "Indeksiranje"
|
||||||
|
STR_INDEX_FAILED: "Indeksiranje nije uspjelo - nevažeća knjiga"
|
||||||
|
STR_MEMORY_ERROR: "Greška memorije"
|
||||||
|
STR_PAGE_LOAD_ERROR: "Greška pri učitavanju stranice"
|
||||||
|
STR_EMPTY_FILE: "Prazna datoteka"
|
||||||
|
STR_OUT_OF_BOUNDS: "Izvan granica"
|
||||||
|
STR_LOADING: "Učitavanje..."
|
||||||
|
STR_LOADING_POPUP: "Učitavanje"
|
||||||
|
STR_WIFI_NETWORKS: "Wi-Fi mreže"
|
||||||
|
STR_NO_NETWORKS: "Nema pronađenih mreža"
|
||||||
|
STR_NETWORKS_FOUND: "%zu pronađenih mreža"
|
||||||
|
STR_SCANNING: "Skeniranje..."
|
||||||
|
STR_FINDING_SAVED_WIFI: "Traženje sačuvanog Wi-Fi-a..."
|
||||||
|
STR_CONNECTING: "Povezivanje..."
|
||||||
|
STR_CONNECTING_SAVED_WIFI: "Povezivanje na sačuvani Wi-Fi..."
|
||||||
|
STR_SHOW_NETWORKS: "Prikaži"
|
||||||
|
STR_CONNECTED: "Povezano!"
|
||||||
|
STR_CONNECTION_FAILED: "Povezivanje nije uspjelo"
|
||||||
|
STR_FORGET_NETWORK: "Zaboraviti mrežu?"
|
||||||
|
STR_SAVE_PASSWORD: "Sačuvati lozinku za sljedeći put?"
|
||||||
|
STR_PRESS_OK_SCAN: "Pritisni OK za ponovno skeniranje"
|
||||||
|
STR_JOIN_NETWORK: "Pridruži se mreži"
|
||||||
|
STR_CREATE_HOTSPOT: "Kreiraj hotspot"
|
||||||
|
STR_JOIN_DESC: "Poveži se na postojeću Wi-Fi mrežu"
|
||||||
|
STR_HOTSPOT_DESC: "Kreiraj Wi-Fi mrežu kojoj se drugi mogu pridružiti"
|
||||||
|
STR_STARTING_HOTSPOT: "Pokretanje hotspota..."
|
||||||
|
STR_HOTSPOT_MODE: "Hotspot način rada"
|
||||||
|
STR_CONNECT_WIFI_HINT: "Poveži svoj uređaj na ovu Wi-Fi mrežu"
|
||||||
|
STR_OPEN_URL_HINT: "Otvori ovaj URL u svom pregledniku"
|
||||||
|
STR_OR_HTTP_PREFIX: "ili http://"
|
||||||
|
STR_SCAN_QR_HINT: "ili skeniraj QR kod svojim telefonom:"
|
||||||
|
STR_CALIBRE_WIRELESS: "Calibre bežično"
|
||||||
|
STR_NETWORK_LEGEND: "* = Šifrovano | + = Sačuvano"
|
||||||
|
STR_MAC_ADDRESS: "MAC adresa:"
|
||||||
|
STR_CHECKING_WIFI: "Provjera Wi-Fi-a..."
|
||||||
|
STR_ENTER_WIFI_PASSWORD: "Unesi Wi-Fi lozinku"
|
||||||
|
STR_ADD_HIDDEN_NETWORK: "Dodaj skrivenu mrežu..."
|
||||||
|
STR_ENTER_WIFI_SSID: "Unesi naziv mreže (SSID)"
|
||||||
|
STR_TO_PREFIX: "na "
|
||||||
|
STR_CALIBRE_RECEIVING: "Primanje: "
|
||||||
|
STR_CALIBRE_RECEIVED: "Primljeno: "
|
||||||
|
STR_CALIBRE_INSTRUCTION_1: "1) Instaliraj CrossPoint Reader dodatak"
|
||||||
|
STR_CALIBRE_INSTRUCTION_2: "2) Budi na istoj Wi-Fi mreži"
|
||||||
|
STR_CALIBRE_INSTRUCTION_3: "3) U Calibreu: \"Pošalji na uređaj\""
|
||||||
|
STR_CALIBRE_INSTRUCTION_4: "\"Drži ovaj ekran otvorenim tokom slanja\""
|
||||||
|
STR_CAT_DISPLAY: "Ekran"
|
||||||
|
STR_CAT_READER: "Čitač"
|
||||||
|
STR_CAT_CONTROLS: "Kontrole"
|
||||||
|
STR_CAT_SYSTEM: "Sistem"
|
||||||
|
STR_SLEEP_SCREEN: "Ekran mirovanja"
|
||||||
|
STR_QUICK_RESUME_TIMEOUT: "Brzi nastavak nakon isteka vremena"
|
||||||
|
STR_SLEEP_COVER_MODE: "Način prikaza korica na ekranu mirovanja"
|
||||||
|
STR_HIDE_BATTERY: "Sakrij % baterije"
|
||||||
|
STR_EXTRA_SPACING: "Dodatni razmak između paragrafa"
|
||||||
|
STR_TEXT_AA: "Zaglađivanje teksta"
|
||||||
|
STR_IMAGES: "Slike"
|
||||||
|
STR_IMAGES_DISPLAY: "Prikaz"
|
||||||
|
STR_IMAGES_PLACEHOLDER: "Rezervisano mjesto"
|
||||||
|
STR_IMAGES_SUPPRESS: "Sakrij"
|
||||||
|
STR_EOB_HOME: "Početna"
|
||||||
|
STR_EOB_CONTINUE_WITH: "Nastavi sa"
|
||||||
|
STR_SHORT_PWR_BTN: "Kratak klik dugmeta napajanja"
|
||||||
|
STR_ORIENTATION: "Orijentacija čitanja"
|
||||||
|
STR_SIDE_BTN_LAYOUT: "Raspored bočnih dugmadi (čitač)"
|
||||||
|
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orijentiši prednja dugmad"
|
||||||
|
STR_LONG_PRESS_BEHAVIOR: "Ponašanje dugog pritiska dugmeta"
|
||||||
|
STR_LONG_PRESS_BEHAVIOR_OFF: "ISKLJUČENO"
|
||||||
|
STR_LONG_PRESS_BEHAVIOR_SKIP: "Preskakanje poglavlja"
|
||||||
|
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Promjena orijentacije"
|
||||||
|
STR_LONG_PRESS_MENU: "Meni dugog pritiska"
|
||||||
|
STR_FONT_PREVIEW_TEXT: "Brza smeđa lisica preskače lijenog psa"
|
||||||
|
STR_FONT_FAMILY: "Porodica fonta čitača"
|
||||||
|
STR_FONT_SIZE: "Veličina fonta čitača"
|
||||||
|
STR_LINE_SPACING: "Razmak između redova čitača"
|
||||||
|
STR_SCREEN_MARGIN: "Margina ekrana čitača"
|
||||||
|
STR_PARA_ALIGNMENT: "Poravnanje paragrafa čitača"
|
||||||
|
STR_HYPHENATION: "Rastavljanje riječi"
|
||||||
|
STR_TIME_TO_SLEEP: "Vrijeme do mirovanja"
|
||||||
|
STR_SHOW_HIDDEN_FILES: "Prikaži skrivene datoteke"
|
||||||
|
STR_REMOVE_READ_FROM_RECENTS: "Ukloni pročitane knjige sa liste nedavnih"
|
||||||
|
STR_MOVE_FINISHED_TO_READ: "Premjesti završene knjige u fasciklu Read"
|
||||||
|
STR_REFRESH_FREQ: "Učestalost osvježavanja"
|
||||||
|
STR_KOREADER_SYNC: "KOReader sinhronizacija"
|
||||||
|
STR_CHECK_UPDATES: "Provjeri ažuriranja"
|
||||||
|
STR_LANGUAGE: "Jezik"
|
||||||
|
STR_CLEAR_READING_CACHE: "Očisti keš čitanja"
|
||||||
|
STR_USERNAME: "Korisničko ime"
|
||||||
|
STR_PASSWORD: "Lozinka"
|
||||||
|
STR_SYNC_SERVER_URL: "URL servera za sinhronizaciju"
|
||||||
|
STR_DOCUMENT_MATCHING: "Podudaranje dokumenata"
|
||||||
|
STR_SEND_METADATA: "Pošalji metapodatke dokumenta"
|
||||||
|
STR_AUTHENTICATE: "Autentifikuj"
|
||||||
|
STR_KOREADER_USERNAME: "KOReader korisničko ime"
|
||||||
|
STR_KOREADER_PASSWORD: "KOReader lozinka"
|
||||||
|
STR_FILENAME: "Naziv datoteke"
|
||||||
|
STR_BINARY: "Binarno"
|
||||||
|
STR_SET_CREDENTIALS_FIRST: "Prvo postavi vjerodajnice"
|
||||||
|
STR_WIFI_CONN_FAILED: "Wi-Fi povezivanje nije uspjelo"
|
||||||
|
STR_AUTHENTICATING: "Autentifikacija..."
|
||||||
|
STR_AUTH_SUCCESS: "Uspješno autentifikovano!"
|
||||||
|
STR_KOREADER_AUTH: "KOReader autentifikacija"
|
||||||
|
STR_SYNC_READY: "KOReader sinhronizacija je spremna za upotrebu"
|
||||||
|
STR_AUTH_FAILED: "Autentifikacija nije uspjela"
|
||||||
|
STR_DONE: "Gotovo"
|
||||||
|
STR_CLEAR_CACHE_WARNING_1: "Ovo će očistiti sve keširane podatke knjiga."
|
||||||
|
STR_CLEAR_CACHE_WARNING_2: "Sav napredak čitanja će biti izgubljen!"
|
||||||
|
STR_CLEAR_CACHE_WARNING_3: "Knjige će morati biti ponovo indeksirane"
|
||||||
|
STR_CLEAR_CACHE_WARNING_4: "kada se ponovo otvore."
|
||||||
|
STR_CLEARING_CACHE: "Čišćenje keša..."
|
||||||
|
STR_CACHE_CLEARED: "Keš očišćen"
|
||||||
|
STR_ITEMS_REMOVED: "stavki uklonjeno"
|
||||||
|
STR_FAILED_LOWER: "nije uspjelo"
|
||||||
|
STR_CLEAR_CACHE_FAILED: "Čišćenje keša nije uspjelo"
|
||||||
|
STR_CHECK_SERIAL_OUTPUT: "Provjeri serijski izlaz za detalje"
|
||||||
|
STR_DARK: "Tamno"
|
||||||
|
STR_LIGHT: "Svijetlo"
|
||||||
|
STR_CUSTOM: "Prilagođeno"
|
||||||
|
STR_COVER: "Korice"
|
||||||
|
STR_NONE_OPT: "Ništa"
|
||||||
|
STR_FIT: "Prilagodi"
|
||||||
|
STR_CROP: "Isjeci"
|
||||||
|
STR_NEVER: "Nikad"
|
||||||
|
STR_IN_READER: "U čitaču"
|
||||||
|
STR_ALWAYS: "Uvijek"
|
||||||
|
STR_IGNORE: "Ignoriši"
|
||||||
|
STR_SLEEP: "Mirovanje"
|
||||||
|
STR_PAGE_TURN: "Okretanje stranice"
|
||||||
|
STR_FORCE_REFRESH: "Osvježi ekran"
|
||||||
|
STR_PORTRAIT: "Uspravno"
|
||||||
|
STR_LANDSCAPE_CW: "Položeno (u smjeru kazaljke na satu)"
|
||||||
|
STR_INVERTED: "Obrnuto"
|
||||||
|
STR_ORIENTATION_INVERTED: "Uspravno 180°"
|
||||||
|
STR_LANDSCAPE_CCW: "Položeno (suprotno smjeru kazaljke na satu)"
|
||||||
|
STR_PREV_NEXT: "Prethodno/Sljedeće"
|
||||||
|
STR_NEXT_PREV: "Sljedeće/Prethodno"
|
||||||
|
STR_KOSYNC: "KOSync"
|
||||||
|
STR_BOOKMARK_OPTION: "Obilježivač"
|
||||||
|
STR_DISABLED: "Onemogućeno"
|
||||||
|
STR_NOTO_SERIF: "Noto Serif"
|
||||||
|
STR_NOTO_SANS: "Noto Sans"
|
||||||
|
STR_SMALL: "Malo"
|
||||||
|
STR_MEDIUM: "Srednje"
|
||||||
|
STR_LARGE: "Veliko"
|
||||||
|
STR_X_LARGE: "Vrlo veliko"
|
||||||
|
STR_TIGHT: "Usko"
|
||||||
|
STR_NORMAL: "Normalno"
|
||||||
|
STR_WIDE: "Široko"
|
||||||
|
STR_JUSTIFY: "Poravnaj obostrano"
|
||||||
|
STR_ALIGN_LEFT: "Lijevo"
|
||||||
|
STR_CENTER: "Centrirano"
|
||||||
|
STR_ALIGN_RIGHT: "Desno"
|
||||||
|
STR_PAGES_1: "1 stranica"
|
||||||
|
STR_PAGES_5: "5 stranica"
|
||||||
|
STR_PAGES_10: "10 stranica"
|
||||||
|
STR_PAGES_15: "15 stranica"
|
||||||
|
STR_PAGES_30: "30 stranica"
|
||||||
|
STR_UPDATE: "Ažuriraj"
|
||||||
|
STR_CHECKING_UPDATE: "Provjera ažuriranja..."
|
||||||
|
STR_NEW_UPDATE: "Dostupno je novo ažuriranje!"
|
||||||
|
STR_CURRENT_VERSION: "Trenutna verzija: "
|
||||||
|
STR_NEW_VERSION: "Nova verzija: "
|
||||||
|
STR_UPDATING: "Ažuriranje..."
|
||||||
|
STR_NO_UPDATE: "Nema dostupnih ažuriranja"
|
||||||
|
STR_UPDATE_FAILED: "Ažuriranje nije uspjelo"
|
||||||
|
STR_UPDATE_COMPLETE: "Ažuriranje završeno"
|
||||||
|
STR_POWER_ON_HINT: "Pritisni i drži dugme napajanja da ponovo uključiš"
|
||||||
|
STR_RESTARTING_HINT: "Ponovno pokretanje... Ako se uređaj ne pokrene ponovo, drži dugme napajanja nekoliko sekundi."
|
||||||
|
STR_NO_ENTRIES: "Nema pronađenih unosa"
|
||||||
|
STR_DOWNLOADING: "Preuzimanje..."
|
||||||
|
STR_DOWNLOAD_FAILED: "Preuzimanje nije uspjelo"
|
||||||
|
STR_ERROR_MSG: "Greška:"
|
||||||
|
STR_UNNAMED: "Neimenovano"
|
||||||
|
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_NETWORK_PREFIX: "Mreža: "
|
||||||
|
STR_IP_ADDRESS_PREFIX: "IP adresa: "
|
||||||
|
STR_ERROR_GENERAL_FAILURE: "Greška: Opšta greška"
|
||||||
|
STR_ERROR_NETWORK_NOT_FOUND: "Greška: Mreža nije pronađena"
|
||||||
|
STR_ERROR_CONNECTION_TIMEOUT: "Greška: Isteklo vrijeme veze"
|
||||||
|
STR_SD_CARD: "SD kartica"
|
||||||
|
STR_BACK: "« Nazad"
|
||||||
|
STR_EXIT: "« Izlaz"
|
||||||
|
STR_HOME: "« Početna"
|
||||||
|
STR_SELECT: "Odaberi"
|
||||||
|
STR_SELECTED: "Odabrano"
|
||||||
|
STR_TOGGLE: "Promijeni"
|
||||||
|
STR_TOGGLE_BOOKMARK: "Promijeni obilježivač"
|
||||||
|
STR_CONFIRM: "Potvrdi"
|
||||||
|
STR_CANCEL: "Otkaži"
|
||||||
|
STR_CONNECT: "Poveži"
|
||||||
|
STR_OPEN: "Otvori"
|
||||||
|
STR_DOWNLOAD: "Preuzmi"
|
||||||
|
STR_RETRY: "Pokušaj ponovo"
|
||||||
|
STR_YES: "Da"
|
||||||
|
STR_NO: "Ne"
|
||||||
|
STR_SHOW: "Prikaži"
|
||||||
|
STR_HIDE: "Sakrij"
|
||||||
|
STR_STATE_ON: "UKLJUČENO"
|
||||||
|
STR_STATE_OFF: "ISKLJUČENO"
|
||||||
|
STR_NOT_SET: "Nije postavljeno"
|
||||||
|
STR_DIR_LEFT: "Lijevo"
|
||||||
|
STR_DIR_RIGHT: "Desno"
|
||||||
|
STR_DIR_UP: "Gore"
|
||||||
|
STR_DIR_DOWN: "Dolje"
|
||||||
|
STR_OK_BUTTON: "OK"
|
||||||
|
STR_SLEEP_COVER_FILTER: "Filter korica ekrana mirovanja"
|
||||||
|
STR_FILTER_CONTRAST: "Kontrast"
|
||||||
|
STR_CUSTOMISE_STATUS_BAR: "Prilagodi statusnu traku"
|
||||||
|
STR_CHAPTER_PAGE_COUNT: "Broj stranica poglavlja"
|
||||||
|
STR_BOOK_PROGRESS_PERCENTAGE: "Procenat napretka knjige"
|
||||||
|
STR_PROGRESS_BAR: "Traka napretka"
|
||||||
|
STR_PROGRESS_BAR_THICKNESS: "Debljina trake napretka"
|
||||||
|
STR_PROGRESS_BAR_THIN: "Tanka"
|
||||||
|
STR_PROGRESS_BAR_MEDIUM: "Srednja"
|
||||||
|
STR_PROGRESS_BAR_THICK: "Debela"
|
||||||
|
STR_BOOK: "Knjiga"
|
||||||
|
STR_CHAPTER: "Poglavlje"
|
||||||
|
STR_EXAMPLE_CHAPTER: "Poglavlje 21"
|
||||||
|
STR_EXAMPLE_BOOK: "Naslov knjige"
|
||||||
|
STR_PREVIEW: "Pregled"
|
||||||
|
STR_TITLE: "Naslov"
|
||||||
|
STR_BATTERY: "Baterija"
|
||||||
|
STR_XTC_STATUS_BAR: "XTC statusna traka"
|
||||||
|
STR_BOTTOM: "Dolje"
|
||||||
|
STR_TOP: "Gore"
|
||||||
|
STR_CLOCK: "Sat"
|
||||||
|
STR_CLOCK_UTC_OFFSET: "UTC odstupanje sata"
|
||||||
|
STR_CLOCK_FORMAT: "Format sata"
|
||||||
|
STR_CLOCK_FORMAT_24H: "24-časovni"
|
||||||
|
STR_CLOCK_FORMAT_12H: "12-časovni"
|
||||||
|
STR_CURRENT_TIME: "Trenutno vrijeme:"
|
||||||
|
STR_NEXT_FIELD: "Sljedeće"
|
||||||
|
STR_CLOCK_SYNC: "Sinhronizuj sat"
|
||||||
|
STR_CLOCK_SYNC_NOW: "Sinhronizuj sat sada"
|
||||||
|
STR_CLOCK_SYNCING: "Sinhronizacija sa NTP-a..."
|
||||||
|
STR_CLOCK_SYNC_OK: "Sat sinhronizovan"
|
||||||
|
STR_CLOCK_SYNC_FAIL: "Sinhronizacija nije uspjela"
|
||||||
|
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi nije povezan"
|
||||||
|
STR_CLOCK_SYNC_NO_WIFI_HINT: "Prvo se poveži na Wi-Fi, pa pokušaj ponovo."
|
||||||
|
STR_CLOCK_SYNCED: "Sat sinhronizovan"
|
||||||
|
STR_UI_THEME: "Tema korisničkog interfejsa"
|
||||||
|
STR_THEME_CLASSIC: "Klasična"
|
||||||
|
STR_THEME_LYRA: "Lyra"
|
||||||
|
STR_THEME_ROUNDEDRAFF: "RoundedRaff"
|
||||||
|
STR_THEME_LYRA_EXTENDED: "Lyra proširena"
|
||||||
|
STR_SUNLIGHT_FADING_FIX: "Ispravka blijeđenja na suncu"
|
||||||
|
STR_REMAP_FRONT_BUTTONS: "Premapiraj prednja dugmad"
|
||||||
|
STR_BOOKMARKS: "Obilježivači"
|
||||||
|
STR_BOOKMARK_ADDED: "Obilježivač dodan."
|
||||||
|
STR_BOOKMARK_REMOVED: "Obilježivač uklonjen."
|
||||||
|
STR_OPDS_BROWSER: "OPDS pregledač"
|
||||||
|
STR_SEARCH: "Pretraga"
|
||||||
|
STR_COVER_CUSTOM: "Korice + Prilagođeno"
|
||||||
|
STR_QUICK_RESUME: "Brzi nastavak"
|
||||||
|
STR_MENU_RECENT_BOOKS: "Nedavne knjige"
|
||||||
|
STR_REMOVE_FROM_RECENTS: "Ukloniti iz nedavnih knjiga?"
|
||||||
|
STR_NO_RECENT_BOOKS: "Nema nedavnih knjiga"
|
||||||
|
STR_CALIBRE_DESC: "Koristi Calibre bežični prijenos uređaja"
|
||||||
|
STR_FORGET_AND_REMOVE: "Zaboraviti mrežu i ukloniti sačuvanu lozinku?"
|
||||||
|
STR_FORGET_BUTTON: "Zaboravi"
|
||||||
|
STR_CALIBRE_STARTING: "Pokretanje Calibrea..."
|
||||||
|
STR_CALIBRE_SETUP: "Podešavanje"
|
||||||
|
STR_CALIBRE_STATUS: "Status"
|
||||||
|
STR_CLEAR_BUTTON: "Očisti"
|
||||||
|
STR_DEFAULT_VALUE: "Zadano"
|
||||||
|
STR_REMAP_PROMPT: "Pritisni prednje dugme za svaku ulogu"
|
||||||
|
STR_UNASSIGNED: "Nedodijeljeno"
|
||||||
|
STR_ALREADY_ASSIGNED: "Već dodijeljeno"
|
||||||
|
STR_REMAP_RESET_HINT: "Bočno dugme Gore: Vrati na zadani raspored"
|
||||||
|
STR_REMAP_CANCEL_HINT: "Bočno dugme Dolje: Otkaži premapiranje"
|
||||||
|
STR_HW_BACK_LABEL: "Nazad (1. dugme)"
|
||||||
|
STR_HW_CONFIRM_LABEL: "Potvrdi (2. dugme)"
|
||||||
|
STR_HW_LEFT_LABEL: "Lijevo (3. dugme)"
|
||||||
|
STR_HW_RIGHT_LABEL: "Desno (4. dugme)"
|
||||||
|
STR_GO_TO_PERCENT: "Idi na %"
|
||||||
|
STR_GO_HOME_BUTTON: "Idi na početnu"
|
||||||
|
STR_SYNC_PROGRESS: "Sinhronizuj napredak"
|
||||||
|
STR_DELETE_CACHE: "Obriši keš knjige"
|
||||||
|
STR_DELETE: "Obriši"
|
||||||
|
STR_CONFIRM_DELETE_BOOKMARK: "Obrisati ovaj obilježivač?"
|
||||||
|
STR_DISPLAY_QR: "Prikaži stranicu kao QR"
|
||||||
|
STR_CHAPTER_PREFIX: "Poglavlje: "
|
||||||
|
STR_PAGES_SEPARATOR: " stranica | "
|
||||||
|
STR_BOOK_PREFIX: "Knjiga: "
|
||||||
|
STR_CALIBRE_URL_HINT: "Za Calibre, dodaj /opds na svoj URL"
|
||||||
|
STR_SYNCING_TIME: "Sinhronizacija vremena..."
|
||||||
|
STR_CALC_HASH: "Izračunavanje heša dokumenta..."
|
||||||
|
STR_HASH_FAILED: "Izračunavanje heša dokumenta nije uspjelo"
|
||||||
|
STR_FETCH_PROGRESS: "Dohvatanje udaljenog napretka..."
|
||||||
|
STR_UPLOAD_PROGRESS: "Slanje napretka..."
|
||||||
|
STR_NO_CREDENTIALS_MSG: "Nisu podešene vjerodajnice"
|
||||||
|
STR_KOREADER_SETUP_HINT: "Podesi KOReader nalog u Postavkama"
|
||||||
|
STR_PROGRESS_FOUND: "Napredak pronađen!"
|
||||||
|
STR_REMOTE_LABEL: "Udaljeno:"
|
||||||
|
STR_LOCAL_LABEL: "Lokalno:"
|
||||||
|
STR_PAGE_OVERALL_FORMAT: "Stranica %d, %.2f%% ukupno"
|
||||||
|
STR_PAGE_TOTAL_OVERALL_FORMAT: "Stranica %d/%d, %.2f%% ukupno"
|
||||||
|
STR_DEVICE_FROM_FORMAT: " Sa: %s"
|
||||||
|
STR_APPLY_REMOTE: "Primijeni udaljeni napredak"
|
||||||
|
STR_UPLOAD_LOCAL: "Pošalji lokalni napredak"
|
||||||
|
STR_NO_REMOTE_MSG: "Nije pronađen udaljeni napredak"
|
||||||
|
STR_UPLOAD_PROMPT: "Poslati trenutnu poziciju?"
|
||||||
|
STR_UPLOAD_SUCCESS: "Napredak poslan!"
|
||||||
|
STR_SYNC_FAILED_MSG: "Sinhronizacija nije uspjela"
|
||||||
|
STR_SAVE_PROGRESS_FAILED: "Napredak nije mogao biti sačuvan"
|
||||||
|
STR_SECTION_PREFIX: "Sekcija "
|
||||||
|
STR_UPLOAD: "Pošalji"
|
||||||
|
STR_BOOK_S_STYLE: "Stil knjige"
|
||||||
|
STR_EMBEDDED_STYLE: "Ugrađeni stil"
|
||||||
|
STR_FOCUS_READING: "Fokusirano čitanje"
|
||||||
|
STR_OPDS_SERVER_URL: "URL OPDS servera"
|
||||||
|
STR_PWR_BTN_FOOTNOTE_BACK: "Brzi povratak iz fusnota"
|
||||||
|
STR_SET_SLEEP_COVER: "Postavi korice"
|
||||||
|
STR_FOOTNOTES: "Fusnote"
|
||||||
|
STR_NO_FOOTNOTES: "Nema fusnota na ovoj stranici"
|
||||||
|
STR_LINK: "[link]"
|
||||||
|
STR_SCREENSHOT_BUTTON: "Snimi ekran"
|
||||||
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
|
STR_SLEEP_NEVER: "Nikad"
|
||||||
|
STR_STEP_HINT_FRONT: "Prednja dugmad:"
|
||||||
|
STR_STEP_HINT_SIDE: "Bočna dugmad:"
|
||||||
|
STR_ADD_SERVER: "Dodaj server"
|
||||||
|
STR_SERVER_NAME: "Naziv servera"
|
||||||
|
STR_NO_SERVERS: "Nema podešenih OPDS servera"
|
||||||
|
STR_DELETE_SERVER: "Obriši server"
|
||||||
|
STR_OPDS_SERVERS: "OPDS serveri"
|
||||||
|
STR_AUTO_TURN_ENABLED: "Automatsko okretanje omogućeno: "
|
||||||
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatsko okretanje (stranica po minuti)"
|
||||||
|
STR_MANAGE_FONTS: "Upravljaj fontovima"
|
||||||
|
STR_FONT_BROWSER: "Pregledač fontova"
|
||||||
|
STR_LOADING_FONT_LIST: "Učitavanje liste fontova..."
|
||||||
|
STR_NO_FONTS_AVAILABLE: "Nema dostupnih fontova"
|
||||||
|
STR_FONT_INSTALLED: "Font instaliran!"
|
||||||
|
STR_FONT_INSTALL_FAILED: "Instalacija fonta nije uspjela"
|
||||||
|
STR_INSTALLED: "Instalirano"
|
||||||
|
STR_DOWNLOAD_ALL: "Preuzmi sve"
|
||||||
|
STR_UPDATE_ALL: "Ažuriraj sve"
|
||||||
|
STR_UPDATE_AVAILABLE: "Ažuriranje"
|
||||||
|
STR_CRASH_TITLE: "Sistemski pad"
|
||||||
|
STR_CRASH_DESCRIPTION: "Detaljan izvještaj je sačuvan u crash_report.txt. Molimo priloži ovu datoteku u svoj izvještaj o grešci."
|
||||||
|
STR_CRASH_REASON: "Razlog pada:"
|
||||||
|
STR_CRASH_NO_REASON: "(Razlog nije zabilježen)"
|
||||||
|
STR_TILT_PAGE_TURN: "Okretanje stranice naginjanjem"
|
||||||
|
STR_KB_HINT_MOVE_CURSOR: "Pritisni LIJEVO ili DESNO za pomjeranje kursora"
|
||||||
|
STR_KB_HINT_RETURN_CURSOR: "Pritisni LIJEVO za povratak na poziciju kursora"
|
||||||
|
STR_KB_HINT_HIDE_PASSWORD: "Drži DESNO pa pritisni [***] za skrivanje lozinke"
|
||||||
|
STR_KB_HINT_SHOW_PASSWORD: "Drži DESNO pa pritisni [abc] za prikaz lozinke"
|
||||||
|
STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Pritisni [***] za skrivanje lozinke"
|
||||||
|
STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Pritisni [abc] za prikaz lozinke"
|
||||||
|
STR_KB_HINT_EDIT_ENTRY: "Drži GORE za uređivanje unosa"
|
||||||
|
STR_KB_TIPS: "Savjeti:"
|
||||||
|
STR_KB_HINT_RETURN_KEYBOARD: "Pritisni DOLJE za povratak na tastaturu"
|
||||||
|
STR_KB_HINT_EXIT_URL_MODE: "Pritisni ABC za izlazak iz URL načina"
|
||||||
|
STR_KB_HINT_CLEAR_TEXT: "Drži DEL za brisanje cijelog teksta"
|
||||||
|
STR_KB_HINT_SECONDARY_CHAR: "Drži SELECT za sekundarni znak"
|
||||||
|
STR_KB_HINT_UPPER_SECONDARY: "Drži SELECT za VELIKA SLOVA ili sekundarni znak"
|
||||||
|
STR_KB_HINT_LOWER_SECONDARY: "Drži SELECT za mala slova ili sekundarni znak"
|
||||||
|
STR_KB_HINT_URL_SNIPPETS: "Pritisni URL za isječke"
|
||||||
|
STR_SD_FIRMWARE_UPDATE: "Ažuriranje firmvera sa SD kartice"
|
||||||
|
STR_SELECT_FIRMWARE_FILE: "Odaberi datoteku firmvera (.bin)"
|
||||||
|
STR_NO_BIN_FILES: "Nema pronađenih .bin datoteka"
|
||||||
|
STR_VALIDATING_FIRMWARE: "Provjera firmvera..."
|
||||||
|
STR_INVALID_FIRMWARE: "Nevažeća datoteka firmvera"
|
||||||
|
STR_FIRMWARE_TOO_LARGE: "Firmver je prevelik za particiju"
|
||||||
|
STR_FIRMWARE_TOO_SMALL: "Datoteka firmvera je premala"
|
||||||
|
STR_FIRMWARE_UPDATE_PROMPT: "Ažurirati firmver?"
|
||||||
|
STR_FIRMWARE_FILE_OPEN_FAILED: "Datoteka se ne može otvoriti"
|
||||||
|
STR_FIRMWARE_WRITE_FAILED: "Upisivanje firmvera nije uspjelo"
|
||||||
|
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Ne isključuj napajanje!"
|
||||||
|
STR_RECOVERY_MODE: "Način oporavka"
|
||||||
|
STR_RECOVERY_MODE_HINT: "Stavi firmware.bin u korijenski direktorij SD kartice i odaberi ga"
|
||||||
@@ -347,6 +347,11 @@ STR_SERVER_NAME: "Nom del servidor"
|
|||||||
STR_NO_SERVERS: "No hi ha servidors OPDS configurats"
|
STR_NO_SERVERS: "No hi ha servidors OPDS configurats"
|
||||||
STR_DELETE_SERVER: "Suprimeix el servidor"
|
STR_DELETE_SERVER: "Suprimeix el servidor"
|
||||||
STR_OPDS_SERVERS: "Servidors OPDS"
|
STR_OPDS_SERVERS: "Servidors OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Carpeta de baixades"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Format del nom de fitxer"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Títol"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Títol - Autor"
|
||||||
|
STR_FMT_TITLE: "Títol"
|
||||||
STR_MANAGE_FONTS: "Gestiona les fonts"
|
STR_MANAGE_FONTS: "Gestiona les fonts"
|
||||||
STR_FONT_BROWSER: "Navegador de fonts"
|
STR_FONT_BROWSER: "Navegador de fonts"
|
||||||
STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..."
|
STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..."
|
||||||
|
|||||||
@@ -270,6 +270,11 @@ STR_BOOK_S_STYLE: "Styl knihy"
|
|||||||
STR_EMBEDDED_STYLE: "Vložený styl"
|
STR_EMBEDDED_STYLE: "Vložený styl"
|
||||||
STR_FOCUS_READING: "Soustředěné čtení"
|
STR_FOCUS_READING: "Soustředěné čtení"
|
||||||
STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Složka pro stahování"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Formát názvu souboru"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Název"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Název - Autor"
|
||||||
|
STR_FMT_TITLE: "Název"
|
||||||
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
STR_SCREENSHOT_BUTTON: "Udělat snímek obrazovky"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Nikdy"
|
STR_SLEEP_NEVER: "Nikdy"
|
||||||
|
|||||||
@@ -300,6 +300,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
|||||||
STR_SLEEP_NEVER: "Aldrig"
|
STR_SLEEP_NEVER: "Aldrig"
|
||||||
STR_STEP_HINT_FRONT: "Frontknapper:"
|
STR_STEP_HINT_FRONT: "Frontknapper:"
|
||||||
STR_STEP_HINT_SIDE: "Sideknapper:"
|
STR_STEP_HINT_SIDE: "Sideknapper:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Downloadmappe"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Filnavnsformat"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Forfatter - Titel"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Titel - Forfatter"
|
||||||
|
STR_FMT_TITLE: "Titel"
|
||||||
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
STR_SCREENSHOT_BUTTON: "Tag skærmbillede"
|
||||||
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
STR_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
||||||
|
|||||||
@@ -300,6 +300,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
|||||||
STR_SLEEP_NEVER: "Nooit"
|
STR_SLEEP_NEVER: "Nooit"
|
||||||
STR_STEP_HINT_FRONT: "Voorknoppen:"
|
STR_STEP_HINT_FRONT: "Voorknoppen:"
|
||||||
STR_STEP_HINT_SIDE: "Zijknoppen:"
|
STR_STEP_HINT_SIDE: "Zijknoppen:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Downloadmap"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Bestandsnaamformaat"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Auteur - Titel"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Titel - Auteur"
|
||||||
|
STR_FMT_TITLE: "Titel"
|
||||||
STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
STR_SCREENSHOT_BUTTON: "Screenshot maken"
|
||||||
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
STR_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ STR_BROWSE_FILES: "Browse Files"
|
|||||||
STR_FILE_TRANSFER: "File Transfer"
|
STR_FILE_TRANSFER: "File Transfer"
|
||||||
STR_SETTINGS_TITLE: "Settings"
|
STR_SETTINGS_TITLE: "Settings"
|
||||||
STR_CONTINUE_READING: "Continue Reading"
|
STR_CONTINUE_READING: "Continue Reading"
|
||||||
|
STR_RESUME: "Resume"
|
||||||
STR_NO_OPEN_BOOK: "No open book"
|
STR_NO_OPEN_BOOK: "No open book"
|
||||||
STR_START_READING: "Start reading below"
|
STR_START_READING: "Start reading below"
|
||||||
STR_NO_FILES_FOUND: "No files found"
|
STR_NO_FILES_FOUND: "No files found"
|
||||||
@@ -108,7 +109,15 @@ STR_PASSWORD: "Password"
|
|||||||
STR_SYNC_SERVER_URL: "Sync Server URL"
|
STR_SYNC_SERVER_URL: "Sync Server URL"
|
||||||
STR_DOCUMENT_MATCHING: "Document Matching"
|
STR_DOCUMENT_MATCHING: "Document Matching"
|
||||||
STR_SEND_METADATA: "Send Document Metadata"
|
STR_SEND_METADATA: "Send Document Metadata"
|
||||||
|
STR_SYNC_BEHAVIOR: "Sync Behavior"
|
||||||
|
STR_ASK_EVERY_TIME: "Ask every time"
|
||||||
|
STR_SMART_SYNC: "Smart sync"
|
||||||
STR_AUTHENTICATE: "Authenticate"
|
STR_AUTHENTICATE: "Authenticate"
|
||||||
|
STR_SIGN_UP: "Sign Up"
|
||||||
|
STR_CREATING_ACCOUNT: "Creating account..."
|
||||||
|
STR_ACCOUNT_CREATED: "Account created"
|
||||||
|
STR_SIGNUP_FAILED: "Sign up failed"
|
||||||
|
STR_USERNAME_TAKEN: "Username is already registered"
|
||||||
STR_KOREADER_USERNAME: "KOReader Username"
|
STR_KOREADER_USERNAME: "KOReader Username"
|
||||||
STR_KOREADER_PASSWORD: "KOReader Password"
|
STR_KOREADER_PASSWORD: "KOReader Password"
|
||||||
STR_FILENAME: "Filename"
|
STR_FILENAME: "Filename"
|
||||||
@@ -229,7 +238,7 @@ STR_OK_BUTTON: "OK"
|
|||||||
STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter"
|
STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter"
|
||||||
STR_FILTER_CONTRAST: "Contrast"
|
STR_FILTER_CONTRAST: "Contrast"
|
||||||
STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar"
|
STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar"
|
||||||
STR_CHAPTER_PAGE_COUNT: "Page Count"
|
STR_CHAPTER_PAGE_COUNT: "Chapter Page Count"
|
||||||
STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage"
|
STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage"
|
||||||
STR_PROGRESS_BAR: "Progress Bar"
|
STR_PROGRESS_BAR: "Progress Bar"
|
||||||
STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness"
|
STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness"
|
||||||
@@ -324,6 +333,7 @@ STR_UPLOAD_LOCAL: "Upload local progress"
|
|||||||
STR_NO_REMOTE_MSG: "No remote progress found"
|
STR_NO_REMOTE_MSG: "No remote progress found"
|
||||||
STR_UPLOAD_PROMPT: "Upload current position?"
|
STR_UPLOAD_PROMPT: "Upload current position?"
|
||||||
STR_UPLOAD_SUCCESS: "Progress uploaded!"
|
STR_UPLOAD_SUCCESS: "Progress uploaded!"
|
||||||
|
STR_ALREADY_SYNCED: "Already synced"
|
||||||
STR_SYNC_FAILED_MSG: "Sync failed"
|
STR_SYNC_FAILED_MSG: "Sync failed"
|
||||||
STR_SAVE_PROGRESS_FAILED: "Could not save progress"
|
STR_SAVE_PROGRESS_FAILED: "Could not save progress"
|
||||||
STR_SECTION_PREFIX: "Section "
|
STR_SECTION_PREFIX: "Section "
|
||||||
@@ -333,6 +343,7 @@ STR_EMBEDDED_STYLE: "Embedded Style"
|
|||||||
STR_FOCUS_READING: "Focus Reading"
|
STR_FOCUS_READING: "Focus Reading"
|
||||||
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
||||||
STR_PWR_BTN_FOOTNOTE_BACK: "Quick-return from footnotes"
|
STR_PWR_BTN_FOOTNOTE_BACK: "Quick-return from footnotes"
|
||||||
|
STR_BACK_SHORT_TO_FILE_BROWSER: "Short Back to File Browser"
|
||||||
STR_SET_SLEEP_COVER: "Set Cover"
|
STR_SET_SLEEP_COVER: "Set Cover"
|
||||||
STR_FOOTNOTES: "Footnotes"
|
STR_FOOTNOTES: "Footnotes"
|
||||||
STR_NO_FOOTNOTES: "No footnotes on this page"
|
STR_NO_FOOTNOTES: "No footnotes on this page"
|
||||||
@@ -347,6 +358,12 @@ STR_SERVER_NAME: "Server Name"
|
|||||||
STR_NO_SERVERS: "No OPDS servers configured"
|
STR_NO_SERVERS: "No OPDS servers configured"
|
||||||
STR_DELETE_SERVER: "Delete Server"
|
STR_DELETE_SERVER: "Delete Server"
|
||||||
STR_OPDS_SERVERS: "OPDS Servers"
|
STR_OPDS_SERVERS: "OPDS Servers"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Download folder"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Filename format"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Author - Title"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Title - Author"
|
||||||
|
STR_FMT_TITLE: "Title"
|
||||||
|
STR_OPDS_SD_ROOT: "SD root"
|
||||||
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
|
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
|
||||||
STR_MANAGE_FONTS: "Manage Fonts"
|
STR_MANAGE_FONTS: "Manage Fonts"
|
||||||
|
|||||||
@@ -268,6 +268,11 @@ STR_BOOK_S_STYLE: "Kirjan tyyli"
|
|||||||
STR_EMBEDDED_STYLE: "Upotettu tyyli"
|
STR_EMBEDDED_STYLE: "Upotettu tyyli"
|
||||||
STR_FOCUS_READING: "Keskittynyt lukeminen"
|
STR_FOCUS_READING: "Keskittynyt lukeminen"
|
||||||
STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
|
STR_OPDS_SERVER_URL: "OPDS-palvelimen osoite"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Latauskansio"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Tiedostonimen muoto"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Tekijä - Nimi"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Nimi - Tekijä"
|
||||||
|
STR_FMT_TITLE: "Nimi"
|
||||||
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
|
STR_SCREENSHOT_BUTTON: "Ota kuvakaappaus"
|
||||||
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||||
STR_SLEEP_NEVER: "Ei koskaan"
|
STR_SLEEP_NEVER: "Ei koskaan"
|
||||||
|
|||||||
@@ -301,6 +301,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
|||||||
STR_SLEEP_NEVER: "Jamais"
|
STR_SLEEP_NEVER: "Jamais"
|
||||||
STR_STEP_HINT_FRONT: "Boutons avant :"
|
STR_STEP_HINT_FRONT: "Boutons avant :"
|
||||||
STR_STEP_HINT_SIDE: "Boutons latéraux :"
|
STR_STEP_HINT_SIDE: "Boutons latéraux :"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Dossier de téléchargement"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Format du nom de fichier"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Auteur - Titre"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Titre - Auteur"
|
||||||
|
STR_FMT_TITLE: "Titre"
|
||||||
STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
STR_SCREENSHOT_BUTTON: "Capture d'écran"
|
||||||
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
STR_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ STR_BOOK_S_STYLE: "Buch-Stil"
|
|||||||
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
||||||
STR_FOCUS_READING: "Fokus-Lesen"
|
STR_FOCUS_READING: "Fokus-Lesen"
|
||||||
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
|
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
|
||||||
|
STR_BACK_SHORT_TO_FILE_BROWSER: "Kurz zurück drücken zum Datei-Browser"
|
||||||
STR_SET_SLEEP_COVER: "Wähle Cover"
|
STR_SET_SLEEP_COVER: "Wähle Cover"
|
||||||
STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
|
STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
|
||||||
STR_FOOTNOTES: "Fußnoten"
|
STR_FOOTNOTES: "Fußnoten"
|
||||||
@@ -329,6 +330,11 @@ STR_SERVER_NAME: "Servername"
|
|||||||
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
||||||
STR_DELETE_SERVER: "Server entfernen"
|
STR_DELETE_SERVER: "Server entfernen"
|
||||||
STR_OPDS_SERVERS: "OPDS-Server"
|
STR_OPDS_SERVERS: "OPDS-Server"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Download-Ordner"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Dateinamenformat"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Titel"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Titel - Autor"
|
||||||
|
STR_FMT_TITLE: "Titel"
|
||||||
STR_AUTO_TURN_ENABLED: "Auto-Umblättern aktiv: "
|
STR_AUTO_TURN_ENABLED: "Auto-Umblättern aktiv: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-Umblättern (Seiten/Min.)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-Umblättern (Seiten/Min.)"
|
||||||
STR_IMAGES: "Bilder"
|
STR_IMAGES: "Bilder"
|
||||||
|
|||||||
@@ -306,6 +306,11 @@ STR_NO_SERVERS: "לא הוגדרו שרתי OPDS"
|
|||||||
STR_DELETE_SERVER: "מחק שרת"
|
STR_DELETE_SERVER: "מחק שרת"
|
||||||
STR_DELETE_CONFIRM: "למחוק שרת זה?"
|
STR_DELETE_CONFIRM: "למחוק שרת זה?"
|
||||||
STR_OPDS_SERVERS: "שרתי OPDS"
|
STR_OPDS_SERVERS: "שרתי OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "תיקיית הורדות"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "תבנית שם קובץ"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "מחבר - כותרת"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "כותרת - מחבר"
|
||||||
|
STR_FMT_TITLE: "כותרת"
|
||||||
STR_AUTO_TURN_ENABLED: "דפדוף אוטומטי פועל: "
|
STR_AUTO_TURN_ENABLED: "דפדוף אוטומטי פועל: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "דפדוף אוטומטי (דפים בדקה)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "דפדוף אוטומטי (דפים בדקה)"
|
||||||
STR_MANAGE_FONTS: "ניהול גופנים"
|
STR_MANAGE_FONTS: "ניהול גופנים"
|
||||||
|
|||||||
@@ -299,6 +299,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
|
|||||||
STR_SLEEP_NEVER: "Soha"
|
STR_SLEEP_NEVER: "Soha"
|
||||||
STR_STEP_HINT_FRONT: "Elülső gombok:"
|
STR_STEP_HINT_FRONT: "Elülső gombok:"
|
||||||
STR_STEP_HINT_SIDE: "Oldalsó gombok:"
|
STR_STEP_HINT_SIDE: "Oldalsó gombok:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Letöltési mappa"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Fájlnév formátuma"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Szerző - Cím"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Cím - Szerző"
|
||||||
|
STR_FMT_TITLE: "Cím"
|
||||||
STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
STR_SCREENSHOT_BUTTON: "Képernyőkép készítése"
|
||||||
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
STR_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
||||||
|
|||||||
@@ -315,6 +315,11 @@ STR_SERVER_NAME: "Nome server"
|
|||||||
STR_NO_SERVERS: "Nessun server OPDS configurato"
|
STR_NO_SERVERS: "Nessun server OPDS configurato"
|
||||||
STR_DELETE_SERVER: "Elimina server"
|
STR_DELETE_SERVER: "Elimina server"
|
||||||
STR_OPDS_SERVERS: "Server OPDS"
|
STR_OPDS_SERVERS: "Server OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Cartella download"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Formato nome file"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autore - Titolo"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Titolo - Autore"
|
||||||
|
STR_FMT_TITLE: "Titolo"
|
||||||
STR_AUTO_TURN_ENABLED: "Volta pagina automatico: "
|
STR_AUTO_TURN_ENABLED: "Volta pagina automatico: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Volta pagina automatico (pag/min)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Volta pagina automatico (pag/min)"
|
||||||
STR_MANAGE_FONTS: "Gestisci font"
|
STR_MANAGE_FONTS: "Gestisci font"
|
||||||
|
|||||||
@@ -296,6 +296,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
|||||||
STR_SLEEP_NEVER: "Ешқашан"
|
STR_SLEEP_NEVER: "Ешқашан"
|
||||||
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
|
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
|
||||||
STR_STEP_HINT_SIDE: "Бүйір түймелері:"
|
STR_STEP_HINT_SIDE: "Бүйір түймелері:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Жүктеп алу қалтасы"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Файл атауының пішімі"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Автор - Атауы"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Атауы - Автор"
|
||||||
|
STR_FMT_TITLE: "Атауы"
|
||||||
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
STR_SCREENSHOT_BUTTON: "Скриншот түсіру"
|
||||||
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
STR_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
||||||
|
|||||||
@@ -297,6 +297,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
|||||||
STR_SLEEP_NEVER: "Niekada"
|
STR_SLEEP_NEVER: "Niekada"
|
||||||
STR_STEP_HINT_FRONT: "Priekiniai mygtukai:"
|
STR_STEP_HINT_FRONT: "Priekiniai mygtukai:"
|
||||||
STR_STEP_HINT_SIDE: "Šoniniai mygtukai:"
|
STR_STEP_HINT_SIDE: "Šoniniai mygtukai:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Atsisiuntimų aplankas"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Failo pavadinimo formatas"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autorius - Pavadinimas"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Pavadinimas - Autorius"
|
||||||
|
STR_FMT_TITLE: "Pavadinimas"
|
||||||
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka"
|
||||||
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
STR_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
||||||
|
|||||||
@@ -316,6 +316,11 @@ STR_SERVER_NAME: "Nazwa serwera"
|
|||||||
STR_NO_SERVERS: "Brak skonfigurowanych serwerów OPDS"
|
STR_NO_SERVERS: "Brak skonfigurowanych serwerów OPDS"
|
||||||
STR_DELETE_SERVER: "Usuń serwer"
|
STR_DELETE_SERVER: "Usuń serwer"
|
||||||
STR_OPDS_SERVERS: "Serwery OPDS"
|
STR_OPDS_SERVERS: "Serwery OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Folder pobierania"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Format nazwy pliku"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Tytuł"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Tytuł - Autor"
|
||||||
|
STR_FMT_TITLE: "Tytuł"
|
||||||
STR_AUTO_TURN_ENABLED: "Auto-kartkowanie: "
|
STR_AUTO_TURN_ENABLED: "Auto-kartkowanie: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-kartkowanie (str./min)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-kartkowanie (str./min)"
|
||||||
STR_DOWNLOAD_FONTS: "Pobierz czcionki"
|
STR_DOWNLOAD_FONTS: "Pobierz czcionki"
|
||||||
|
|||||||
@@ -339,6 +339,11 @@ STR_SERVER_NAME: "Nome do Servidor"
|
|||||||
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
||||||
STR_DELETE_SERVER: "Excluir Servidor"
|
STR_DELETE_SERVER: "Excluir Servidor"
|
||||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Pasta de downloads"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Formato do nome do arquivo"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Título"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Título - Autor"
|
||||||
|
STR_FMT_TITLE: "Título"
|
||||||
STR_AUTO_TURN_ENABLED: "Virada automática ativada: "
|
STR_AUTO_TURN_ENABLED: "Virada automática ativada: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
|
||||||
STR_MANAGE_FONTS: "Gerenciar fontes"
|
STR_MANAGE_FONTS: "Gerenciar fontes"
|
||||||
|
|||||||
@@ -300,6 +300,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
|||||||
STR_SLEEP_NEVER: "Niciodată"
|
STR_SLEEP_NEVER: "Niciodată"
|
||||||
STR_STEP_HINT_FRONT: "Butoane frontale:"
|
STR_STEP_HINT_FRONT: "Butoane frontale:"
|
||||||
STR_STEP_HINT_SIDE: "Butoane laterale:"
|
STR_STEP_HINT_SIDE: "Butoane laterale:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Dosar descărcări"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Format nume fișier"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Titlu"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Titlu - Autor"
|
||||||
|
STR_FMT_TITLE: "Titlu"
|
||||||
STR_SCREENSHOT_BUTTON: "Captură ecran"
|
STR_SCREENSHOT_BUTTON: "Captură ecran"
|
||||||
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
STR_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
||||||
|
|||||||
@@ -339,6 +339,11 @@ STR_SERVER_NAME: "Имя сервера"
|
|||||||
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
||||||
STR_DELETE_SERVER: "Удалить сервер"
|
STR_DELETE_SERVER: "Удалить сервер"
|
||||||
STR_OPDS_SERVERS: "Серверы OPDS"
|
STR_OPDS_SERVERS: "Серверы OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Папка загрузок"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Формат имени файла"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Автор - Название"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Название - Автор"
|
||||||
|
STR_FMT_TITLE: "Название"
|
||||||
STR_AUTO_TURN_ENABLED: "Автоперелистывание: "
|
STR_AUTO_TURN_ENABLED: "Автоперелистывание: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)"
|
||||||
STR_MANAGE_FONTS: "Управление шрифтами"
|
STR_MANAGE_FONTS: "Управление шрифтами"
|
||||||
|
|||||||
@@ -335,6 +335,11 @@ STR_SERVER_NAME: "Názov servera"
|
|||||||
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
||||||
STR_DELETE_SERVER: "Odstrániť server"
|
STR_DELETE_SERVER: "Odstrániť server"
|
||||||
STR_OPDS_SERVERS: "OPDS servery"
|
STR_OPDS_SERVERS: "OPDS servery"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Priečinok sťahovania"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Formát názvu súboru"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Názov"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Názov - Autor"
|
||||||
|
STR_FMT_TITLE: "Názov"
|
||||||
STR_AUTO_TURN_ENABLED: "Automatické otáčanie strán zapnuté: "
|
STR_AUTO_TURN_ENABLED: "Automatické otáčanie strán zapnuté: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatické otáčanie (strán za minútu)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatické otáčanie (strán za minútu)"
|
||||||
STR_MANAGE_FONTS: "Správa písiem"
|
STR_MANAGE_FONTS: "Správa písiem"
|
||||||
|
|||||||
@@ -297,6 +297,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
|||||||
STR_SLEEP_NEVER: "Nikoli"
|
STR_SLEEP_NEVER: "Nikoli"
|
||||||
STR_STEP_HINT_FRONT: "Sprednji gumbi:"
|
STR_STEP_HINT_FRONT: "Sprednji gumbi:"
|
||||||
STR_STEP_HINT_SIDE: "Stranski gumbi:"
|
STR_STEP_HINT_SIDE: "Stranski gumbi:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Mapa za prenose"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Oblika imena datoteke"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Avtor - Naslov"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Naslov - Avtor"
|
||||||
|
STR_FMT_TITLE: "Naslov"
|
||||||
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
|
||||||
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
||||||
|
|||||||
@@ -345,6 +345,11 @@ STR_SERVER_NAME: "Nombre de servidor"
|
|||||||
STR_NO_SERVERS: "No se configuraron servidores OPDS"
|
STR_NO_SERVERS: "No se configuraron servidores OPDS"
|
||||||
STR_DELETE_SERVER: "Borrar servidor"
|
STR_DELETE_SERVER: "Borrar servidor"
|
||||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Carpeta de descargas"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Formato del nombre de archivo"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Título"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Título - Autor"
|
||||||
|
STR_FMT_TITLE: "Título"
|
||||||
STR_AUTO_TURN_ENABLED: "Avance activado: "
|
STR_AUTO_TURN_ENABLED: "Avance activado: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Avance auto. (pág./min)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Avance auto. (pág./min)"
|
||||||
STR_MANAGE_FONTS: "Gestionar tipografías"
|
STR_MANAGE_FONTS: "Gestionar tipografías"
|
||||||
|
|||||||
@@ -342,6 +342,11 @@ STR_SERVER_NAME: "Servernamn"
|
|||||||
STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade"
|
STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade"
|
||||||
STR_DELETE_SERVER: "Ta bort server"
|
STR_DELETE_SERVER: "Ta bort server"
|
||||||
STR_OPDS_SERVERS: "OPDS-servrar"
|
STR_OPDS_SERVERS: "OPDS-servrar"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Hämtningsmapp"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Filnamnsformat"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Författare - Titel"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Titel - Författare"
|
||||||
|
STR_FMT_TITLE: "Titel"
|
||||||
STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: "
|
STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)"
|
||||||
STR_MANAGE_FONTS: "Hantera teckensnitt"
|
STR_MANAGE_FONTS: "Hantera teckensnitt"
|
||||||
|
|||||||
@@ -291,6 +291,11 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
|
|||||||
STR_SLEEP_NEVER: "Asla"
|
STR_SLEEP_NEVER: "Asla"
|
||||||
STR_STEP_HINT_FRONT: "Ön tuşlar:"
|
STR_STEP_HINT_FRONT: "Ön tuşlar:"
|
||||||
STR_STEP_HINT_SIDE: "Yan tuşlar:"
|
STR_STEP_HINT_SIDE: "Yan tuşlar:"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "İndirme klasörü"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Dosya adı biçimi"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Yazar - Başlık"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Başlık - Yazar"
|
||||||
|
STR_FMT_TITLE: "Başlık"
|
||||||
STR_NO_FILES_FOUND: "Dosya bulunamadı"
|
STR_NO_FILES_FOUND: "Dosya bulunamadı"
|
||||||
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
|
STR_NO_FOOTNOTES: "Bu sayfada dipnot yok"
|
||||||
STR_PREVIEW: "Önizleme"
|
STR_PREVIEW: "Önizleme"
|
||||||
|
|||||||
@@ -336,6 +336,11 @@ STR_SERVER_NAME: "Назва сервера"
|
|||||||
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
||||||
STR_DELETE_SERVER: "Видалити сервер"
|
STR_DELETE_SERVER: "Видалити сервер"
|
||||||
STR_OPDS_SERVERS: "Сервери OPDS"
|
STR_OPDS_SERVERS: "Сервери OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Папка завантажень"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Формат імені файлу"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Автор - Назва"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Назва - Автор"
|
||||||
|
STR_FMT_TITLE: "Назва"
|
||||||
STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: "
|
STR_AUTO_TURN_ENABLED: "Автоперегортання увімк: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоперегортання (ст/хв)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Автоперегортання (ст/хв)"
|
||||||
STR_MANAGE_FONTS: "Керування шрифтами"
|
STR_MANAGE_FONTS: "Керування шрифтами"
|
||||||
|
|||||||
@@ -348,6 +348,11 @@ STR_SERVER_NAME: "Nom del servidor"
|
|||||||
STR_NO_SERVERS: "No hi ha servidors OPDS configurats"
|
STR_NO_SERVERS: "No hi ha servidors OPDS configurats"
|
||||||
STR_DELETE_SERVER: "Elimina el servidor"
|
STR_DELETE_SERVER: "Elimina el servidor"
|
||||||
STR_OPDS_SERVERS: "Servidors OPDS"
|
STR_OPDS_SERVERS: "Servidors OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Carpeta de baixades"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Format del nom de fitxer"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Autor - Títol"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Títol - Autor"
|
||||||
|
STR_FMT_TITLE: "Títol"
|
||||||
STR_MANAGE_FONTS: "Gestiona les fonts"
|
STR_MANAGE_FONTS: "Gestiona les fonts"
|
||||||
STR_FONT_BROWSER: "Navegador de fonts"
|
STR_FONT_BROWSER: "Navegador de fonts"
|
||||||
STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..."
|
STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..."
|
||||||
|
|||||||
@@ -335,6 +335,11 @@ STR_SERVER_NAME: "Tên máy chủ"
|
|||||||
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
||||||
STR_DELETE_SERVER: "Xóa máy chủ"
|
STR_DELETE_SERVER: "Xóa máy chủ"
|
||||||
STR_OPDS_SERVERS: "Máy chủ OPDS"
|
STR_OPDS_SERVERS: "Máy chủ OPDS"
|
||||||
|
STR_OPDS_DOWNLOAD_FOLDER: "Thư mục tải xuống"
|
||||||
|
STR_OPDS_FILENAME_FORMAT: "Định dạng tên tệp"
|
||||||
|
STR_FMT_AUTHOR_TITLE: "Tác giả - Tựa đề"
|
||||||
|
STR_FMT_TITLE_AUTHOR: "Tựa đề - Tác giả"
|
||||||
|
STR_FMT_TITLE: "Tựa đề"
|
||||||
STR_AUTO_TURN_ENABLED: "Tự lật trang: "
|
STR_AUTO_TURN_ENABLED: "Tự lật trang: "
|
||||||
STR_AUTO_TURN_PAGES_PER_MIN: "Tự lật (số trang mỗi phút)"
|
STR_AUTO_TURN_PAGES_PER_MIN: "Tự lật (số trang mỗi phút)"
|
||||||
STR_MANAGE_FONTS: "Quản lý phông chữ"
|
STR_MANAGE_FONTS: "Quản lý phông chữ"
|
||||||
|
|||||||
@@ -5,16 +5,27 @@
|
|||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// Default sync server URL
|
// Default sync server URL. crosspoint-sync speaks the full KOSync protocol, so
|
||||||
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
// pointing at any other kosync server (e.g. https://sync.koreader.rocks:443)
|
||||||
|
// still works via the custom server URL setting.
|
||||||
|
constexpr char DEFAULT_SERVER_URL[] = "https://sync.crosspointreader.com";
|
||||||
|
|
||||||
|
// Default before config version 2. Configs saved without a version stamp and an
|
||||||
|
// empty serverUrl were implicitly syncing here — they get pinned on upgrade.
|
||||||
|
constexpr char LEGACY_DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
||||||
|
|
||||||
|
// Bumped when a change to defaults would alter behavior for existing configs.
|
||||||
|
constexpr uint8_t CONFIG_VERSION = 2;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
||||||
|
doc["cfgVersion"] = CONFIG_VERSION;
|
||||||
doc["username"] = getUsername();
|
doc["username"] = getUsername();
|
||||||
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
||||||
doc["serverUrl"] = getServerUrl();
|
doc["serverUrl"] = getServerUrl();
|
||||||
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
|
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
|
||||||
doc["sendMetadata"] = getSendMetadata();
|
doc["sendMetadata"] = getSendMetadata();
|
||||||
|
doc["syncBehavior"] = static_cast<uint8_t>(getSyncBehavior());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||||
@@ -26,6 +37,19 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
|||||||
setCredentials(user, pass);
|
setCredentials(user, pass);
|
||||||
setServerUrl(doc["serverUrl"] | "");
|
setServerUrl(doc["serverUrl"] | "");
|
||||||
|
|
||||||
|
// The default server changed in config v2 (sync.koreader.rocks -> crosspoint-sync).
|
||||||
|
// A pre-v2 config with credentials and no explicit URL was actively syncing
|
||||||
|
// against the old default — pin that URL so the upgrade doesn't switch servers
|
||||||
|
// out from under the user. Fresh setups get the new default.
|
||||||
|
const uint8_t cfgVersion = doc["cfgVersion"] | (uint8_t)1;
|
||||||
|
if (cfgVersion < CONFIG_VERSION) {
|
||||||
|
if (getServerUrl().empty() && hasCredentials()) {
|
||||||
|
LOG_DBG("KRS", "Pre-v2 config used the old default server; pinning %s", LEGACY_DEFAULT_SERVER_URL);
|
||||||
|
setServerUrl(LEGACY_DEFAULT_SERVER_URL);
|
||||||
|
}
|
||||||
|
needsResave = true; // stamp cfgVersion so this migration runs once
|
||||||
|
}
|
||||||
|
|
||||||
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
||||||
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
||||||
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
||||||
@@ -35,6 +59,18 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
|||||||
}
|
}
|
||||||
setSendMetadata(doc["sendMetadata"] | false);
|
setSendMetadata(doc["sendMetadata"] | false);
|
||||||
|
|
||||||
|
const JsonVariantConst behaviorValue = doc["syncBehavior"];
|
||||||
|
const bool missingBehavior = behaviorValue.isNull();
|
||||||
|
uint8_t behavior = behaviorValue | static_cast<uint8_t>(KOReaderSyncBehavior::ASK_EVERY_TIME);
|
||||||
|
if (behavior <= static_cast<uint8_t>(KOReaderSyncBehavior::SMART)) {
|
||||||
|
setSyncBehavior(static_cast<KOReaderSyncBehavior>(behavior));
|
||||||
|
needsResave = needsResave || missingBehavior;
|
||||||
|
} else {
|
||||||
|
LOG_DBG("KRS", "Invalid syncBehavior %u in JSON, resetting to ASK_EVERY_TIME", behavior);
|
||||||
|
setSyncBehavior(KOReaderSyncBehavior::ASK_EVERY_TIME);
|
||||||
|
needsResave = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (needsResave) {
|
if (needsResave) {
|
||||||
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
||||||
saveToFile();
|
saveToFile();
|
||||||
@@ -105,3 +141,11 @@ void KOReaderCredentialStore::setSendMetadata(bool enabled) {
|
|||||||
sendMetadata = enabled;
|
sendMetadata = enabled;
|
||||||
LOG_DBG("KRS", "Set send metadata: %s", enabled ? "true" : "false");
|
LOG_DBG("KRS", "Set send metadata: %s", enabled ? "true" : "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void KOReaderCredentialStore::setSyncBehavior(KOReaderSyncBehavior behavior) {
|
||||||
|
if (static_cast<uint8_t>(behavior) > static_cast<uint8_t>(KOReaderSyncBehavior::SMART)) {
|
||||||
|
behavior = KOReaderSyncBehavior::ASK_EVERY_TIME;
|
||||||
|
}
|
||||||
|
syncBehavior = behavior;
|
||||||
|
LOG_DBG("KRS", "Set sync behavior: %s", behavior == KOReaderSyncBehavior::SMART ? "Smart" : "Ask");
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ enum class DocumentMatchMethod : uint8_t {
|
|||||||
BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical)
|
BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// How manual "Sync Progress" resolves differences after fetching remote progress.
|
||||||
|
enum class KOReaderSyncBehavior : uint8_t {
|
||||||
|
ASK_EVERY_TIME = 0, // Preserve legacy behavior: always show Apply/Upload choices.
|
||||||
|
SMART = 1, // Auto-resolve simple cases using furthest progress.
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton class for storing KOReader sync credentials on the SD card.
|
* Singleton class for storing KOReader sync credentials on the SD card.
|
||||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||||
@@ -25,6 +31,7 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
|
|||||||
std::string serverUrl; // Custom sync server URL (empty = default)
|
std::string serverUrl; // Custom sync server URL (empty = default)
|
||||||
DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility
|
DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility
|
||||||
bool sendMetadata = false; // Send document metadata with progress sync
|
bool sendMetadata = false; // Send document metadata with progress sync
|
||||||
|
KOReaderSyncBehavior syncBehavior = KOReaderSyncBehavior::SMART;
|
||||||
|
|
||||||
// Private constructor for singleton
|
// Private constructor for singleton
|
||||||
KOReaderCredentialStore() = default;
|
KOReaderCredentialStore() = default;
|
||||||
@@ -65,6 +72,10 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
|
|||||||
// Send metadata setting
|
// Send metadata setting
|
||||||
void setSendMetadata(bool enabled);
|
void setSendMetadata(bool enabled);
|
||||||
bool getSendMetadata() const { return sendMetadata; }
|
bool getSendMetadata() const { return sendMetadata; }
|
||||||
|
|
||||||
|
// Sync behavior
|
||||||
|
void setSyncBehavior(KOReaderSyncBehavior behavior);
|
||||||
|
KOReaderSyncBehavior getSyncBehavior() const { return syncBehavior; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper macro to access credential store
|
// Helper macro to access credential store
|
||||||
|
|||||||
@@ -78,6 +78,43 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
|||||||
return SERVER_ERROR;
|
return SERVER_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KOReaderSyncClient::Error KOReaderSyncClient::createUser() {
|
||||||
|
lastHttpCode = 0;
|
||||||
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
|
LOG_DBG("KOSync", "No credentials configured");
|
||||||
|
return NO_CREDENTIALS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string url = KOREADER_STORE.getBaseUrl() + "/users/create";
|
||||||
|
LOG_DBG("KOSync", "Creating account: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
|
||||||
|
if (insufficientHeap()) return LOW_MEMORY;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["username"] = KOREADER_STORE.getUsername();
|
||||||
|
doc["password"] = KOREADER_STORE.getMd5Password();
|
||||||
|
std::string body;
|
||||||
|
serializeJson(doc, body);
|
||||||
|
|
||||||
|
freeink::SecureHttpClient http;
|
||||||
|
http.setInsecure();
|
||||||
|
if (!http.begin(url)) {
|
||||||
|
LOG_ERR("KOSync", "Bad URL: %s", url.c_str());
|
||||||
|
return NETWORK_ERROR;
|
||||||
|
}
|
||||||
|
http.addHeader("Accept", "application/vnd.koreader.v1+json");
|
||||||
|
http.addHeader("Content-Type", "application/json");
|
||||||
|
const int httpCode = http.sendRequest("POST", body);
|
||||||
|
http.end();
|
||||||
|
lastHttpCode = httpCode;
|
||||||
|
|
||||||
|
LOG_DBG("KOSync", "Create user response: %d", httpCode);
|
||||||
|
|
||||||
|
if (httpCode <= 0) return NETWORK_ERROR;
|
||||||
|
if (httpCode == 200 || httpCode == 201) return OK;
|
||||||
|
if (httpCode == 402) return USER_EXISTS;
|
||||||
|
return SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
|
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
|
||||||
KOReaderProgress& outProgress) {
|
KOReaderProgress& outProgress) {
|
||||||
lastHttpCode = 0;
|
lastHttpCode = 0;
|
||||||
@@ -124,6 +161,24 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
|||||||
outProgress.deviceId = doc["device_id"].as<std::string>();
|
outProgress.deviceId = doc["device_id"].as<std::string>();
|
||||||
outProgress.timestamp = doc["timestamp"].as<int64_t>();
|
outProgress.timestamp = doc["timestamp"].as<int64_t>();
|
||||||
|
|
||||||
|
// Extended crosspoint-sync field; absent on plain kosync servers.
|
||||||
|
outProgress.position.reset();
|
||||||
|
const JsonObjectConst pos = doc["position"].as<JsonObjectConst>();
|
||||||
|
if (!pos.isNull()) {
|
||||||
|
KOReaderRichPosition rich;
|
||||||
|
rich.pctQ = pos["pctQ"].as<uint32_t>();
|
||||||
|
rich.spineIndex = pos["spine"].as<uint16_t>();
|
||||||
|
rich.pageNumber = pos["page"].as<uint16_t>();
|
||||||
|
const uint16_t pages = pos["pages"].as<uint16_t>();
|
||||||
|
rich.totalPages = pages > 0 ? pages : 1;
|
||||||
|
const uint16_t para = pos["para"].as<uint16_t>();
|
||||||
|
if (para > 0) rich.paragraphIndex = para;
|
||||||
|
rich.xpath = pos["xpath"].as<const char*>() ? pos["xpath"].as<const char*>() : "";
|
||||||
|
LOG_DBG("KOSync", "Got rich position: spine=%u page=%u/%u para=%u", rich.spineIndex, rich.pageNumber,
|
||||||
|
rich.totalPages, para);
|
||||||
|
outProgress.position = std::move(rich);
|
||||||
|
}
|
||||||
|
|
||||||
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
|
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
|
||||||
return OK;
|
return OK;
|
||||||
}
|
}
|
||||||
@@ -158,6 +213,18 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
|
|||||||
doc["percentage"] = progress.percentage;
|
doc["percentage"] = progress.percentage;
|
||||||
doc["device"] = DEVICE_NAME;
|
doc["device"] = DEVICE_NAME;
|
||||||
doc["device_id"] = DEVICE_ID;
|
doc["device_id"] = DEVICE_ID;
|
||||||
|
if (progress.position.has_value()) {
|
||||||
|
// Extended crosspoint-sync field; kosync servers ignore unknown keys.
|
||||||
|
const auto& p = *progress.position;
|
||||||
|
auto pos = doc["position"].to<JsonObject>();
|
||||||
|
pos["pctQ"] = p.pctQ;
|
||||||
|
pos["spine"] = p.spineIndex;
|
||||||
|
pos["page"] = p.pageNumber;
|
||||||
|
pos["pages"] = p.totalPages;
|
||||||
|
if (p.paragraphIndex.has_value()) pos["para"] = *p.paragraphIndex;
|
||||||
|
// Server rejects the whole position object if xpath exceeds 120 bytes.
|
||||||
|
if (!p.xpath.empty() && p.xpath.size() <= 120) pos["xpath"] = p.xpath;
|
||||||
|
}
|
||||||
|
|
||||||
std::string body;
|
std::string body;
|
||||||
serializeJson(doc, body);
|
serializeJson(doc, body);
|
||||||
|
|||||||
@@ -14,17 +14,33 @@ struct KOReaderMetadata {
|
|||||||
std::string authors; // Author(s) from EPUB metadata
|
std::string authors; // Author(s) from EPUB metadata
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich CrossPoint position sent alongside progress uploads. Maps 1:1 onto the
|
||||||
|
* crosspoint-sync extended `position` object (see crosspoint-sync docs/API.md).
|
||||||
|
* The official KOSync server ignores unknown fields; crosspoint-sync stores it
|
||||||
|
* so CrossPoint<->CrossPoint sync is lossless instead of xpath-approximated.
|
||||||
|
*/
|
||||||
|
struct KOReaderRichPosition {
|
||||||
|
uint32_t pctQ = 0; // Percentage quantized 0..1,000,000 (authoritative)
|
||||||
|
uint16_t spineIndex = 0; // Spine (chapter) index
|
||||||
|
uint16_t pageNumber = 0; // Page within spine (layout-dependent hint)
|
||||||
|
uint16_t totalPages = 1; // Spine page count (layout-dependent hint)
|
||||||
|
std::optional<uint16_t> paragraphIndex; // Synthetic 1-based paragraph index
|
||||||
|
std::string xpath; // KOReader-style xpath (server cap: 120 bytes)
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress data from KOReader sync server.
|
* Progress data from KOReader sync server.
|
||||||
*/
|
*/
|
||||||
struct KOReaderProgress {
|
struct KOReaderProgress {
|
||||||
std::string document; // Document hash
|
std::string document; // Document hash
|
||||||
std::string progress; // XPath-like progress string
|
std::string progress; // XPath-like progress string
|
||||||
float percentage; // Progress percentage (0.0 to 1.0)
|
float percentage; // Progress percentage (0.0 to 1.0)
|
||||||
std::string device; // Device name
|
std::string device; // Device name
|
||||||
std::string deviceId; // Device ID
|
std::string deviceId; // Device ID
|
||||||
int64_t timestamp; // Unix timestamp of last update
|
int64_t timestamp; // Unix timestamp of last update
|
||||||
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
||||||
|
std::optional<KOReaderRichPosition> position; // Optional rich position (crosspoint-sync servers only)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +59,17 @@ struct KOReaderProgress {
|
|||||||
*/
|
*/
|
||||||
class KOReaderSyncClient {
|
class KOReaderSyncClient {
|
||||||
public:
|
public:
|
||||||
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND, LOW_MEMORY };
|
enum Error {
|
||||||
|
OK = 0,
|
||||||
|
NO_CREDENTIALS,
|
||||||
|
NETWORK_ERROR,
|
||||||
|
AUTH_FAILED,
|
||||||
|
SERVER_ERROR,
|
||||||
|
JSON_ERROR,
|
||||||
|
NOT_FOUND,
|
||||||
|
LOW_MEMORY,
|
||||||
|
USER_EXISTS
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate with the sync server (validate credentials).
|
* Authenticate with the sync server (validate credentials).
|
||||||
@@ -51,6 +77,14 @@ class KOReaderSyncClient {
|
|||||||
*/
|
*/
|
||||||
static Error authenticate();
|
static Error authenticate();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new account on the sync server using the stored credentials
|
||||||
|
* (POST /users/create with the MD5 auth key — the server never sees the
|
||||||
|
* plain password).
|
||||||
|
* @return OK on success, USER_EXISTS if the username is taken
|
||||||
|
*/
|
||||||
|
static Error createUser();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get reading progress for a document.
|
* Get reading progress for a document.
|
||||||
* @param documentHash The document hash (from KOReaderDocumentId)
|
* @param documentHash The document hash (from KOReaderDocumentId)
|
||||||
|
|||||||
@@ -724,6 +724,59 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<CrossPointPosition> ProgressMapper::fromRichPosition(const std::shared_ptr<Epub>& epub,
|
||||||
|
const KOReaderRichPosition& rich,
|
||||||
|
GfxRenderer& renderer) {
|
||||||
|
const int spineCount = epub->getSpineItemsCount();
|
||||||
|
if (static_cast<int>(rich.spineIndex) >= spineCount) {
|
||||||
|
LOG_DBG("PM", "Rich position spine %u out of range (%d spine items)", rich.spineIndex, spineCount);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
CrossPointPosition result{};
|
||||||
|
result.spineIndex = rich.spineIndex;
|
||||||
|
|
||||||
|
Section tempSection(epub, result.spineIndex, renderer);
|
||||||
|
const auto cachedCount = tempSection.getCachedPageCount();
|
||||||
|
if (!cachedCount || *cachedCount <= 0) {
|
||||||
|
// No local layout for the target spine yet; the percentage/xpath mapping
|
||||||
|
// handles density estimation better than a blind copy of remote pages.
|
||||||
|
LOG_DBG("PM", "Rich position spine %u has no cached page count", rich.spineIndex);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
result.totalPages = *cachedCount;
|
||||||
|
|
||||||
|
const int remotePages = rich.totalPages > 0 ? rich.totalPages : 1;
|
||||||
|
if (result.totalPages == remotePages) {
|
||||||
|
// Identical layout (same render settings) — the page transfers losslessly.
|
||||||
|
result.pageNumber = std::min<int>(rich.pageNumber, result.totalPages - 1);
|
||||||
|
LOG_DBG("PM", "Rich position exact: spine=%d page=%d/%d", result.spineIndex, result.pageNumber, result.totalPages);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout differs; the paragraph LUT is the most accurate anchor we have.
|
||||||
|
if (rich.paragraphIndex.has_value()) {
|
||||||
|
const auto lutPage = tempSection.getPageForParagraphIndex(*rich.paragraphIndex);
|
||||||
|
if (lutPage.has_value()) {
|
||||||
|
result.paragraphIndex = *rich.paragraphIndex;
|
||||||
|
result.hasParagraphIndex = true;
|
||||||
|
result.pageNumber = std::min<int>(*lutPage, result.totalPages - 1);
|
||||||
|
LOG_DBG("PM", "Rich position para %u -> spine=%d page=%d/%d", *rich.paragraphIndex, result.spineIndex,
|
||||||
|
result.pageNumber, result.totalPages);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the intra-spine page fraction.
|
||||||
|
const float intra =
|
||||||
|
(remotePages > 1) ? static_cast<float>(rich.pageNumber) / static_cast<float>(remotePages - 1) : 0.0f;
|
||||||
|
result.pageNumber = std::max(
|
||||||
|
0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1));
|
||||||
|
LOG_DBG("PM", "Rich position scaled: spine=%d remote %u/%d -> page=%d/%d", result.spineIndex, rich.pageNumber,
|
||||||
|
remotePages, result.pageNumber, result.totalPages);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
|
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
|
||||||
GfxRenderer& renderer, int currentSpineIndex,
|
GfxRenderer& renderer, int currentSpineIndex,
|
||||||
int totalPagesInCurrentSpine, int fallbackTotalPages) {
|
int totalPagesInCurrentSpine, int fallbackTotalPages) {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "KOReaderSyncClient.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CrossPoint position representation.
|
* CrossPoint position representation.
|
||||||
*/
|
*/
|
||||||
@@ -65,6 +68,20 @@ class ProgressMapper {
|
|||||||
GfxRenderer& renderer, int currentSpineIndex = -1,
|
GfxRenderer& renderer, int currentSpineIndex = -1,
|
||||||
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
|
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a rich CrossPoint position (downloaded from a crosspoint-sync
|
||||||
|
* server) directly to a CrossPoint position, without XPath approximation.
|
||||||
|
* When the local layout matches the uploader's (same spine page count) the
|
||||||
|
* page transfers losslessly; otherwise the paragraph LUT or the intra-spine
|
||||||
|
* page fraction is used.
|
||||||
|
*
|
||||||
|
* @return The position, or std::nullopt when the rich position cannot be
|
||||||
|
* applied (spine out of range, no section cache) and the caller
|
||||||
|
* should fall back to toCrossPoint().
|
||||||
|
*/
|
||||||
|
static std::optional<CrossPointPosition> fromRichPosition(const std::shared_ptr<Epub>& epub,
|
||||||
|
const KOReaderRichPosition& rich, GfxRenderer& renderer);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
|
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
|
||||||
|
|||||||
+8
-19
@@ -225,29 +225,17 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
|||||||
|
|
||||||
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
|
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
|
||||||
|
|
||||||
void HalGPIO::startDeepSleep() {
|
bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
|
||||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
|
||||||
while (inputMgr.isPressed(BTN_POWER)) {
|
|
||||||
delay(50);
|
|
||||||
inputMgr.update();
|
|
||||||
}
|
|
||||||
// Arm the wakeup trigger *after* the button is released
|
|
||||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
|
||||||
// Enter Deep Sleep
|
|
||||||
esp_deep_sleep_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
|
|
||||||
if (shortPressAllowed) {
|
if (shortPressAllowed) {
|
||||||
// Fast path - no duration check needed
|
// Fast path - no duration check needed
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
// TODO: Intermittent edge case remains: a single tap followed by another single tap
|
// TODO: Intermittent edge case remains: a single tap followed by another single tap
|
||||||
// can still power on the device. Tighten wake debounce/state handling here.
|
// can still power on the device. Tighten wake debounce/state handling here.
|
||||||
|
|
||||||
// Calibrate: subtract boot time already elapsed, assuming button held since boot
|
// Calibrate: subtract boot time already elapsed, assuming button held since boot.
|
||||||
const uint16_t calibration = millis();
|
const unsigned long calibration = millis();
|
||||||
const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1;
|
const unsigned long calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1;
|
||||||
|
|
||||||
const auto start = millis();
|
const auto start = millis();
|
||||||
inputMgr.update();
|
inputMgr.update();
|
||||||
@@ -262,11 +250,12 @@ void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre
|
|||||||
inputMgr.update();
|
inputMgr.update();
|
||||||
} while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration);
|
} while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration);
|
||||||
if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) {
|
if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) {
|
||||||
startDeepSleep();
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
startDeepSleep();
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool HalGPIO::isUsbConnected() const {
|
bool HalGPIO::isUsbConnected() const {
|
||||||
|
|||||||
+2
-5
@@ -72,13 +72,10 @@ class HalGPIO {
|
|||||||
unsigned long getHeldTime() const;
|
unsigned long getHeldTime() const;
|
||||||
unsigned long getPowerButtonHeldTime() const;
|
unsigned long getPowerButtonHeldTime() const;
|
||||||
|
|
||||||
// Setup wake up GPIO and enter deep sleep
|
|
||||||
void startDeepSleep();
|
|
||||||
|
|
||||||
// Verify power button was held long enough after wakeup.
|
// Verify power button was held long enough after wakeup.
|
||||||
// If verification fails, enters deep sleep and does not return.
|
// Returns true if verification succeeded, false if device should return to sleep.
|
||||||
// Should only be called when wakeup reason is PowerButton.
|
// Should only be called when wakeup reason is PowerButton.
|
||||||
void verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed);
|
bool verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed);
|
||||||
|
|
||||||
// Check if USB is connected
|
// Check if USB is connected
|
||||||
bool isUsbConnected() const;
|
bool isUsbConnected() const;
|
||||||
|
|||||||
+13
-12
@@ -29,8 +29,8 @@ class CrossPointSettings {
|
|||||||
LIGHT = 1,
|
LIGHT = 1,
|
||||||
CUSTOM = 2,
|
CUSTOM = 2,
|
||||||
COVER = 3,
|
COVER = 3,
|
||||||
BLANK = 4,
|
COVER_CUSTOM = 4,
|
||||||
COVER_CUSTOM = 5,
|
BLANK = 5,
|
||||||
QUICK_RESUME = 6,
|
QUICK_RESUME = 6,
|
||||||
SLEEP_SCREEN_MODE_COUNT
|
SLEEP_SCREEN_MODE_COUNT
|
||||||
};
|
};
|
||||||
@@ -58,14 +58,6 @@ class CrossPointSettings {
|
|||||||
HIDE_PROGRESS = 2,
|
HIDE_PROGRESS = 2,
|
||||||
STATUS_BAR_PROGRESS_BAR_COUNT
|
STATUS_BAR_PROGRESS_BAR_COUNT
|
||||||
};
|
};
|
||||||
// Values 0/1 keep the meaning of the old show/hide toggle (persisted under the
|
|
||||||
// legacy "statusBarChapterPageCount" key), so existing settings files load as-is.
|
|
||||||
enum STATUS_BAR_PAGE_COUNT {
|
|
||||||
HIDE_PAGE_COUNT = 0,
|
|
||||||
CHAPTER_PAGE_COUNT = 1,
|
|
||||||
BOOK_PAGE_COUNT = 2,
|
|
||||||
STATUS_BAR_PAGE_COUNT_COUNT
|
|
||||||
};
|
|
||||||
enum STATUS_BAR_PROGRESS_BAR_THICKNESS {
|
enum STATUS_BAR_PROGRESS_BAR_THICKNESS {
|
||||||
PROGRESS_BAR_THIN = 0,
|
PROGRESS_BAR_THIN = 0,
|
||||||
PROGRESS_BAR_NORMAL = 1,
|
PROGRESS_BAR_NORMAL = 1,
|
||||||
@@ -197,8 +189,7 @@ class CrossPointSettings {
|
|||||||
uint8_t sleepScreenCoverFilter = NO_FILTER;
|
uint8_t sleepScreenCoverFilter = NO_FILTER;
|
||||||
// Status bar settings (statusBar retained for migration only)
|
// Status bar settings (statusBar retained for migration only)
|
||||||
uint8_t statusBar = FULL;
|
uint8_t statusBar = FULL;
|
||||||
// STATUS_BAR_PAGE_COUNT; persisted under the legacy "statusBarChapterPageCount" key.
|
uint8_t statusBarChapterPageCount = 1;
|
||||||
uint8_t statusBarPageCount = CHAPTER_PAGE_COUNT;
|
|
||||||
uint8_t statusBarBookProgressPercentage = 1;
|
uint8_t statusBarBookProgressPercentage = 1;
|
||||||
uint8_t statusBarProgressBar = HIDE_PROGRESS;
|
uint8_t statusBarProgressBar = HIDE_PROGRESS;
|
||||||
uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL;
|
uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL;
|
||||||
@@ -247,6 +238,14 @@ class CrossPointSettings {
|
|||||||
|
|
||||||
// Reader screen margin settings
|
// Reader screen margin settings
|
||||||
uint8_t screenMargin = 5;
|
uint8_t screenMargin = 5;
|
||||||
|
// OPDS download destination folder ("" = SD root). Global; edited from the
|
||||||
|
// OPDS server list. Persisted via a category-less SettingInfo::String in
|
||||||
|
// SettingsList.h, so it stays out of the on-device Settings screen.
|
||||||
|
char opdsDownloadFolder[64] = "";
|
||||||
|
// On-disk filename format for OPDS downloads (0=Author-Title default, 1=Title-Author,
|
||||||
|
// 2=Title). See OpdsFilenameFormat. Persisted via a category-less SettingInfo::Enum,
|
||||||
|
// edited from the OPDS server list; hidden from the on-device Settings screen.
|
||||||
|
uint8_t opdsFilenameFormat = 0;
|
||||||
// Hide battery percentage
|
// Hide battery percentage
|
||||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||||
// Long-press page turn button behavior
|
// Long-press page turn button behavior
|
||||||
@@ -272,6 +271,8 @@ class CrossPointSettings {
|
|||||||
uint8_t removeReadBooksFromRecents = 0;
|
uint8_t removeReadBooksFromRecents = 0;
|
||||||
// Move epub to /Read/ folder on SD card when finished (0 = disabled, 1 = enabled)
|
// Move epub to /Read/ folder on SD card when finished (0 = disabled, 1 = enabled)
|
||||||
uint8_t moveFinishedToReadFolder = 0;
|
uint8_t moveFinishedToReadFolder = 0;
|
||||||
|
// Short press Back goes to file browser instead of home (0 = disabled, 1 = enabled)
|
||||||
|
uint8_t backShortToFileBrowser = 0;
|
||||||
// Image rendering mode in EPUB reader
|
// Image rendering mode in EPUB reader
|
||||||
uint8_t imageRendering = IMAGES_DISPLAY;
|
uint8_t imageRendering = IMAGES_DISPLAY;
|
||||||
// Tilt-based page turning (X3 only — requires QMI8658 IMU)
|
// Tilt-based page turning (X3 only — requires QMI8658 IMU)
|
||||||
|
|||||||
@@ -21,35 +21,35 @@
|
|||||||
void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
|
void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
|
||||||
switch (static_cast<CrossPointSettings::STATUS_BAR_MODE>(settings.statusBar)) {
|
switch (static_cast<CrossPointSettings::STATUS_BAR_MODE>(settings.statusBar)) {
|
||||||
case CrossPointSettings::NONE:
|
case CrossPointSettings::NONE:
|
||||||
settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
|
settings.statusBarChapterPageCount = 0;
|
||||||
settings.statusBarBookProgressPercentage = 0;
|
settings.statusBarBookProgressPercentage = 0;
|
||||||
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
||||||
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
|
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
|
||||||
settings.statusBarBattery = 0;
|
settings.statusBarBattery = 0;
|
||||||
break;
|
break;
|
||||||
case CrossPointSettings::NO_PROGRESS:
|
case CrossPointSettings::NO_PROGRESS:
|
||||||
settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
|
settings.statusBarChapterPageCount = 0;
|
||||||
settings.statusBarBookProgressPercentage = 0;
|
settings.statusBarBookProgressPercentage = 0;
|
||||||
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
||||||
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
||||||
settings.statusBarBattery = 1;
|
settings.statusBarBattery = 1;
|
||||||
break;
|
break;
|
||||||
case CrossPointSettings::BOOK_PROGRESS_BAR:
|
case CrossPointSettings::BOOK_PROGRESS_BAR:
|
||||||
settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
|
settings.statusBarChapterPageCount = 1;
|
||||||
settings.statusBarBookProgressPercentage = 0;
|
settings.statusBarBookProgressPercentage = 0;
|
||||||
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
|
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
|
||||||
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
||||||
settings.statusBarBattery = 1;
|
settings.statusBarBattery = 1;
|
||||||
break;
|
break;
|
||||||
case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR:
|
case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR:
|
||||||
settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
|
settings.statusBarChapterPageCount = 1;
|
||||||
settings.statusBarBookProgressPercentage = 0;
|
settings.statusBarBookProgressPercentage = 0;
|
||||||
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
|
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
|
||||||
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
|
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
|
||||||
settings.statusBarBattery = 0;
|
settings.statusBarBattery = 0;
|
||||||
break;
|
break;
|
||||||
case CrossPointSettings::CHAPTER_PROGRESS_BAR:
|
case CrossPointSettings::CHAPTER_PROGRESS_BAR:
|
||||||
settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
|
settings.statusBarChapterPageCount = 0;
|
||||||
settings.statusBarBookProgressPercentage = 1;
|
settings.statusBarBookProgressPercentage = 1;
|
||||||
settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS;
|
settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS;
|
||||||
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
||||||
@@ -57,7 +57,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
|
|||||||
break;
|
break;
|
||||||
case CrossPointSettings::FULL:
|
case CrossPointSettings::FULL:
|
||||||
default:
|
default:
|
||||||
settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
|
settings.statusBarChapterPageCount = 1;
|
||||||
settings.statusBarBookProgressPercentage = 1;
|
settings.statusBarBookProgressPercentage = 1;
|
||||||
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
||||||
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
||||||
|
|||||||
+22
-4
@@ -181,6 +181,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
|||||||
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
||||||
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
|
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
|
||||||
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
|
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
|
||||||
|
SettingInfo::Toggle(StrId::STR_BACK_SHORT_TO_FILE_BROWSER, &CrossPointSettings::backShortToFileBrowser,
|
||||||
|
"backShortToFileBrowser", StrId::STR_CAT_CONTROLS),
|
||||||
|
|
||||||
// --- System ---
|
// --- System ---
|
||||||
SettingInfo::Value(
|
SettingInfo::Value(
|
||||||
@@ -194,6 +196,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
|||||||
SettingInfo::Toggle(StrId::STR_MOVE_FINISHED_TO_READ, &CrossPointSettings::moveFinishedToReadFolder,
|
SettingInfo::Toggle(StrId::STR_MOVE_FINISHED_TO_READ, &CrossPointSettings::moveFinishedToReadFolder,
|
||||||
"moveFinishedToReadFolder", StrId::STR_CAT_SYSTEM),
|
"moveFinishedToReadFolder", StrId::STR_CAT_SYSTEM),
|
||||||
|
|
||||||
|
// OPDS download folder: persisted + web-exposed, but category-less so it
|
||||||
|
// is hidden from the on-device Settings screen (edited via OPDS UI).
|
||||||
|
SettingInfo::String(StrId::STR_OPDS_DOWNLOAD_FOLDER, &SETTINGS.opdsDownloadFolder[0],
|
||||||
|
sizeof(SETTINGS.opdsDownloadFolder), "opdsDownloadFolder"),
|
||||||
|
// OPDS download filename format: persisted + web-exposed, category-less so it
|
||||||
|
// is hidden from the on-device Settings screen (cycled from the OPDS UI).
|
||||||
|
SettingInfo::Enum(StrId::STR_OPDS_FILENAME_FORMAT, &CrossPointSettings::opdsFilenameFormat,
|
||||||
|
{StrId::STR_FMT_AUTHOR_TITLE, StrId::STR_FMT_TITLE_AUTHOR, StrId::STR_FMT_TITLE},
|
||||||
|
"opdsFilenameFormat"),
|
||||||
|
|
||||||
// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
|
// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
|
||||||
SettingInfo::DynamicString(
|
SettingInfo::DynamicString(
|
||||||
StrId::STR_KOREADER_USERNAME, [] { return KOREADER_STORE.getUsername(); },
|
StrId::STR_KOREADER_USERNAME, [] { return KOREADER_STORE.getUsername(); },
|
||||||
@@ -232,11 +244,17 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
|||||||
KOREADER_STORE.saveToFile();
|
KOREADER_STORE.saveToFile();
|
||||||
},
|
},
|
||||||
"koSendMetadata", StrId::STR_KOREADER_SYNC),
|
"koSendMetadata", StrId::STR_KOREADER_SYNC),
|
||||||
|
SettingInfo::DynamicEnum(
|
||||||
|
StrId::STR_SYNC_BEHAVIOR, {StrId::STR_ASK_EVERY_TIME, StrId::STR_SMART_SYNC},
|
||||||
|
[] { return static_cast<uint8_t>(KOREADER_STORE.getSyncBehavior()); },
|
||||||
|
[](uint8_t v) {
|
||||||
|
KOREADER_STORE.setSyncBehavior(static_cast<KOReaderSyncBehavior>(v));
|
||||||
|
KOREADER_STORE.saveToFile();
|
||||||
|
},
|
||||||
|
"koSyncBehavior", StrId::STR_KOREADER_SYNC),
|
||||||
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
|
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
|
||||||
// Key kept from the old show/hide toggle so existing settings files load unchanged.
|
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount,
|
||||||
SettingInfo::Enum(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarPageCount,
|
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||||
{StrId::STR_HIDE, StrId::STR_CHAPTER, StrId::STR_BOOK}, "statusBarChapterPageCount",
|
|
||||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
|
||||||
SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage,
|
SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage,
|
||||||
"statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR),
|
"statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||||
SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar,
|
SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar,
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalStorage.h>
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <OpdsStream.h>
|
#include <OpdsStream.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "SilentRestart.h"
|
#include "SilentRestart.h"
|
||||||
#include "activities/network/WifiSelectionActivity.h"
|
#include "activities/network/WifiSelectionActivity.h"
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "network/HttpDownloader.h"
|
#include "network/HttpDownloader.h"
|
||||||
#include "util/BookCacheUtils.h"
|
#include "util/BookCacheUtils.h"
|
||||||
|
#include "util/OpdsFilename.h"
|
||||||
#include "util/StringUtils.h"
|
#include "util/StringUtils.h"
|
||||||
#include "util/UrlUtils.h"
|
#include "util/UrlUtils.h"
|
||||||
|
|
||||||
@@ -279,8 +282,26 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
|||||||
// Build full download URL relative to the current feed, not the root server URL
|
// Build full download URL relative to the current feed, not the root server URL
|
||||||
const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath);
|
const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath);
|
||||||
std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href);
|
std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href);
|
||||||
std::string filename =
|
// opdsDownloadFolder is already a null-terminated char[64]; use it directly —
|
||||||
"/" + StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) + ".epub";
|
// no std::string copy. exists()/mkdir() take const char*.
|
||||||
|
const char* folder = SETTINGS.opdsDownloadFolder; // "" => SD root
|
||||||
|
bool haveFolder = folder[0] != '\0';
|
||||||
|
if (haveFolder && !Storage.exists(folder) && !Storage.mkdir(folder)) {
|
||||||
|
// exists()-guard first: mkdir's return-on-existing is unconfirmed, and every
|
||||||
|
// existing caller checks exists() before mkdir. On real failure, fall back
|
||||||
|
// to SD root so the download is never lost.
|
||||||
|
LOG_ERR("OPDS", "mkdir failed for %s, using SD root", folder);
|
||||||
|
haveFolder = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadToFile() needs a std::string, and titles are unbounded (a fixed
|
||||||
|
// char[] would truncate). Cold path (a multi-second download follows), so one
|
||||||
|
// reserve'd, in-place-appended owning string is the right call.
|
||||||
|
std::string filename;
|
||||||
|
filename.reserve(96);
|
||||||
|
if (haveFolder) filename += folder;
|
||||||
|
filename += '/';
|
||||||
|
filename += opdsBookFilename(book.author, book.title, static_cast<OpdsFilenameFormat>(SETTINGS.opdsFilenameFormat));
|
||||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||||
|
|
||||||
int lastRenderedPercent = -1;
|
int lastRenderedPercent = -1;
|
||||||
|
|||||||
@@ -179,6 +179,17 @@ void HomeActivity::loop() {
|
|||||||
requestUpdate();
|
requestUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) backPressSeen = true;
|
||||||
|
|
||||||
|
// Back is otherwise unused on the home menu: open the most recently read
|
||||||
|
// book directly (recentBooks is most-recent-first and already pruned of
|
||||||
|
// files missing from the SD card). backPressSeen guards against the stale
|
||||||
|
// release of the Back press that closed the previous activity.
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && backPressSeen && !recentBooks.empty()) {
|
||||||
|
onSelectBook(recentBooks[0].path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
if (selectorIndex < recentBooks.size()) {
|
if (selectorIndex < recentBooks.size()) {
|
||||||
onSelectBook(recentBooks[selectorIndex].path);
|
onSelectBook(recentBooks[selectorIndex].path);
|
||||||
@@ -256,7 +267,8 @@ void HomeActivity::render(RenderLock&&) {
|
|||||||
[&menuItems](int index) { return std::string(menuItems[index]); },
|
[&menuItems](int index) { return std::string(menuItems[index]); },
|
||||||
[&menuIcons](int index) { return menuIcons[index]; });
|
[&menuIcons](int index) { return menuIcons[index]; });
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
const auto labels = mappedInput.mapLabels(recentBooks.empty() ? "" : tr(STR_RESUME), tr(STR_SELECT), tr(STR_DIR_UP),
|
||||||
|
tr(STR_DIR_DOWN));
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class HomeActivity final : public Activity {
|
|||||||
bool hasOpdsServers = false;
|
bool hasOpdsServers = false;
|
||||||
bool coverRendered = false; // Track if cover has been rendered once
|
bool coverRendered = false; // Track if cover has been rendered once
|
||||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
bool coverBufferStored = false; // Track if cover buffer is stored
|
||||||
|
// Home can be entered while Back is still held (e.g. leaving Settings with
|
||||||
|
// Back): ignore that stale release until a fresh press is seen here.
|
||||||
|
bool backPressSeen = false;
|
||||||
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
||||||
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
|
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
|
||||||
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
|
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "ReaderUtils.h"
|
#include "ReaderUtils.h"
|
||||||
|
// ReaderUtils.h pulls in ActivityManager.h, which only forward-declares Activity while holding
|
||||||
|
// std::unique_ptr<Activity> members. Destroying that unique_ptr needs the complete type, so the
|
||||||
|
// definition must be visible here.
|
||||||
|
#include "activities/Activity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
|
|||||||
@@ -44,22 +44,6 @@ constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
|
|||||||
constexpr size_t initialBookmarkCacheCapacity = 16;
|
constexpr size_t initialBookmarkCacheCapacity = 16;
|
||||||
constexpr float bookmarkProgressEpsilon = 0.0001f;
|
constexpr float bookmarkProgressEpsilon = 0.0001f;
|
||||||
|
|
||||||
// The render parameters the reader paginates with, as passed to loadSectionFile /
|
|
||||||
// createSectionFile / startBuild below. Used to validate other sections' cached
|
|
||||||
// page counts and to detect when a settings change invalidates the harvest.
|
|
||||||
Section::RenderParams readerRenderParams(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
|
||||||
return {SETTINGS.getReaderFontId(),
|
|
||||||
SETTINGS.getReaderLineCompression(),
|
|
||||||
static_cast<bool>(SETTINGS.extraParagraphSpacing),
|
|
||||||
SETTINGS.paragraphAlignment,
|
|
||||||
viewportWidth,
|
|
||||||
viewportHeight,
|
|
||||||
static_cast<bool>(SETTINGS.hyphenationEnabled),
|
|
||||||
static_cast<bool>(SETTINGS.embeddedStyle),
|
|
||||||
SETTINGS.imageRendering,
|
|
||||||
static_cast<bool>(SETTINGS.focusReadingEnabled)};
|
|
||||||
}
|
|
||||||
|
|
||||||
int clampPercent(int percent) {
|
int clampPercent(int percent) {
|
||||||
if (percent < 0) {
|
if (percent < 0) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -335,25 +319,6 @@ void EpubReaderActivity::loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whole-book page counter: harvest one other section's cached page count per tick
|
|
||||||
// (a header-only peek), so the total converges to exact without visiting every
|
|
||||||
// chapter. Idle-priority — skipped whenever a render is pending or a build is
|
|
||||||
// running. No re-render is requested; the counter refreshes on the next page turn.
|
|
||||||
if (bookPages && bookPagesSweepIndex < epub->getSpineItemsCount() && !RenderLock::peek() &&
|
|
||||||
!(section && section->isBuilding())) {
|
|
||||||
RenderLock lock;
|
|
||||||
// Re-check under the lock: render() may have just reset/reallocated the table.
|
|
||||||
if (bookPages && bookPagesSweepIndex < epub->getSpineItemsCount()) {
|
|
||||||
const int index = bookPagesSweepIndex++;
|
|
||||||
if (bookPages[index].pages < 0) {
|
|
||||||
const Section peekSection(epub, index, renderer);
|
|
||||||
if (const auto count = peekSection.getCachedPageCount(&bookPagesParams)) {
|
|
||||||
bookPages[index].pages = *count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is
|
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is
|
||||||
// finished. Two independent finished-book features key off this same condition.
|
// finished. Two independent finished-book features key off this same condition.
|
||||||
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
|
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
|
||||||
@@ -483,20 +448,15 @@ void EpubReaderActivity::loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long press BACK (1s+) goes to file selection
|
// Short press Back restores position when viewing a footnote (takes priority over navigation)
|
||||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
if (footnoteDepth > 0 && mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||||
activityManager.goToFileBrowser(epub ? epub->getPath() : "");
|
mappedInput.getHeldTime() < ReaderUtils::GO_BACK_OR_HOME_MS) {
|
||||||
|
restoreSavedPosition();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short press BACK goes directly to home (or restores position if viewing footnote)
|
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, epub ? epub->getPath().c_str() : "",
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
{this, [](void* ctx) { static_cast<EpubReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
|
||||||
if (footnoteDepth > 0) {
|
|
||||||
restoreSavedPosition();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onGoHome();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,8 +975,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
buildViewportWidth = viewportWidth;
|
buildViewportWidth = viewportWidth;
|
||||||
buildViewportHeight = viewportHeight;
|
buildViewportHeight = viewportHeight;
|
||||||
|
|
||||||
ensureBookPages(viewportWidth, viewportHeight);
|
|
||||||
|
|
||||||
if (!section) {
|
if (!section) {
|
||||||
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
|
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
|
||||||
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
||||||
@@ -1259,8 +1217,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
||||||
applyDeferredReposition();
|
applyDeferredReposition();
|
||||||
|
|
||||||
recordCurrentSectionPages();
|
|
||||||
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
|
|
||||||
if (section->pageCount == 0) {
|
if (section->pageCount == 0) {
|
||||||
@@ -1370,59 +1326,6 @@ bool EpubReaderActivity::applyDeferredReposition() {
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::ensureBookPages(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
|
||||||
if (SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::BOOK_PAGE_COUNT) {
|
|
||||||
bookPages.reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const Section::RenderParams params = readerRenderParams(viewportWidth, viewportHeight);
|
|
||||||
if (bookPages && params == bookPagesParams) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// First render, or a render-params change (font/orientation/...): the harvested
|
|
||||||
// counts are for the old pagination, so start over. The section caches themselves
|
|
||||||
// are the persistence; there is nothing else to invalidate.
|
|
||||||
bookPagesParams = params;
|
|
||||||
bookPagesSweepIndex = 0;
|
|
||||||
bookPages.reset();
|
|
||||||
const int sectionCount = epub->getSpineItemsCount();
|
|
||||||
if (sectionCount <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 8 bytes per spine item, held for the reading session; freed with the activity
|
|
||||||
// or when the feature is switched off. On OOM stays null (chapter-local counts).
|
|
||||||
bookPages = makeUniqueNoThrow<BookPageEntry[]>(sectionCount);
|
|
||||||
if (!bookPages) {
|
|
||||||
LOG_ERR("ERS", "OOM: book page table (%d sections)", sectionCount);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
size_t prev = 0;
|
|
||||||
for (int i = 0; i < sectionCount; ++i) {
|
|
||||||
const size_t cum = epub->getCumulativeSpineItemSize(i);
|
|
||||||
bookPages[i].bytes = static_cast<uint32_t>(cum >= prev ? cum - prev : 0);
|
|
||||||
prev = cum;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void EpubReaderActivity::recordCurrentSectionPages() {
|
|
||||||
// Only a finalized section's pageCount is the chapter total; a building or
|
|
||||||
// partial section's is just its current watermark.
|
|
||||||
if (!bookPages || !section || section->isBuilding() || section->isPartial()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentSpineIndex >= 0 && currentSpineIndex < epub->getSpineItemsCount()) {
|
|
||||||
bookPages[currentSpineIndex].pages = section->pageCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<BookPagePosition> EpubReaderActivity::bookPagePosition() const {
|
|
||||||
if (!bookPages || !section) {
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
return computeBookPagePosition(bookPages.get(), epub->getSpineItemsCount(), currentSpineIndex, section->currentPage,
|
|
||||||
section->estimatedTotalPages());
|
|
||||||
}
|
|
||||||
|
|
||||||
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
||||||
return EpubReaderUtils::saveProgress(*epub, spineIndex, currentPage, pageCount);
|
return EpubReaderUtils::saveProgress(*epub, spineIndex, currentPage, pageCount);
|
||||||
}
|
}
|
||||||
@@ -1604,21 +1507,10 @@ void EpubReaderActivity::renderStatusBar() const {
|
|||||||
// Calculate progress in book. Use the estimated total while a giant spine is still building so
|
// Calculate progress in book. Use the estimated total while a giant spine is still building so
|
||||||
// "page X of Y" and the progress bar don't read off the small build watermark.
|
// "page X of Y" and the progress bar don't read off the small build watermark.
|
||||||
const int currentPage = section->currentPage + 1;
|
const int currentPage = section->currentPage + 1;
|
||||||
const int pageCount = section->estimatedTotalPages();
|
const float pageCount = section->estimatedTotalPages();
|
||||||
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
|
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
|
||||||
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
|
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
|
||||||
|
|
||||||
// Page counter values: whole-book numbers when Page Count is set to Book (and the
|
|
||||||
// table is alive), otherwise chapter-local. The progress bar stays on bookProgress.
|
|
||||||
int counterPage = currentPage;
|
|
||||||
int counterTotal = pageCount;
|
|
||||||
bool counterIsEstimate = section->isBuilding();
|
|
||||||
if (const auto book = bookPagePosition()) {
|
|
||||||
counterPage = book->currentPage;
|
|
||||||
counterTotal = book->totalPages;
|
|
||||||
counterIsEstimate = book->isEstimate;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string title;
|
std::string title;
|
||||||
|
|
||||||
int textYOffset = 0;
|
int textYOffset = 0;
|
||||||
@@ -1646,8 +1538,8 @@ void EpubReaderActivity::renderStatusBar() const {
|
|||||||
title = epub->getTitle();
|
title = epub->getTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
GUI.drawStatusBar(renderer, bookProgress, counterPage, counterTotal, title, 0, textYOffset, true,
|
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
||||||
currentPageBookmarked, counterIsEstimate);
|
section->isBuilding());
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <Epub/BookPages.h>
|
|
||||||
#include <Epub/FootnoteEntry.h>
|
#include <Epub/FootnoteEntry.h>
|
||||||
#include <Epub/Section.h>
|
#include <Epub/Section.h>
|
||||||
|
|
||||||
@@ -24,17 +23,6 @@ class EpubReaderActivity final : public Activity {
|
|||||||
int pagesUntilFullRefresh = 0;
|
int pagesUntilFullRefresh = 0;
|
||||||
int cachedSpineIndex = 0;
|
int cachedSpineIndex = 0;
|
||||||
int cachedChapterTotalPageCount = 0;
|
int cachedChapterTotalPageCount = 0;
|
||||||
// Whole-book page accounting ("page X of Y" across the whole book), active only
|
|
||||||
// when the status bar Page Count is set to Book. Exact counts are harvested from
|
|
||||||
// finalized section caches — nothing extra is persisted (see BookPages.h). Null
|
|
||||||
// while inactive or on OOM; every book-page path degrades to chapter-local counts.
|
|
||||||
// Shared between the render task and loop(): only touch under the RenderLock.
|
|
||||||
std::unique_ptr<BookPageEntry[]> bookPages;
|
|
||||||
// Render params the harvested counts are valid for; a change resets the harvest.
|
|
||||||
Section::RenderParams bookPagesParams;
|
|
||||||
// Next spine index for loop()'s background sweep that peeks other sections'
|
|
||||||
// cached counts (one per tick); >= spine count once the sweep is done.
|
|
||||||
int bookPagesSweepIndex = 0;
|
|
||||||
unsigned long lastPageTurnTime = 0UL;
|
unsigned long lastPageTurnTime = 0UL;
|
||||||
unsigned long pageTurnDuration = 0UL;
|
unsigned long pageTurnDuration = 0UL;
|
||||||
// Signals that the next render should reposition within the newly loaded section
|
// Signals that the next render should reposition within the newly loaded section
|
||||||
@@ -125,14 +113,6 @@ class EpubReaderActivity final : public Activity {
|
|||||||
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
||||||
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
||||||
bool applyDeferredReposition();
|
bool applyDeferredReposition();
|
||||||
// (Re)allocate and reset bookPages when the feature turns on or the render params
|
|
||||||
// change. Called from render() (under the RenderLock) where the viewport is known.
|
|
||||||
void ensureBookPages(uint16_t viewportWidth, uint16_t viewportHeight);
|
|
||||||
// Store the current section's exact page count once its pagination is final
|
|
||||||
// (no-op while building or partial). Caller must hold the RenderLock.
|
|
||||||
void recordCurrentSectionPages();
|
|
||||||
// Book-global position for the current page, or nullopt while inactive.
|
|
||||||
std::optional<BookPagePosition> bookPagePosition() const;
|
|
||||||
bool saveProgress(int spineIndex, int currentPage, int pageCount);
|
bool saveProgress(int spineIndex, int currentPage, int pageCount);
|
||||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||||
void jumpToPercent(int percent);
|
void jumpToPercent(int percent);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
#include "Epub/Section.h"
|
#include "Epub/Section.h"
|
||||||
#include "EpubReaderUtils.h"
|
#include "EpubReaderUtils.h"
|
||||||
@@ -24,6 +25,19 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
std::string calculateDocumentHashForMethod(const std::string& path, const DocumentMatchMethod method) {
|
||||||
|
return method == DocumentMatchMethod::FILENAME ? KOReaderDocumentId::calculateFromFilename(path)
|
||||||
|
: KOReaderDocumentId::calculate(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentMatchMethod alternateMatchMethod(const DocumentMatchMethod method) {
|
||||||
|
return method == DocumentMatchMethod::FILENAME ? DocumentMatchMethod::BINARY : DocumentMatchMethod::FILENAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* matchMethodName(const DocumentMatchMethod method) {
|
||||||
|
return method == DocumentMatchMethod::FILENAME ? "filename" : "binary";
|
||||||
|
}
|
||||||
|
|
||||||
void syncTimeWithNTP() {
|
void syncTimeWithNTP() {
|
||||||
// Stop SNTP if already running (can't reconfigure while running)
|
// Stop SNTP if already running (can't reconfigure while running)
|
||||||
if (esp_sntp_enabled()) {
|
if (esp_sntp_enabled()) {
|
||||||
@@ -84,6 +98,21 @@ void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
|
|||||||
|
|
||||||
void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); }
|
void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); }
|
||||||
|
|
||||||
|
bool KOReaderSyncActivity::smartSyncEnabled() const {
|
||||||
|
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART;
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderSyncActivity::markAutoReturn() { autoReturnAt = millis() + AUTO_RETURN_DELAY_MS; }
|
||||||
|
|
||||||
|
void KOReaderSyncActivity::completeAlreadySynced() {
|
||||||
|
{
|
||||||
|
RenderLock lock(*this);
|
||||||
|
state = SYNC_COMPLETE;
|
||||||
|
}
|
||||||
|
markAutoReturn();
|
||||||
|
requestUpdate(true);
|
||||||
|
}
|
||||||
|
|
||||||
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||||
if (!success) {
|
if (!success) {
|
||||||
LOG_DBG("KOSync", "WiFi connection failed, exiting");
|
LOG_DBG("KOSync", "WiFi connection failed, exiting");
|
||||||
@@ -113,12 +142,8 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void KOReaderSyncActivity::performSync() {
|
void KOReaderSyncActivity::performSync() {
|
||||||
// Calculate document hash based on user's preferred method
|
const DocumentMatchMethod primaryMethod = KOREADER_STORE.getMatchMethod();
|
||||||
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
|
documentHash = calculateDocumentHashForMethod(epubPath, primaryMethod);
|
||||||
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
|
|
||||||
} else {
|
|
||||||
documentHash = KOReaderDocumentId::calculate(epubPath);
|
|
||||||
}
|
|
||||||
if (documentHash.empty()) {
|
if (documentHash.empty()) {
|
||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
@@ -128,8 +153,9 @@ void KOReaderSyncActivity::performSync() {
|
|||||||
requestUpdate(true);
|
requestUpdate(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const std::string primaryHash = documentHash;
|
||||||
|
|
||||||
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
|
LOG_DBG("KOSync", "Document hash (%s): %s", matchMethodName(primaryMethod), documentHash.c_str());
|
||||||
|
|
||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
@@ -137,10 +163,42 @@ void KOReaderSyncActivity::performSync() {
|
|||||||
}
|
}
|
||||||
requestUpdateAndWait();
|
requestUpdateAndWait();
|
||||||
|
|
||||||
// Fetch remote progress
|
// Fetch remote progress. In smart mode, also probe the alternate document-id
|
||||||
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
|
// method and use the furthest remote state we can find. This avoids a stale
|
||||||
|
// local upload when another KOReader device synced the same book with a
|
||||||
|
// different document matching method.
|
||||||
|
auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
|
||||||
|
LOG_DBG("KOSync", "Primary remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
|
||||||
|
matchMethodName(primaryMethod), result, KOReaderSyncClient::lastHttpCode, documentHash.c_str(),
|
||||||
|
localProgress.percentage, remoteProgress.percentage, remoteProgress.progress.c_str());
|
||||||
|
|
||||||
|
if (smartSyncEnabled()) {
|
||||||
|
const DocumentMatchMethod altMethod = alternateMatchMethod(primaryMethod);
|
||||||
|
const std::string altHash = calculateDocumentHashForMethod(epubPath, altMethod);
|
||||||
|
if (!altHash.empty() && altHash != documentHash) {
|
||||||
|
KOReaderProgress altProgress;
|
||||||
|
const auto altResult = KOReaderSyncClient::getProgress(altHash, altProgress);
|
||||||
|
LOG_DBG("KOSync", "Alternate remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
|
||||||
|
matchMethodName(altMethod), altResult, KOReaderSyncClient::lastHttpCode, altHash.c_str(),
|
||||||
|
localProgress.percentage, altProgress.percentage, altProgress.progress.c_str());
|
||||||
|
|
||||||
|
if (altResult == KOReaderSyncClient::OK &&
|
||||||
|
(result == KOReaderSyncClient::NOT_FOUND || altProgress.percentage > remoteProgress.percentage)) {
|
||||||
|
documentHash = altHash;
|
||||||
|
remoteProgress = std::move(altProgress);
|
||||||
|
result = KOReaderSyncClient::OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result == KOReaderSyncClient::NOT_FOUND) {
|
if (result == KOReaderSyncClient::NOT_FOUND) {
|
||||||
|
if (smartSyncEnabled()) {
|
||||||
|
LOG_DBG("KOSync", "Smart sync: no remote progress found for known document hashes; uploading local %.6f",
|
||||||
|
localProgress.percentage);
|
||||||
|
performUpload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// No remote progress - offer to upload
|
// No remote progress - offer to upload
|
||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
@@ -174,8 +232,42 @@ void KOReaderSyncActivity::performSync() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
// Prefer the exact spine/page from a crosspoint-sync rich position (lossless
|
||||||
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
|
// CrossPoint<->CrossPoint sync); fall back to the approximate XPath mapping
|
||||||
|
// for plain kosync servers or when the rich position cannot be applied.
|
||||||
|
std::optional<CrossPointPosition> richMapped;
|
||||||
|
if (remoteProgress.position.has_value()) {
|
||||||
|
richMapped = ProgressMapper::fromRichPosition(epub, *remoteProgress.position, renderer);
|
||||||
|
}
|
||||||
|
if (richMapped.has_value()) {
|
||||||
|
remotePosition = *richMapped;
|
||||||
|
} else {
|
||||||
|
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
||||||
|
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smartSyncEnabled()) {
|
||||||
|
static constexpr float SAME_PROGRESS_EPSILON = 0.001f; // 0.1 percentage points
|
||||||
|
const float delta = localProgress.percentage - remoteProgress.percentage;
|
||||||
|
LOG_DBG("KOSync", "Smart decision: doc=%s local=%.6f remote=%.6f delta=%.6f remoteXpath=%s mapped=%d/%d",
|
||||||
|
documentHash.c_str(), localProgress.percentage, remoteProgress.percentage, delta,
|
||||||
|
remoteProgress.progress.c_str(), remotePosition.spineIndex, remotePosition.pageNumber);
|
||||||
|
if (std::fabs(delta) <= SAME_PROGRESS_EPSILON) {
|
||||||
|
completeAlreadySynced();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
// Alternate hashes are only probes for newer remote state. Keep uploads
|
||||||
|
// on the user's configured matching method so its primary record heals.
|
||||||
|
documentHash = primaryHash;
|
||||||
|
performUpload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
|
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
|
||||||
{
|
{
|
||||||
@@ -206,17 +298,45 @@ void KOReaderSyncActivity::performUpload() {
|
|||||||
progress.progress = localProgress.xpath;
|
progress.progress = localProgress.xpath;
|
||||||
progress.percentage = localProgress.percentage;
|
progress.percentage = localProgress.percentage;
|
||||||
|
|
||||||
|
// Rich CrossPoint position for crosspoint-sync servers (lossless
|
||||||
|
// CrossPoint<->CrossPoint sync); plain kosync servers ignore the extra field.
|
||||||
|
{
|
||||||
|
KOReaderRichPosition pos;
|
||||||
|
const float pct = localProgress.percentage < 0.0f ? 0.0f
|
||||||
|
: localProgress.percentage > 1.0f ? 1.0f
|
||||||
|
: localProgress.percentage;
|
||||||
|
pos.pctQ = static_cast<uint32_t>(pct * 1000000.0f + 0.5f);
|
||||||
|
pos.spineIndex = static_cast<uint16_t>(currentSpineIndex);
|
||||||
|
pos.pageNumber = static_cast<uint16_t>(currentPage);
|
||||||
|
pos.totalPages = static_cast<uint16_t>(totalPagesInSpine > 0 ? totalPagesInSpine : 1);
|
||||||
|
pos.paragraphIndex = currentParagraphIndex;
|
||||||
|
pos.xpath = localProgress.xpath;
|
||||||
|
progress.position = std::move(pos);
|
||||||
|
}
|
||||||
|
|
||||||
// Optionally include document metadata (KOReader PR #15306)
|
// Optionally include document metadata (KOReader PR #15306)
|
||||||
if (KOREADER_STORE.getSendMetadata()) {
|
if (KOREADER_STORE.getSendMetadata()) {
|
||||||
|
// The Epub is released before the sync network calls and is only reloaded on the
|
||||||
|
// remote-progress path (performSync). When uploading from NO_REMOTE_PROGRESS the
|
||||||
|
// Epub is still null, so reload it here and guard the title/author reads to avoid
|
||||||
|
// dereferencing a null Epub. Filename is derived from the path and is always safe.
|
||||||
|
ensureEpubLoaded();
|
||||||
KOReaderMetadata meta;
|
KOReaderMetadata meta;
|
||||||
// Extract filename from path
|
|
||||||
const auto lastSlash = epubPath.rfind('/');
|
const auto lastSlash = epubPath.rfind('/');
|
||||||
meta.filename = (lastSlash != std::string::npos) ? epubPath.substr(lastSlash + 1) : epubPath;
|
meta.filename = (lastSlash != std::string::npos) ? epubPath.substr(lastSlash + 1) : epubPath;
|
||||||
meta.title = epub->getTitle();
|
if (epub) {
|
||||||
meta.authors = epub->getAuthor();
|
meta.title = epub->getTitle();
|
||||||
|
meta.authors = epub->getAuthor();
|
||||||
|
} else {
|
||||||
|
LOG_ERR("KOSync", "Epub unavailable for metadata; sending filename only");
|
||||||
|
}
|
||||||
progress.metadata = std::move(meta);
|
progress.metadata = std::move(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release the Epub before the network call so the TLS handshake has enough free heap
|
||||||
|
// (consistent with the release-before-sync pattern in performSync); nothing below needs it.
|
||||||
|
epub.reset();
|
||||||
|
|
||||||
const auto result = KOReaderSyncClient::updateProgress(progress);
|
const auto result = KOReaderSyncClient::updateProgress(progress);
|
||||||
|
|
||||||
// Drop the radio while user reads the result; full teardown happens at silent reboot.
|
// Drop the radio while user reads the result; full teardown happens at silent reboot.
|
||||||
@@ -236,6 +356,7 @@ void KOReaderSyncActivity::performUpload() {
|
|||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
state = UPLOAD_COMPLETE;
|
state = UPLOAD_COMPLETE;
|
||||||
}
|
}
|
||||||
|
markAutoReturn();
|
||||||
requestUpdate(true);
|
requestUpdate(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,10 +500,12 @@ void KOReaderSyncActivity::render(RenderLock&&) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state == UPLOAD_COMPLETE) {
|
if (state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
|
||||||
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD);
|
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top,
|
||||||
|
state == UPLOAD_COMPLETE ? tr(STR_UPLOAD_SUCCESS) : tr(STR_ALREADY_SYNCED), true,
|
||||||
|
EpdFontFamily::BOLD);
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DONE), "", "");
|
||||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
return;
|
return;
|
||||||
@@ -400,8 +523,13 @@ void KOReaderSyncActivity::render(RenderLock&&) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void KOReaderSyncActivity::loop() {
|
void KOReaderSyncActivity::loop() {
|
||||||
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
|
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
if (autoReturnAt != 0 && millis() >= autoReturnAt) {
|
||||||
|
returnToReader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
returnToReader();
|
returnToReader();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class KOReaderSyncActivity final : public Activity {
|
|||||||
void onExit() override;
|
void onExit() override;
|
||||||
void loop() override;
|
void loop() override;
|
||||||
void render(RenderLock&&) override;
|
void render(RenderLock&&) override;
|
||||||
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; }
|
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING || state == UPLOADING; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum State {
|
enum State {
|
||||||
@@ -50,6 +50,7 @@ class KOReaderSyncActivity final : public Activity {
|
|||||||
SHOWING_RESULT,
|
SHOWING_RESULT,
|
||||||
UPLOADING,
|
UPLOADING,
|
||||||
UPLOAD_COMPLETE,
|
UPLOAD_COMPLETE,
|
||||||
|
SYNC_COMPLETE,
|
||||||
NO_REMOTE_PROGRESS,
|
NO_REMOTE_PROGRESS,
|
||||||
SYNC_FAILED,
|
SYNC_FAILED,
|
||||||
NO_CREDENTIALS
|
NO_CREDENTIALS
|
||||||
@@ -78,6 +79,10 @@ class KOReaderSyncActivity final : public Activity {
|
|||||||
// Selection in result screen (0=Apply, 1=Upload)
|
// Selection in result screen (0=Apply, 1=Upload)
|
||||||
int selectedOption = 0;
|
int selectedOption = 0;
|
||||||
|
|
||||||
|
// Timed return for successful smart-sync terminal states.
|
||||||
|
unsigned long autoReturnAt = 0;
|
||||||
|
static constexpr unsigned long AUTO_RETURN_DELAY_MS = 1200;
|
||||||
|
|
||||||
// Tracks whether this session activated WiFi. Set in onEnter past the credentials
|
// Tracks whether this session activated WiFi. Set in onEnter past the credentials
|
||||||
// check; checked in onExit to decide whether to silent-reboot. Can't rely on
|
// check; checked in onExit to decide whether to silent-reboot. Can't rely on
|
||||||
// WiFi.getMode() because performUpload() calls esp_wifi_stop() on the way out,
|
// WiFi.getMode() because performUpload() calls esp_wifi_stop() on the way out,
|
||||||
@@ -87,6 +92,9 @@ class KOReaderSyncActivity final : public Activity {
|
|||||||
void onWifiSelectionComplete(bool success);
|
void onWifiSelectionComplete(bool success);
|
||||||
void performSync();
|
void performSync();
|
||||||
void performUpload();
|
void performUpload();
|
||||||
|
bool smartSyncEnabled() const;
|
||||||
|
void markAutoReturn();
|
||||||
|
void completeAlreadySynced();
|
||||||
void ensureEpubLoaded();
|
void ensureEpubLoaded();
|
||||||
void saveProgressAndReturn(int spineIndex, int page);
|
void saveProgressAndReturn(int spineIndex, int page);
|
||||||
void returnToReader();
|
void returnToReader();
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "activities/ActivityManager.h"
|
||||||
|
|
||||||
namespace ReaderUtils {
|
namespace ReaderUtils {
|
||||||
|
|
||||||
constexpr unsigned long GO_HOME_MS = 1000;
|
constexpr unsigned long GO_HOME_MS = 1000;
|
||||||
|
constexpr unsigned long GO_BACK_OR_HOME_MS = GO_HOME_MS;
|
||||||
constexpr unsigned long SKIP_HOLD_MS = 700;
|
constexpr unsigned long SKIP_HOLD_MS = 700;
|
||||||
constexpr unsigned long BOOKMARK_HOLD_MS = 400;
|
constexpr unsigned long BOOKMARK_HOLD_MS = 400;
|
||||||
constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500;
|
constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500;
|
||||||
@@ -96,4 +98,37 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) {
|
|||||||
renderer.restoreBwBuffer();
|
renderer.restoreBwBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BackNavCallback {
|
||||||
|
void* ctx;
|
||||||
|
void (*fn)(void*);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns true if the back button was consumed (caller should return).
|
||||||
|
// Long press (>= GO_BACK_OR_HOME_MS):
|
||||||
|
// - default: go to file browser
|
||||||
|
// - with backShortToFileBrowser: go home
|
||||||
|
// Short press (< GO_BACK_OR_HOME_MS):
|
||||||
|
// - default: go home
|
||||||
|
// - with backShortToFileBrowser: go to file browser.
|
||||||
|
inline bool handleBackNavigation(const MappedInputManager& mappedInput, ActivityManager& activityManager,
|
||||||
|
const char* filePath, BackNavCallback goHome) {
|
||||||
|
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_BACK_OR_HOME_MS) {
|
||||||
|
if (SETTINGS.backShortToFileBrowser) {
|
||||||
|
goHome.fn(goHome.ctx);
|
||||||
|
} else {
|
||||||
|
activityManager.goToFileBrowser(filePath);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < GO_BACK_OR_HOME_MS) {
|
||||||
|
if (SETTINGS.backShortToFileBrowser) {
|
||||||
|
activityManager.goToFileBrowser(filePath);
|
||||||
|
} else {
|
||||||
|
goHome.fn(goHome.ctx);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ReaderUtils
|
} // namespace ReaderUtils
|
||||||
|
|||||||
@@ -60,16 +60,8 @@ void TxtReaderActivity::onExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void TxtReaderActivity::loop() {
|
void TxtReaderActivity::loop() {
|
||||||
// Long press BACK (1s+) goes to file selection
|
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, txt ? txt->getPath().c_str() : "",
|
||||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
{this, [](void* ctx) { static_cast<TxtReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||||
activityManager.goToFileBrowser(txt ? txt->getPath() : "");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short press BACK goes directly to home
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
|
||||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
|
||||||
onGoHome();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,16 +101,8 @@ void XtcReaderActivity::loop() {
|
|||||||
openChapterSelection();
|
openChapterSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Long press BACK (1s+) goes to file selection
|
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, xtc ? xtc->getPath().c_str() : "",
|
||||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
{this, [](void* ctx) { static_cast<XtcReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||||
activityManager.goToFileBrowser(xtc ? xtc->getPath() : "");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short press BACK goes directly to home
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
|
||||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
|
||||||
onGoHome();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
|||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
state = AUTHENTICATING;
|
state = AUTHENTICATING;
|
||||||
statusMessage = tr(STR_AUTHENTICATING);
|
statusMessage = mode == Mode::SIGN_UP ? tr(STR_CREATING_ACCOUNT) : tr(STR_AUTHENTICATING);
|
||||||
}
|
}
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
|
||||||
@@ -34,16 +34,17 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void KOReaderAuthActivity::performAuthentication() {
|
void KOReaderAuthActivity::performAuthentication() {
|
||||||
const auto result = KOReaderSyncClient::authenticate();
|
const auto result = mode == Mode::SIGN_UP ? KOReaderSyncClient::createUser() : KOReaderSyncClient::authenticate();
|
||||||
|
|
||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
if (result == KOReaderSyncClient::OK) {
|
if (result == KOReaderSyncClient::OK) {
|
||||||
state = SUCCESS;
|
state = SUCCESS;
|
||||||
statusMessage = tr(STR_AUTH_SUCCESS);
|
statusMessage = mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS);
|
||||||
} else {
|
} else {
|
||||||
state = FAILED;
|
state = FAILED;
|
||||||
errorMessage = KOReaderSyncClient::errorString(result);
|
errorMessage =
|
||||||
|
result == KOReaderSyncClient::USER_EXISTS ? tr(STR_USERNAME_TAKEN) : KOReaderSyncClient::errorString(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
@@ -80,17 +81,21 @@ void KOReaderAuthActivity::render(RenderLock&&) {
|
|||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_KOREADER_AUTH));
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight},
|
||||||
|
mode == Mode::SIGN_UP ? tr(STR_SIGN_UP) : tr(STR_KOREADER_AUTH));
|
||||||
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
||||||
const auto top = (pageHeight - height) / 2;
|
const auto top = (pageHeight - height) / 2;
|
||||||
|
|
||||||
if (state == AUTHENTICATING) {
|
if (state == AUTHENTICATING) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str());
|
||||||
} else if (state == SUCCESS) {
|
} else if (state == SUCCESS) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_SUCCESS), true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_10_FONT_ID, top,
|
||||||
|
mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS), true,
|
||||||
|
EpdFontFamily::BOLD);
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY));
|
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY));
|
||||||
} else if (state == FAILED) {
|
} else if (state == FAILED) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_FAILED), true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_10_FONT_ID, top, mode == Mode::SIGN_UP ? tr(STR_SIGNUP_FAILED) : tr(STR_AUTH_FAILED),
|
||||||
|
true, EpdFontFamily::BOLD);
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity for testing KOReader credentials.
|
* Activity for testing KOReader credentials, or — in sign-up mode — creating a
|
||||||
* Connects to WiFi and authenticates with the KOReader sync server.
|
* new account on the sync server with the entered username/password.
|
||||||
|
* Connects to WiFi, then authenticates or registers.
|
||||||
*/
|
*/
|
||||||
class KOReaderAuthActivity final : public Activity {
|
class KOReaderAuthActivity final : public Activity {
|
||||||
public:
|
public:
|
||||||
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
enum class Mode { AUTHENTICATE, SIGN_UP };
|
||||||
: Activity("KOReaderAuth", renderer, mappedInput) {}
|
|
||||||
|
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, Mode mode = Mode::AUTHENTICATE)
|
||||||
|
: Activity("KOReaderAuth", renderer, mappedInput), mode(mode) {}
|
||||||
|
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
@@ -22,6 +25,7 @@ class KOReaderAuthActivity final : public Activity {
|
|||||||
private:
|
private:
|
||||||
enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED };
|
enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED };
|
||||||
|
|
||||||
|
Mode mode = Mode::AUTHENTICATE;
|
||||||
State state = WIFI_SELECTION;
|
State state = WIFI_SELECTION;
|
||||||
std::string statusMessage;
|
std::string statusMessage;
|
||||||
std::string errorMessage;
|
std::string errorMessage;
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int MENU_ITEMS = 6;
|
constexpr int MENU_ITEMS = 8;
|
||||||
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
|
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
|
||||||
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_AUTHENTICATE};
|
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_SYNC_BEHAVIOR,
|
||||||
|
StrId::STR_SIGN_UP, StrId::STR_AUTHENTICATE};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void KOReaderSettingsActivity::onEnter() {
|
void KOReaderSettingsActivity::onEnter() {
|
||||||
@@ -103,6 +104,22 @@ void KOReaderSettingsActivity::handleSelection() {
|
|||||||
KOREADER_STORE.saveToFile();
|
KOREADER_STORE.saveToFile();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
} else if (selectedIndex == 5) {
|
} else if (selectedIndex == 5) {
|
||||||
|
// Sync behavior - toggle between Ask and Smart
|
||||||
|
const auto current = KOREADER_STORE.getSyncBehavior();
|
||||||
|
const auto newBehavior = (current == KOReaderSyncBehavior::ASK_EVERY_TIME) ? KOReaderSyncBehavior::SMART
|
||||||
|
: KOReaderSyncBehavior::ASK_EVERY_TIME;
|
||||||
|
KOREADER_STORE.setSyncBehavior(newBehavior);
|
||||||
|
KOREADER_STORE.saveToFile();
|
||||||
|
requestUpdate();
|
||||||
|
} else if (selectedIndex == 6) {
|
||||||
|
// Sign Up - create a new account on the sync server with the entered credentials
|
||||||
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<KOReaderAuthActivity>(renderer, mappedInput, KOReaderAuthActivity::Mode::SIGN_UP),
|
||||||
|
[](const ActivityResult&) {});
|
||||||
|
} else if (selectedIndex == 7) {
|
||||||
// Authenticate
|
// Authenticate
|
||||||
if (!KOREADER_STORE.hasCredentials()) {
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
// Can't authenticate without credentials - just show message briefly
|
// Can't authenticate without credentials - just show message briefly
|
||||||
@@ -136,13 +153,25 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
|
|||||||
return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||||
} else if (index == 2) {
|
} else if (index == 2) {
|
||||||
auto serverUrl = KOREADER_STORE.getServerUrl();
|
auto serverUrl = KOREADER_STORE.getServerUrl();
|
||||||
return serverUrl.empty() ? std::string(tr(STR_DEFAULT_VALUE)) : serverUrl;
|
if (!serverUrl.empty()) {
|
||||||
|
return serverUrl;
|
||||||
|
}
|
||||||
|
// Show which server the default actually is, scheme stripped for space
|
||||||
|
std::string defaultUrl = KOREADER_STORE.getBaseUrl();
|
||||||
|
const auto schemeEnd = defaultUrl.find("://");
|
||||||
|
if (schemeEnd != std::string::npos) {
|
||||||
|
defaultUrl.erase(0, schemeEnd + 3);
|
||||||
|
}
|
||||||
|
return std::string(tr(STR_DEFAULT_VALUE)) + ": " + defaultUrl;
|
||||||
} else if (index == 3) {
|
} else if (index == 3) {
|
||||||
return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME))
|
return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME))
|
||||||
: std::string(tr(STR_BINARY));
|
: std::string(tr(STR_BINARY));
|
||||||
} else if (index == 4) {
|
} else if (index == 4) {
|
||||||
return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
|
return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
|
||||||
} else if (index == 5) {
|
} else if (index == 5) {
|
||||||
|
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART ? std::string(tr(STR_SMART_SYNC))
|
||||||
|
: std::string(tr(STR_ASK_EVERY_TIME));
|
||||||
|
} else if (index == 6 || index == 7) {
|
||||||
return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]";
|
return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]";
|
||||||
}
|
}
|
||||||
return std::string(tr(STR_NOT_SET));
|
return std::string(tr(STR_NOT_SET));
|
||||||
|
|||||||
@@ -3,19 +3,51 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
#include "OpdsSettingsActivity.h"
|
#include "OpdsSettingsActivity.h"
|
||||||
#include "activities/ActivityManager.h"
|
#include "activities/ActivityManager.h"
|
||||||
#include "activities/browser/OpdsBookBrowserActivity.h"
|
#include "activities/browser/OpdsBookBrowserActivity.h"
|
||||||
|
#include "activities/util/KeyboardEntryActivity.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
#include "util/OpdsFilename.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Normalizes a user-typed folder: trims spaces, "" => SD root, otherwise a
|
||||||
|
// single leading '/' and no trailing '/'. Cold path (runs once per edit).
|
||||||
|
std::string normalizeFolder(std::string v) {
|
||||||
|
while (!v.empty() && (v.front() == ' ' || v.front() == '\t')) v.erase(v.begin());
|
||||||
|
while (!v.empty() && (v.back() == ' ' || v.back() == '\t')) v.pop_back();
|
||||||
|
if (v.empty()) return "";
|
||||||
|
if (v.front() != '/') v.insert(v.begin(), '/');
|
||||||
|
while (v.size() > 1 && v.back() == '/') v.pop_back();
|
||||||
|
if (v == "/") return ""; // a bare slash is SD root, same as empty
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label shown for the current OPDS filename format in the list subtitle.
|
||||||
|
StrId opdsFormatLabel(uint8_t format) {
|
||||||
|
switch (format) {
|
||||||
|
case static_cast<uint8_t>(OpdsFilenameFormat::TitleAuthor):
|
||||||
|
return StrId::STR_FMT_TITLE_AUTHOR;
|
||||||
|
case static_cast<uint8_t>(OpdsFilenameFormat::TitleOnly):
|
||||||
|
return StrId::STR_FMT_TITLE;
|
||||||
|
default:
|
||||||
|
return StrId::STR_FMT_AUTHOR_TITLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
int OpdsServerListActivity::getItemCount() const {
|
int OpdsServerListActivity::getItemCount() const {
|
||||||
int count = static_cast<int>(OPDS_STORE.getCount());
|
int count = static_cast<int>(OPDS_STORE.getCount());
|
||||||
// In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers
|
// Settings mode appends three virtual items: "Add Server", "Download folder"
|
||||||
|
// and "Filename format".
|
||||||
if (!pickerMode) {
|
if (!pickerMode) {
|
||||||
count++;
|
count += 3;
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
@@ -74,6 +106,34 @@ void OpdsServerListActivity::handleSelection() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index layout: [servers 0..serverCount-1], [Add Server], [Download folder], [Filename format].
|
||||||
|
if (selectedIndex == serverCount + 1) {
|
||||||
|
auto folderHandler = [this](const ActivityResult& result) {
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||||
|
const std::string norm = normalizeFolder(kb.text);
|
||||||
|
strncpy(SETTINGS.opdsDownloadFolder, norm.c_str(), sizeof(SETTINGS.opdsDownloadFolder) - 1);
|
||||||
|
SETTINGS.opdsDownloadFolder[sizeof(SETTINGS.opdsDownloadFolder) - 1] = '\0';
|
||||||
|
SETTINGS.saveToFile();
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_OPDS_DOWNLOAD_FOLDER),
|
||||||
|
std::string(SETTINGS.opdsDownloadFolder), 63, InputType::Text),
|
||||||
|
folderHandler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Filename format": tap cycles through the available formats.
|
||||||
|
if (selectedIndex == serverCount + 2) {
|
||||||
|
SETTINGS.opdsFilenameFormat =
|
||||||
|
static_cast<uint8_t>((SETTINGS.opdsFilenameFormat + 1) % static_cast<uint8_t>(OpdsFilenameFormat::Count));
|
||||||
|
SETTINGS.saveToFile();
|
||||||
|
requestUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Settings mode: open editor for selected server, or create a new one
|
// Settings mode: open editor for selected server, or create a new one
|
||||||
auto resultHandler = [this](const ActivityResult&) {
|
auto resultHandler = [this](const ActivityResult&) {
|
||||||
// Reload server list when returning from editor
|
// Reload server list when returning from editor
|
||||||
@@ -111,17 +171,30 @@ void OpdsServerListActivity::render(RenderLock&&) {
|
|||||||
// Secondary label: server URL (shown as subtitle when name is set).
|
// Secondary label: server URL (shown as subtitle when name is set).
|
||||||
GUI.drawList(
|
GUI.drawList(
|
||||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
|
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
|
||||||
[&servers, serverCount](int index) {
|
[&servers, serverCount](int index) -> std::string {
|
||||||
if (index < serverCount) {
|
if (index < serverCount) {
|
||||||
const auto& server = servers[index];
|
const auto& server = servers[index];
|
||||||
return server.name.empty() ? server.url : server.name;
|
return server.name.empty() ? server.url : server.name;
|
||||||
}
|
}
|
||||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
if (index == serverCount) {
|
||||||
|
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||||
|
}
|
||||||
|
if (index == serverCount + 1) {
|
||||||
|
return std::string(I18n::getInstance().get(StrId::STR_OPDS_DOWNLOAD_FOLDER));
|
||||||
|
}
|
||||||
|
return std::string(I18n::getInstance().get(StrId::STR_OPDS_FILENAME_FORMAT));
|
||||||
},
|
},
|
||||||
[&servers, serverCount](int index) {
|
[&servers, serverCount](int index) -> std::string {
|
||||||
if (index < serverCount && !servers[index].name.empty()) {
|
if (index < serverCount && !servers[index].name.empty()) {
|
||||||
return servers[index].url;
|
return servers[index].url;
|
||||||
}
|
}
|
||||||
|
if (index == serverCount + 1) {
|
||||||
|
const char* f = SETTINGS.opdsDownloadFolder;
|
||||||
|
return f[0] ? std::string(f) : std::string(I18n::getInstance().get(StrId::STR_OPDS_SD_ROOT));
|
||||||
|
}
|
||||||
|
if (index == serverCount + 2) {
|
||||||
|
return std::string(I18n::getInstance().get(opdsFormatLabel(SETTINGS.opdsFilenameFormat)));
|
||||||
|
}
|
||||||
return std::string("");
|
return std::string("");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,6 @@ std::string formatUtcOffset(uint8_t biasedQ) {
|
|||||||
snprintf(buf, sizeof(buf), "UTC%c%d:%02d", neg ? '-' : '+', hours, mins);
|
snprintf(buf, sizeof(buf), "UTC%c%d:%02d", neg ? '-' : '+', hours, mins);
|
||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
// Order follows STATUS_BAR_PAGE_COUNT (hide=0, chapter=1, book=2).
|
|
||||||
constexpr int PAGE_COUNT_ITEMS = 3;
|
|
||||||
const StrId pageCountNames[PAGE_COUNT_ITEMS] = {StrId::STR_HIDE, StrId::STR_CHAPTER, StrId::STR_BOOK};
|
|
||||||
|
|
||||||
constexpr int PROGRESS_BAR_ITEMS = 3;
|
constexpr int PROGRESS_BAR_ITEMS = 3;
|
||||||
const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
|
const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
|
||||||
|
|
||||||
@@ -94,11 +90,7 @@ void StatusBarSettingsActivity::onEnter() {
|
|||||||
selectedIndex = 0;
|
selectedIndex = 0;
|
||||||
visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS;
|
visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS;
|
||||||
|
|
||||||
// Clamp enum-valued settings in case of corrupt/migrated data
|
// Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data
|
||||||
if (SETTINGS.statusBarPageCount >= PAGE_COUNT_ITEMS) {
|
|
||||||
SETTINGS.statusBarPageCount = CrossPointSettings::STATUS_BAR_PAGE_COUNT::CHAPTER_PAGE_COUNT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) {
|
if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) {
|
||||||
SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS;
|
SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS;
|
||||||
}
|
}
|
||||||
@@ -171,12 +163,8 @@ void StatusBarSettingsActivity::loop() {
|
|||||||
void StatusBarSettingsActivity::handleSelection() {
|
void StatusBarSettingsActivity::handleSelection() {
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case ITEM_CHAPTER_PAGE_COUNT:
|
case ITEM_CHAPTER_PAGE_COUNT:
|
||||||
optionPopup.show(StrId::STR_CHAPTER_PAGE_COUNT, pageCountNames, PAGE_COUNT_ITEMS, SETTINGS.statusBarPageCount,
|
SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2;
|
||||||
[this](int idx) {
|
break;
|
||||||
SETTINGS.statusBarPageCount = idx;
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
case ITEM_BOOK_PROGRESS_PERCENTAGE:
|
case ITEM_BOOK_PROGRESS_PERCENTAGE:
|
||||||
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
|
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
|
||||||
break;
|
break;
|
||||||
@@ -248,7 +236,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
|
|||||||
[](int index) -> std::string {
|
[](int index) -> std::string {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case ITEM_CHAPTER_PAGE_COUNT:
|
case ITEM_CHAPTER_PAGE_COUNT:
|
||||||
return I18N.get(pageCountNames[SETTINGS.statusBarPageCount]);
|
return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||||
case ITEM_BOOK_PROGRESS_PERCENTAGE:
|
case ITEM_BOOK_PROGRESS_PERCENTAGE:
|
||||||
return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE);
|
return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||||
case ITEM_PROGRESS_BAR:
|
case ITEM_PROGRESS_BAR:
|
||||||
|
|||||||
@@ -132,8 +132,7 @@ int UITheme::getStatusBarHeight() {
|
|||||||
|
|
||||||
// Add status bar margin
|
// Add status bar margin
|
||||||
const bool showStatusBar =
|
const bool showStatusBar =
|
||||||
SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT ||
|
SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
|
||||||
SETTINGS.statusBarBookProgressPercentage ||
|
|
||||||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
|
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
|
||||||
SETTINGS.statusBarClock != CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
|
SETTINGS.statusBarClock != CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
|
||||||
const bool showProgressBar =
|
const bool showProgressBar =
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ constexpr int bookmarkStatusIconGap = 4;
|
|||||||
constexpr int bookmarkStatusIconTopCrop = 2;
|
constexpr int bookmarkStatusIconTopCrop = 2;
|
||||||
|
|
||||||
bool statusBarTextLaneVisible() {
|
bool statusBarTextLaneVisible() {
|
||||||
return SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT ||
|
return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
|
||||||
SETTINGS.statusBarBookProgressPercentage ||
|
|
||||||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
|
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
|
||||||
(SETTINGS.statusBarClock && halClock.isAvailable());
|
(SETTINGS.statusBarClock && halClock.isAvailable());
|
||||||
}
|
}
|
||||||
@@ -766,23 +765,20 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
|
|||||||
int leftClusterWidth = 0;
|
int leftClusterWidth = 0;
|
||||||
int rightClusterWidth = 0;
|
int rightClusterWidth = 0;
|
||||||
|
|
||||||
const bool showPageCount = SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT;
|
if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) {
|
||||||
if (SETTINGS.statusBarBookProgressPercentage || showPageCount) {
|
|
||||||
// Right aligned text for progress counter
|
// Right aligned text for progress counter
|
||||||
char progressStr[32];
|
char progressStr[32];
|
||||||
|
|
||||||
// Mark the total with "~" while it is only an estimate (a still-building spine's
|
// Prefix the page count with "~" while a still-building spine only yields an estimated total.
|
||||||
// watermark, or a whole-book total with not-yet-paginated chapters). The current
|
|
||||||
// page is always exact, so the marker sits on the total.
|
|
||||||
const char* estimatePrefix = pageCountEstimated ? "~" : "";
|
const char* estimatePrefix = pageCountEstimated ? "~" : "";
|
||||||
|
|
||||||
if (SETTINGS.statusBarBookProgressPercentage && showPageCount) {
|
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) {
|
||||||
snprintf(progressStr, sizeof(progressStr), "%d/%s%d %.0f%%", currentPage, estimatePrefix, pageCount,
|
snprintf(progressStr, sizeof(progressStr), "%s%d/%d %.0f%%", estimatePrefix, currentPage, pageCount,
|
||||||
bookProgress);
|
bookProgress);
|
||||||
} else if (SETTINGS.statusBarBookProgressPercentage) {
|
} else if (SETTINGS.statusBarBookProgressPercentage) {
|
||||||
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
|
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
|
||||||
} else {
|
} else {
|
||||||
snprintf(progressStr, sizeof(progressStr), "%d/%s%d", currentPage, estimatePrefix, pageCount);
|
snprintf(progressStr, sizeof(progressStr), "%s%d/%d", estimatePrefix, currentPage, pageCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
|
int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
|
||||||
|
|||||||
+4
-45
@@ -159,49 +159,6 @@ void silentRestartToReader() {
|
|||||||
ESP.restart();
|
ESP.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify power button press duration on wake-up from deep sleep
|
|
||||||
// Pre-condition: isWakeupByPowerButton() == true
|
|
||||||
void verifyPowerButtonDuration() {
|
|
||||||
if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) {
|
|
||||||
// Fast path for short press
|
|
||||||
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
|
|
||||||
const auto start = millis();
|
|
||||||
bool abort = false;
|
|
||||||
// Subtract the current time, because inputManager only starts counting the HeldTime from the first update()
|
|
||||||
// This way, we remove the time we already took to reach here from the duration,
|
|
||||||
// assuming the button was held until now from millis()==0 (i.e. device start time).
|
|
||||||
const uint16_t calibration = start;
|
|
||||||
const uint16_t calibratedPressDuration =
|
|
||||||
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
|
||||||
|
|
||||||
gpio.update();
|
|
||||||
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state
|
|
||||||
while (!gpio.isPressed(HalGPIO::BTN_POWER) && millis() - start < 1000) {
|
|
||||||
delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
|
|
||||||
gpio.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
t2 = millis();
|
|
||||||
if (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
|
||||||
do {
|
|
||||||
delay(10);
|
|
||||||
gpio.update();
|
|
||||||
} while (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getPowerButtonHeldTime() < calibratedPressDuration);
|
|
||||||
abort = gpio.getPowerButtonHeldTime() < calibratedPressDuration;
|
|
||||||
} else {
|
|
||||||
abort = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (abort) {
|
|
||||||
// Button released too early. Returning to sleep.
|
|
||||||
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
|
|
||||||
powerManager.startDeepSleep(gpio);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void waitForPowerRelease() {
|
void waitForPowerRelease() {
|
||||||
gpio.update();
|
gpio.update();
|
||||||
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||||
@@ -357,8 +314,10 @@ void setup() {
|
|||||||
switch (wakeupReason) {
|
switch (wakeupReason) {
|
||||||
case HalGPIO::WakeupReason::PowerButton:
|
case HalGPIO::WakeupReason::PowerButton:
|
||||||
LOG_DBG("MAIN", "Verifying power button press duration");
|
LOG_DBG("MAIN", "Verifying power button press duration");
|
||||||
gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(),
|
if (!gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(),
|
||||||
SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP);
|
SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP)) {
|
||||||
|
powerManager.startDeepSleep(gpio);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case HalGPIO::WakeupReason::AfterUSBPower:
|
case HalGPIO::WakeupReason::AfterUSBPower:
|
||||||
// If USB power caused a cold boot, go back to sleep
|
// If USB power caused a cold boot, go back to sleep
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ void CrossPointWebServer::begin() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Access-Control-Allow-* headers to every response so web-based clients
|
||||||
|
// and PWAs on other origins can use the HTTP API. Preflight OPTIONS requests
|
||||||
|
// are answered in handleNotFound().
|
||||||
|
server->enableCORS(true);
|
||||||
|
|
||||||
// Setup routes
|
// Setup routes
|
||||||
LOG_DBG("WEB", "Setting up routes...");
|
LOG_DBG("WEB", "Setting up routes...");
|
||||||
server->on("/", HTTP_GET, [this] { handleRoot(); });
|
server->on("/", HTTP_GET, [this] { handleRoot(); });
|
||||||
@@ -353,6 +358,13 @@ void CrossPointWebServer::handleJszip() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CrossPointWebServer::handleNotFound() const {
|
void CrossPointWebServer::handleNotFound() const {
|
||||||
|
// CORS preflight: routes are registered per-method, so OPTIONS requests land
|
||||||
|
// here. The Access-Control-Allow-* headers are added by enableCORS().
|
||||||
|
if (server->method() == HTTP_OPTIONS) {
|
||||||
|
server->send(204, "text/plain", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// in AP mode, redirect unmatched browser/captive-portal requests to "/" so the OS auto-opens the browser
|
// in AP mode, redirect unmatched browser/captive-portal requests to "/" so the OS auto-opens the browser
|
||||||
// API requests (/api/*) still return 404 so XHR errors surface correctly
|
// API requests (/api/*) still return 404 so XHR errors surface correctly
|
||||||
// see https://en.wikipedia.org/wiki/Captive_portal#Detection
|
// see https://en.wikipedia.org/wiki/Captive_portal#Detection
|
||||||
@@ -671,17 +683,15 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
|
|||||||
LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str());
|
LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str());
|
||||||
LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap());
|
LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap());
|
||||||
|
|
||||||
// Create file path
|
|
||||||
String filePath = state.path;
|
String filePath = state.path;
|
||||||
if (!filePath.endsWith("/")) filePath += "/";
|
if (!filePath.endsWith("/")) filePath += "/";
|
||||||
filePath += state.fileName;
|
filePath += state.fileName;
|
||||||
|
|
||||||
// Check if file already exists - SD operations can be slow
|
|
||||||
esp_task_wdt_reset();
|
esp_task_wdt_reset();
|
||||||
if (Storage.exists(filePath.c_str())) {
|
if (Storage.exists(filePath.c_str())) {
|
||||||
LOG_DBG("WEB", "[UPLOAD] Overwriting existing file: %s", filePath.c_str());
|
state.error = "File already exists: " + state.fileName;
|
||||||
esp_task_wdt_reset();
|
LOG_DBG("WEB", "[UPLOAD] Collision: %s", filePath.c_str());
|
||||||
Storage.remove(filePath.c_str());
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open file for writing - this can be slow due to FAT cluster allocation
|
// Open file for writing - this can be slow due to FAT cluster allocation
|
||||||
@@ -749,7 +759,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
|
|||||||
LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime,
|
LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime,
|
||||||
writePercent);
|
writePercent);
|
||||||
|
|
||||||
// Clear epub cache to prevent stale metadata issues when overwriting files
|
// Clear epub cache after uploading the file
|
||||||
String filePath = state.path;
|
String filePath = state.path;
|
||||||
if (!filePath.endsWith("/")) filePath += "/";
|
if (!filePath.endsWith("/")) filePath += "/";
|
||||||
filePath += state.fileName;
|
filePath += state.fileName;
|
||||||
@@ -1624,20 +1634,20 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
|||||||
wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1);
|
wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build file path
|
|
||||||
String filePath = wsUploadPath;
|
String filePath = wsUploadPath;
|
||||||
if (!filePath.endsWith("/")) filePath += "/";
|
if (!filePath.endsWith("/")) filePath += "/";
|
||||||
filePath += wsUploadFileName;
|
filePath += wsUploadFileName;
|
||||||
|
|
||||||
LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize,
|
|
||||||
filePath.c_str());
|
|
||||||
|
|
||||||
// Check if file exists and remove it
|
|
||||||
esp_task_wdt_reset();
|
esp_task_wdt_reset();
|
||||||
if (Storage.exists(filePath.c_str())) {
|
if (Storage.exists(filePath.c_str())) {
|
||||||
Storage.remove(filePath.c_str());
|
LOG_DBG("WS", "Upload collision: %s", filePath.c_str());
|
||||||
|
wsServer->sendTXT(num, "ERROR:File already exists: " + wsUploadFileName);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize,
|
||||||
|
filePath.c_str());
|
||||||
|
|
||||||
// Open file for writing
|
// Open file for writing
|
||||||
esp_task_wdt_reset();
|
esp_task_wdt_reset();
|
||||||
if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
|
if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
|
||||||
@@ -1721,7 +1731,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
|||||||
LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize,
|
LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize,
|
||||||
elapsed, kbps);
|
elapsed, kbps);
|
||||||
|
|
||||||
// Clear epub cache to prevent stale metadata issues when overwriting files
|
// Clear epub cache after uploading the file
|
||||||
String filePath = wsUploadPath;
|
String filePath = wsUploadPath;
|
||||||
if (!filePath.endsWith("/")) filePath += "/";
|
if (!filePath.endsWith("/")) filePath += "/";
|
||||||
filePath += wsUploadFileName;
|
filePath += wsUploadFileName;
|
||||||
|
|||||||
+244
-35
@@ -1579,6 +1579,19 @@
|
|||||||
<div id="convertInfo" style="display:none; margin-bottom: 10px; font-size: 0.9em;">
|
<div id="convertInfo" style="display:none; margin-bottom: 10px; font-size: 0.9em;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="advanced-setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<div class="setting-title">Rename from Book Metadata</div>
|
||||||
|
<div class="setting-desc">Use Title - Author.epub when available</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="renameFromMetadataToggle" onchange="updateUploadSettingsPersistence()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="convert-settings" id="convertSettings" style="display:block;">
|
<div class="convert-settings" id="convertSettings" style="display:block;">
|
||||||
<span>⚫ True-Grayscale</span>
|
<span>⚫ True-Grayscale</span>
|
||||||
<span id="convertSizeSummary">📏 Max 480×800px</span>
|
<span id="convertSizeSummary">📏 Max 480×800px</span>
|
||||||
@@ -1675,7 +1688,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-controls">
|
<div class="setting-controls">
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="export-log-checkbox">
|
<input type="checkbox" id="export-log-checkbox" onchange="updateUploadSettingsPersistence()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="advanced-setting-row">
|
||||||
|
<div class="setting-label">
|
||||||
|
<div class="setting-title">Remember Settings</div>
|
||||||
|
<div class="setting-desc">Store these upload options in this browser</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-controls">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="rememberUploadSettings" onchange="updateUploadSettingsPersistence()">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -2085,12 +2110,8 @@
|
|||||||
|
|
||||||
// Modal functions
|
// Modal functions
|
||||||
function openUploadModal() {
|
function openUploadModal() {
|
||||||
// Reset converter variables to defaults
|
|
||||||
ENABLE_GRAYSCALE = true;
|
|
||||||
JPEG_QUALITY = 85;
|
|
||||||
HANDEDNESS = 'right';
|
|
||||||
OVERLAP_PERCENT = 5;
|
|
||||||
imageStates = {};
|
imageStates = {};
|
||||||
|
restoreUploadSettingsFromStorage();
|
||||||
|
|
||||||
// Hide convert options when opening modal (no files selected initially)
|
// Hide convert options when opening modal (no files selected initially)
|
||||||
const convertOptions = document.getElementById('convertOptions');
|
const convertOptions = document.getElementById('convertOptions');
|
||||||
@@ -2098,10 +2119,6 @@
|
|||||||
convertOptions.style.display = 'none';
|
convertOptions.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset rotation and overlap UI
|
|
||||||
setHandedness('right');
|
|
||||||
setOverlap(5);
|
|
||||||
|
|
||||||
// Hide log section from previous session
|
// Hide log section from previous session
|
||||||
const logSection = document.getElementById('log-section');
|
const logSection = document.getElementById('log-section');
|
||||||
if (logSection) logSection.classList.remove('visible');
|
if (logSection) logSection.classList.remove('visible');
|
||||||
@@ -2160,7 +2177,8 @@
|
|||||||
document.getElementById('progress-container').style.display = 'none';
|
document.getElementById('progress-container').style.display = 'none';
|
||||||
document.getElementById('progress-fill').style.width = '0%';
|
document.getElementById('progress-fill').style.width = '0%';
|
||||||
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
|
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
|
||||||
document.getElementById('convertBeforeUpload').checked = false;
|
const convertOptions = document.getElementById('convertOptions');
|
||||||
|
if (convertOptions) convertOptions.style.display = 'none';
|
||||||
document.getElementById('convertInfo').style.display = 'none';
|
document.getElementById('convertInfo').style.display = 'none';
|
||||||
document.getElementById('convertWarning').style.display = 'none';
|
document.getElementById('convertWarning').style.display = 'none';
|
||||||
// Clear image picker cache and reset layout
|
// Clear image picker cache and reset layout
|
||||||
@@ -2183,15 +2201,7 @@
|
|||||||
advancedOptionsToggle.style.opacity = '0.5';
|
advancedOptionsToggle.style.opacity = '0.5';
|
||||||
advancedOptionsToggle.style.pointerEvents = 'none';
|
advancedOptionsToggle.style.pointerEvents = 'none';
|
||||||
}
|
}
|
||||||
// Reset to defaults
|
applyUploadSettings();
|
||||||
document.getElementById('qualitySlider').value = 85;
|
|
||||||
document.getElementById('qualityInput').value = 85;
|
|
||||||
const autoCropToggle = document.getElementById('autoCropToggle');
|
|
||||||
if (autoCropToggle) autoCropToggle.checked = false;
|
|
||||||
setHandedness('right');
|
|
||||||
setOverlap(5);
|
|
||||||
// Update converter variables
|
|
||||||
updateQualitySettings();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBatchModeUI(isBatch) {
|
function updateBatchModeUI(isBatch) {
|
||||||
@@ -2228,6 +2238,7 @@
|
|||||||
advancedOptionsToggle.style.opacity = checked ? '1' : '0.5';
|
advancedOptionsToggle.style.opacity = checked ? '1' : '0.5';
|
||||||
advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none';
|
advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none';
|
||||||
}
|
}
|
||||||
|
updateUploadSettingsPersistence();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAdvancedOptions() {
|
function toggleAdvancedOptions() {
|
||||||
@@ -2265,12 +2276,8 @@
|
|||||||
function setQualityPreset(value) {
|
function setQualityPreset(value) {
|
||||||
document.getElementById('qualitySlider').value = value;
|
document.getElementById('qualitySlider').value = value;
|
||||||
document.getElementById('qualityInput').value = value;
|
document.getElementById('qualityInput').value = value;
|
||||||
// Update active preset
|
|
||||||
document.querySelectorAll('.quality-preset').forEach(btn => {
|
document.querySelectorAll('.quality-preset').forEach(btn => {
|
||||||
btn.classList.remove('active');
|
btn.classList.toggle('active', parseInt(btn.dataset.value, 10) === value);
|
||||||
if (parseInt(btn.dataset.value, 10) === value) {
|
|
||||||
btn.classList.add('active');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
updateQualitySettings();
|
updateQualitySettings();
|
||||||
}
|
}
|
||||||
@@ -2292,6 +2299,7 @@
|
|||||||
if (isImagePickerVisible()) {
|
if (isImagePickerVisible()) {
|
||||||
renderImageGrid();
|
renderImageGrid();
|
||||||
}
|
}
|
||||||
|
updateUploadSettingsPersistence();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHandedness(value) {
|
function setHandedness(value) {
|
||||||
@@ -2304,6 +2312,7 @@
|
|||||||
if (isImagePickerVisible()) {
|
if (isImagePickerVisible()) {
|
||||||
renderImageGrid();
|
renderImageGrid();
|
||||||
}
|
}
|
||||||
|
updateUploadSettingsPersistence();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOverlap(value) {
|
function setOverlap(value) {
|
||||||
@@ -2312,6 +2321,7 @@
|
|||||||
document.querySelectorAll('.overlap-btn').forEach(btn => {
|
document.querySelectorAll('.overlap-btn').forEach(btn => {
|
||||||
btn.classList.toggle('active', parseInt(btn.dataset.value) === value);
|
btn.classList.toggle('active', parseInt(btn.dataset.value) === value);
|
||||||
});
|
});
|
||||||
|
updateUploadSettingsPersistence();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isImagePickerVisible() {
|
function isImagePickerVisible() {
|
||||||
@@ -2980,7 +2990,12 @@
|
|||||||
|
|
||||||
if (qualitySlider && qualityInput) {
|
if (qualitySlider && qualityInput) {
|
||||||
// Initialize converter variables with UI default values
|
// Initialize converter variables with UI default values
|
||||||
updateQualitySettings();
|
suppressUploadSettingsSave = true;
|
||||||
|
try {
|
||||||
|
updateQualitySettings();
|
||||||
|
} finally {
|
||||||
|
suppressUploadSettingsSave = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Deselect all presets when slider is manually changed
|
// Deselect all presets when slider is manually changed
|
||||||
const deselectPresets = function() {
|
const deselectPresets = function() {
|
||||||
@@ -3213,15 +3228,13 @@
|
|||||||
const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub'));
|
const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub'));
|
||||||
if (files.length > 0 && hasEpub) {
|
if (files.length > 0 && hasEpub) {
|
||||||
convertOptions.style.display = 'block';
|
convertOptions.style.display = 'block';
|
||||||
|
toggleConvertOptions();
|
||||||
} else {
|
} else {
|
||||||
convertOptions.style.display = 'none';
|
convertOptions.style.display = 'none';
|
||||||
// Clear stale checkbox state so the "Optimize & Upload" button doesn't linger
|
uploadBtn.textContent = 'Upload';
|
||||||
// when the user re-picks a non-EPUB after having ticked Optimize for an EPUB.
|
uploadBtn.classList.remove('optimize');
|
||||||
const cb = document.getElementById('convertBeforeUpload');
|
document.getElementById('convertInfo').style.display = 'none';
|
||||||
if (cb && cb.checked) {
|
document.getElementById('convertWarning').style.display = 'none';
|
||||||
cb.checked = false;
|
|
||||||
toggleConvertOptions();
|
|
||||||
}
|
|
||||||
if (files.length === 0) clearImagePicker();
|
if (files.length === 0) clearImagePicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3303,6 +3316,75 @@ let ENABLE_GRAYSCALE = DEFAULT_ENABLE_GRAYSCALE;
|
|||||||
let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP;
|
let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP;
|
||||||
let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed)
|
let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed)
|
||||||
let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%)
|
let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%)
|
||||||
|
const UPLOAD_SETTINGS_STORAGE_KEY = 'crosspoint.files.uploadSettings.v1';
|
||||||
|
const DEFAULT_UPLOAD_SETTINGS = Object.freeze({
|
||||||
|
convertBeforeUpload: false,
|
||||||
|
renameFromMetadata: false,
|
||||||
|
quality: DEFAULT_JPEG_QUALITY,
|
||||||
|
autoCrop: DEFAULT_ENABLE_AUTO_CROP,
|
||||||
|
deviceTarget: 'auto',
|
||||||
|
handedness: 'right',
|
||||||
|
overlap: 5,
|
||||||
|
exportLog: false
|
||||||
|
});
|
||||||
|
let suppressUploadSettingsSave = false;
|
||||||
|
|
||||||
|
function getCurrentUploadSettings() {
|
||||||
|
return {
|
||||||
|
convertBeforeUpload: !!document.getElementById('convertBeforeUpload')?.checked,
|
||||||
|
renameFromMetadata: !!document.getElementById('renameFromMetadataToggle')?.checked,
|
||||||
|
quality: parseInt(document.getElementById('qualitySlider')?.value || JPEG_QUALITY, 10),
|
||||||
|
autoCrop: !!document.getElementById('autoCropToggle')?.checked,
|
||||||
|
deviceTarget: DEVICE_TARGET,
|
||||||
|
handedness: HANDEDNESS,
|
||||||
|
overlap: OVERLAP_PERCENT,
|
||||||
|
exportLog: !!document.getElementById('export-log-checkbox')?.checked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUploadSettings(settings = {}) {
|
||||||
|
const merged = { ...DEFAULT_UPLOAD_SETTINGS, ...settings };
|
||||||
|
suppressUploadSettingsSave = true;
|
||||||
|
try {
|
||||||
|
document.getElementById('convertBeforeUpload').checked = !!merged.convertBeforeUpload;
|
||||||
|
document.getElementById('renameFromMetadataToggle').checked = !!merged.renameFromMetadata;
|
||||||
|
document.getElementById('autoCropToggle').checked = !!merged.autoCrop;
|
||||||
|
document.getElementById('export-log-checkbox').checked = !!merged.exportLog;
|
||||||
|
document.getElementById('rememberUploadSettings').checked = !!settings.rememberSettings;
|
||||||
|
|
||||||
|
setQualityPreset(Math.max(1, Math.min(95, parseInt(merged.quality, 10) || DEFAULT_JPEG_QUALITY)));
|
||||||
|
setDeviceTarget(['auto', 'X3', 'X4'].includes(merged.deviceTarget) ? merged.deviceTarget : 'auto');
|
||||||
|
setHandedness(merged.handedness === 'left' ? 'left' : 'right');
|
||||||
|
setOverlap([5, 10, 15].includes(Number(merged.overlap)) ? Number(merged.overlap) : 5);
|
||||||
|
toggleConvertOptions();
|
||||||
|
} finally {
|
||||||
|
suppressUploadSettingsSave = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreUploadSettingsFromStorage() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(UPLOAD_SETTINGS_STORAGE_KEY) || 'null');
|
||||||
|
applyUploadSettings(saved?.rememberSettings ? saved : undefined);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not read remembered upload settings:', e);
|
||||||
|
applyUploadSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUploadSettingsPersistence() {
|
||||||
|
if (suppressUploadSettingsSave) return;
|
||||||
|
try {
|
||||||
|
if (!document.getElementById('rememberUploadSettings')?.checked) {
|
||||||
|
localStorage.removeItem(UPLOAD_SETTINGS_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = { ...getCurrentUploadSettings(), rememberSettings: true };
|
||||||
|
localStorage.setItem(UPLOAD_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not save upload settings:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Image Picker State Management
|
// Image Picker State Management
|
||||||
@@ -3380,6 +3462,7 @@ function applyDeviceTarget() {
|
|||||||
function setDeviceTarget(value) {
|
function setDeviceTarget(value) {
|
||||||
DEVICE_TARGET = value;
|
DEVICE_TARGET = value;
|
||||||
applyDeviceTarget();
|
applyDeviceTarget();
|
||||||
|
updateUploadSettingsPersistence();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch logging system for multiple files
|
// Batch logging system for multiple files
|
||||||
@@ -3788,6 +3871,104 @@ async function findOPFPath(zip) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeMetadataFilenamePart(value) {
|
||||||
|
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
return (text.normalize ? text.normalize('NFC') : text)
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/^[. ]+/g, '')
|
||||||
|
.replace(/[. ]+$/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetadataFilename(title, author) {
|
||||||
|
title = sanitizeMetadataFilenamePart(title);
|
||||||
|
author = sanitizeMetadataFilenamePart(author);
|
||||||
|
if (!title) return '';
|
||||||
|
let base = author ? `${title} - ${author}` : title;
|
||||||
|
if (base.length > 180) base = base.substring(0, 180).replace(/\s+\S*$/g, '').trim() || base.substring(0, 180).trim();
|
||||||
|
return `${base}.epub`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMetadataFilenameForEpub(file) {
|
||||||
|
if (typeof JSZip === 'undefined') return '';
|
||||||
|
const zip = await JSZip.loadAsync(file);
|
||||||
|
const opfPath = await findOPFPath(zip);
|
||||||
|
if (!opfPath || !zip.files[opfPath]) return '';
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(await safeReadText(zip.files[opfPath]), 'application/xml');
|
||||||
|
if (doc.querySelector('parsererror')) return '';
|
||||||
|
|
||||||
|
const title = doc.getElementsByTagNameNS('*', 'title')[0]?.textContent || '';
|
||||||
|
const creators = Array.from(doc.getElementsByTagNameNS('*', 'creator'));
|
||||||
|
const getCreatorRole = el => (
|
||||||
|
el.getAttribute('role') ||
|
||||||
|
el.getAttribute('opf:role') ||
|
||||||
|
el.getAttributeNS('http://www.idpf.org/2007/opf', 'role') ||
|
||||||
|
''
|
||||||
|
).toLowerCase();
|
||||||
|
const authorEl = creators.find(el => getCreatorRole(el) === 'aut') ||
|
||||||
|
creators.find(el => !getCreatorRole(el));
|
||||||
|
const authorText = authorEl?.textContent?.trim();
|
||||||
|
const author = authorText ||
|
||||||
|
authorEl?.getAttribute('file-as') ||
|
||||||
|
authorEl?.getAttribute('opf:file-as') ||
|
||||||
|
authorEl?.getAttributeNS('http://www.idpf.org/2007/opf', 'file-as') ||
|
||||||
|
'';
|
||||||
|
return buildMetadataFilename(title, author);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeRenameEbookFile(file) {
|
||||||
|
const renameToggle = document.getElementById('renameFromMetadataToggle');
|
||||||
|
if (!renameToggle || !renameToggle.checked || !file.name.toLowerCase().endsWith('.epub')) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadataName = await getMetadataFilenameForEpub(file);
|
||||||
|
if (!metadataName || metadataName === file.name) return file;
|
||||||
|
return new File([file], metadataName, {
|
||||||
|
type: file.type || 'application/epub+zip',
|
||||||
|
lastModified: file.lastModified
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not rename EPUB from metadata:', e);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reserveAvailableUploadFilename(fileName, usedFileNames) {
|
||||||
|
const normalize = name => name.toLowerCase();
|
||||||
|
if (!usedFileNames.has(normalize(fileName))) {
|
||||||
|
usedFileNames.add(normalize(fileName));
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotIndex = fileName.lastIndexOf('.');
|
||||||
|
const extensionIndex = dotIndex > 0 ? dotIndex : fileName.length;
|
||||||
|
const baseName = fileName.substring(0, extensionIndex);
|
||||||
|
const extension = fileName.substring(extensionIndex);
|
||||||
|
let nextSuffix = 2;
|
||||||
|
|
||||||
|
let candidateName;
|
||||||
|
do {
|
||||||
|
candidateName = `${baseName} (${nextSuffix})${extension}`;
|
||||||
|
nextSuffix++;
|
||||||
|
} while (usedFileNames.has(normalize(candidateName)));
|
||||||
|
|
||||||
|
usedFileNames.add(normalize(candidateName));
|
||||||
|
return candidateName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchExistingUploadNames() {
|
||||||
|
const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now());
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.status + ' ' + response.statusText);
|
||||||
|
}
|
||||||
|
const entries = await response.json();
|
||||||
|
return new Set(entries.map(entry => entry.name.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a relative href against a base file path.
|
* Resolve a relative href against a base file path.
|
||||||
* Handles multiple ../, ./, absolute /, and bare relative paths.
|
* Handles multiple ../, ./, absolute /, and bare relative paths.
|
||||||
@@ -5219,7 +5400,7 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadFile() {
|
async function uploadFile() {
|
||||||
if (isUploadInProgress) return;
|
if (isUploadInProgress) return;
|
||||||
|
|
||||||
const fileInput = document.getElementById('fileInput');
|
const fileInput = document.getElementById('fileInput');
|
||||||
@@ -5231,8 +5412,17 @@ function uploadFile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent modal close during upload
|
|
||||||
isUploadInProgress = true;
|
isUploadInProgress = true;
|
||||||
|
let usedFileNames;
|
||||||
|
try {
|
||||||
|
usedFileNames = await fetchExistingUploadNames();
|
||||||
|
} catch (error) {
|
||||||
|
isUploadInProgress = false;
|
||||||
|
alert('Failed to check existing files: ' + error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent modal close during upload
|
||||||
uploadGeneration++;
|
uploadGeneration++;
|
||||||
const myGeneration = uploadGeneration;
|
const myGeneration = uploadGeneration;
|
||||||
document.getElementById('uploadModalClose').classList.add('disabled');
|
document.getElementById('uploadModalClose').classList.add('disabled');
|
||||||
@@ -5323,6 +5513,25 @@ function uploadFile() {
|
|||||||
let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded
|
let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded
|
||||||
let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded
|
let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded
|
||||||
|
|
||||||
|
if (isEpub && document.getElementById('renameFromMetadataToggle').checked) {
|
||||||
|
const originalName = file.name;
|
||||||
|
progressText.style.color = '';
|
||||||
|
progressText.textContent = `Reading metadata for ${file.name} (${currentIndex + 1}/${files.length})...`;
|
||||||
|
file = await maybeRenameEbookFile(file);
|
||||||
|
if (file.name !== originalName) {
|
||||||
|
console.log(`[Upload] Renamed from metadata: ${originalName} -> ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableName = reserveAvailableUploadFilename(file.name, usedFileNames);
|
||||||
|
if (availableName !== file.name) {
|
||||||
|
console.log(`[Upload] Renamed to avoid collision: ${file.name} -> ${availableName}`);
|
||||||
|
file = new File([file], availableName, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const methodText = useWebSocket ? ' [WS]' : ' [HTTP]';
|
const methodText = useWebSocket ? ' [WS]' : ' [HTTP]';
|
||||||
const stageText = needsConversion ? 'Converting & uploading' : 'Uploading';
|
const stageText = needsConversion ? 'Converting & uploading' : 'Uploading';
|
||||||
progressText.style.color = '';
|
progressText.style.color = '';
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#include "OpdsFilename.h"
|
||||||
|
|
||||||
|
#include "StringUtils.h"
|
||||||
|
|
||||||
|
std::string opdsBookFilename(const std::string& author, const std::string& title, OpdsFilenameFormat format) {
|
||||||
|
std::string base;
|
||||||
|
switch (format) {
|
||||||
|
case OpdsFilenameFormat::TitleAuthor:
|
||||||
|
base = author.empty() ? title : title + " - " + author;
|
||||||
|
break;
|
||||||
|
case OpdsFilenameFormat::TitleOnly:
|
||||||
|
base = title;
|
||||||
|
break;
|
||||||
|
case OpdsFilenameFormat::AuthorTitle:
|
||||||
|
default:
|
||||||
|
base = author.empty() ? title : author + " - " + title;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// sanitizeFilename caps at 100 bytes and never returns empty (falls back to
|
||||||
|
// "book"); ".epub" is appended after so the extension is never truncated —
|
||||||
|
// identical treatment to the previous inline construction.
|
||||||
|
return StringUtils::sanitizeFilename(base) + ".epub";
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// On-disk filename format for books downloaded from an OPDS server. Stored as a
|
||||||
|
// uint8_t in CrossPointSettings; cast to this enum at the call sites. `Count` is
|
||||||
|
// the number of selectable formats (used to cycle the setting in the UI).
|
||||||
|
enum class OpdsFilenameFormat : uint8_t {
|
||||||
|
AuthorTitle = 0, // "Author - Title.epub" (default; matches legacy behaviour)
|
||||||
|
TitleAuthor = 1, // "Title - Author.epub"
|
||||||
|
TitleOnly = 2, // "Title.epub"
|
||||||
|
Count = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Composes and sanitizes the on-disk filename (including the ".epub" extension)
|
||||||
|
// for a downloaded OPDS book, according to `format`. When the author is empty,
|
||||||
|
// every format collapses to just the sanitized title. Pure: no I/O, no globals.
|
||||||
|
std::string opdsBookFilename(const std::string& author, const std::string& title, OpdsFilenameFormat format);
|
||||||
@@ -43,5 +43,6 @@ add_subdirectory(release_json_parser)
|
|||||||
add_subdirectory(differential_rounding)
|
add_subdirectory(differential_rounding)
|
||||||
add_subdirectory(hyphenation_eval)
|
add_subdirectory(hyphenation_eval)
|
||||||
add_subdirectory(utf8_compose)
|
add_subdirectory(utf8_compose)
|
||||||
|
add_subdirectory(opds_filename)
|
||||||
add_subdirectory(minibidi_arabic)
|
add_subdirectory(minibidi_arabic)
|
||||||
add_subdirectory(combining_marks)
|
add_subdirectory(combining_marks)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
add_executable(OpdsFilenameTest
|
||||||
|
OpdsFilenameTest.cpp
|
||||||
|
${REPO_ROOT}/src/util/OpdsFilename.cpp
|
||||||
|
${REPO_ROOT}/src/util/StringUtils.cpp
|
||||||
|
${REPO_ROOT}/lib/Utf8/Utf8.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(OpdsFilenameTest PRIVATE
|
||||||
|
${REPO_ROOT}/src/util
|
||||||
|
${REPO_ROOT}/lib/Utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(OpdsFilenameTest PRIVATE
|
||||||
|
crosspoint_test_common
|
||||||
|
GTest::gtest_main
|
||||||
|
)
|
||||||
|
|
||||||
|
gtest_discover_tests(OpdsFilenameTest)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "OpdsFilename.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
TEST(OpdsFilename, AuthorTitleIsDefaultOrder) {
|
||||||
|
EXPECT_EQ(opdsBookFilename("J. Doe", "My Book", OpdsFilenameFormat::AuthorTitle), "J. Doe - My Book.epub");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OpdsFilename, TitleAuthorSwapsOrder) {
|
||||||
|
EXPECT_EQ(opdsBookFilename("J. Doe", "My Book", OpdsFilenameFormat::TitleAuthor), "My Book - J. Doe.epub");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OpdsFilename, TitleOnlyIgnoresAuthor) {
|
||||||
|
EXPECT_EQ(opdsBookFilename("J. Doe", "My Book", OpdsFilenameFormat::TitleOnly), "My Book.epub");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OpdsFilename, EmptyAuthorCollapsesToTitleForEveryFormat) {
|
||||||
|
EXPECT_EQ(opdsBookFilename("", "My Book", OpdsFilenameFormat::AuthorTitle), "My Book.epub");
|
||||||
|
EXPECT_EQ(opdsBookFilename("", "My Book", OpdsFilenameFormat::TitleAuthor), "My Book.epub");
|
||||||
|
EXPECT_EQ(opdsBookFilename("", "My Book", OpdsFilenameFormat::TitleOnly), "My Book.epub");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OpdsFilename, IllegalCharactersAreSanitized) {
|
||||||
|
// '/' ':' '*' '?' etc. are replaced with '_' by sanitizeFilename.
|
||||||
|
EXPECT_EQ(opdsBookFilename("A/B", "C:D*E?", OpdsFilenameFormat::AuthorTitle), "A_B - C_D_E_.epub");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OpdsFilename, EmptyAuthorAndTitleFallsBackToBook) {
|
||||||
|
// sanitizeFilename returns "book" when nothing usable remains.
|
||||||
|
EXPECT_EQ(opdsBookFilename("", "", OpdsFilenameFormat::AuthorTitle), "book.epub");
|
||||||
|
EXPECT_EQ(opdsBookFilename("", "", OpdsFilenameFormat::TitleOnly), "book.epub");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OpdsFilename, LongNameIsTruncatedToByteBudgetBeforeExtension) {
|
||||||
|
// sanitizeFilename caps the base at 100 bytes; ".epub" is appended after.
|
||||||
|
const std::string longTitle(200, 'a');
|
||||||
|
const std::string result = opdsBookFilename("", longTitle, OpdsFilenameFormat::TitleOnly);
|
||||||
|
EXPECT_EQ(result, std::string(100, 'a') + ".epub");
|
||||||
|
EXPECT_EQ(result.size(), 105u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OpdsFilename, UnknownFormatValueFallsBackToAuthorTitle) {
|
||||||
|
// Defensive: a persisted value outside the enum still yields a valid name.
|
||||||
|
const auto bogus = static_cast<OpdsFilenameFormat>(99);
|
||||||
|
EXPECT_EQ(opdsBookFilename("J. Doe", "My Book", bogus), "J. Doe - My Book.epub");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
Reference in New Issue
Block a user