Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd00c185f2 |
+29
-42
@@ -28,10 +28,8 @@ 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.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||
- [Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)](#option-a-crosspoint-sync-server-synccrosspointreadercom-default)
|
||||
- [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)
|
||||
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
|
||||
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
|
||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||
- [Cover settings](#cover-settings)
|
||||
- [Custom images](#custom-images)
|
||||
@@ -299,7 +297,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.
|
||||
|
||||
- **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.
|
||||
- **KOReader Sync**: Options for setting up KOReader for syncing book progress.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -362,37 +360,9 @@ Behavior notes:
|
||||
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.
|
||||
|
||||
##### Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)
|
||||
##### Option A: Free Public Server (`sync.koreader.rocks`)
|
||||
|
||||
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:
|
||||
1. Register a user once (only if needed):
|
||||
|
||||
```bash
|
||||
USERNAME="user"
|
||||
@@ -405,9 +375,27 @@ curl -i "https://sync.koreader.rocks/users/create" \
|
||||
--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.
|
||||
|
||||
##### Option C: Self-Hosted Server (Docker Compose)
|
||||
2. 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).
|
||||
|
||||
- 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:
|
||||
|
||||
@@ -480,12 +468,11 @@ 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).
|
||||
|
||||
##### Syncing While Reading
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
5. 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.
|
||||
|
||||
### 3.7 Sleep Screen
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#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,30 +287,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
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()) {
|
||||
// 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;
|
||||
size_t tokenStart = 0;
|
||||
for (const size_t breakOffset : breakOffsets) {
|
||||
@@ -349,8 +326,29 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
|
||||
// --- FOCUS READING LOGIC BELOW ---
|
||||
|
||||
// Worst case: a segment boundary on each byte (highly punctuated UTF-8 text).
|
||||
ensureTokenCapacity(word.length());
|
||||
// Pre-reserve capacity to prevent mid-word heap reallocations.
|
||||
size_t maxPossibleNewTokens = 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
|
||||
// Use std::string_view to avoid heap allocations when slicing
|
||||
|
||||
+38
-26
@@ -39,8 +39,34 @@ 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(uint8_t) + sizeof(bool) + 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
|
||||
|
||||
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
|
||||
// constructed/destroyed where the parser's full definition is visible.
|
||||
Section::Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
|
||||
@@ -133,31 +159,11 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
}
|
||||
filePartial = (version == SECTION_FILE_PARTIAL_VERSION);
|
||||
|
||||
int fileFontId;
|
||||
uint16_t fileViewportWidth, fileViewportHeight;
|
||||
float fileLineCompression;
|
||||
bool fileExtraParagraphSpacing;
|
||||
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) {
|
||||
const RenderParams fileParams = readHeaderRenderParams(file);
|
||||
const RenderParams params = {fontId, lineCompression, extraParagraphSpacing, paragraphAlignment,
|
||||
viewportWidth, viewportHeight, hyphenationEnabled, embeddedStyle,
|
||||
imageRendering, focusReadingEnabled};
|
||||
if (!(fileParams == params)) {
|
||||
file.close();
|
||||
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
|
||||
clearCache();
|
||||
@@ -754,7 +760,7 @@ std::string Section::getTextFromSectionFile() {
|
||||
return fullText;
|
||||
}
|
||||
|
||||
std::optional<uint16_t> Section::getCachedPageCount() const {
|
||||
std::optional<uint16_t> Section::getCachedPageCount(const RenderParams* mustMatch) const {
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("SCT", filePath, f)) {
|
||||
return std::nullopt;
|
||||
@@ -774,6 +780,12 @@ std::optional<uint16_t> Section::getCachedPageCount() const {
|
||||
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));
|
||||
uint16_t count;
|
||||
serialization::readPod(f, count);
|
||||
|
||||
+21
-1
@@ -78,6 +78,23 @@ class Section {
|
||||
std::unique_ptr<Page> loadPageDuringBuild(int page);
|
||||
|
||||
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;
|
||||
int currentPage = 0;
|
||||
|
||||
@@ -144,7 +161,10 @@ class Section {
|
||||
std::optional<uint16_t> findAnchorDuringBuild(const std::string& anchor) const;
|
||||
|
||||
// Get the page count from the section cache file without fully loading it.
|
||||
std::optional<uint16_t> getCachedPageCount() const;
|
||||
// Finalized files only (a partial's count is just a build watermark). When
|
||||
// `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].
|
||||
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
|
||||
|
||||
@@ -22,17 +22,6 @@
|
||||
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
|
||||
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
|
||||
// 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
|
||||
@@ -1166,14 +1155,12 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
||||
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
|
||||
}
|
||||
|
||||
// Keep token growth bounded: CSS-heavy spans can fragment text into many tiny
|
||||
// words, so flush earlier when embedded CSS is active. We still keep the
|
||||
// "exclude last line" behavior to preserve paragraph flow across chunks.
|
||||
const size_t blockWordCount = self->currentTextBlock->size();
|
||||
const size_t softFlushThreshold =
|
||||
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));
|
||||
// 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.
|
||||
if (self->currentTextBlock->size() > 750) {
|
||||
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
|
||||
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
|
||||
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
|
||||
? static_cast<uint16_t>(self->viewportWidth - horizontalInset)
|
||||
|
||||
@@ -298,11 +298,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв"
|
||||
STR_SLEEP_NEVER: "Ніколі"
|
||||
STR_STEP_HINT_FRONT: "Пярэднія кнопкі:"
|
||||
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_PAGES_PER_MIN: "Аўтаперагортванне (старонак за хвіліну)"
|
||||
STR_TILT_PAGE_TURN: "Перагортванне нахілам"
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
_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,11 +347,6 @@ STR_SERVER_NAME: "Nom del servidor"
|
||||
STR_NO_SERVERS: "No hi ha servidors OPDS configurats"
|
||||
STR_DELETE_SERVER: "Suprimeix el servidor"
|
||||
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_FONT_BROWSER: "Navegador de fonts"
|
||||
STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..."
|
||||
|
||||
@@ -270,11 +270,6 @@ STR_BOOK_S_STYLE: "Styl knihy"
|
||||
STR_EMBEDDED_STYLE: "Vložený styl"
|
||||
STR_FOCUS_READING: "Soustředěné čtení"
|
||||
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_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikdy"
|
||||
|
||||
@@ -300,11 +300,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Aldrig"
|
||||
STR_STEP_HINT_FRONT: "Frontknapper:"
|
||||
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_AUTO_TURN_ENABLED: "Automatisk sidevendning aktiveret: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vending (sider per minut)"
|
||||
|
||||
@@ -300,11 +300,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nooit"
|
||||
STR_STEP_HINT_FRONT: "Voorknoppen:"
|
||||
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_AUTO_TURN_ENABLED: "Automatisch omslaan ingeschakeld: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Autom. omslaan (pagina's per minuut)"
|
||||
|
||||
@@ -10,7 +10,6 @@ STR_BROWSE_FILES: "Browse Files"
|
||||
STR_FILE_TRANSFER: "File Transfer"
|
||||
STR_SETTINGS_TITLE: "Settings"
|
||||
STR_CONTINUE_READING: "Continue Reading"
|
||||
STR_RESUME: "Resume"
|
||||
STR_NO_OPEN_BOOK: "No open book"
|
||||
STR_START_READING: "Start reading below"
|
||||
STR_NO_FILES_FOUND: "No files found"
|
||||
@@ -109,15 +108,7 @@ STR_PASSWORD: "Password"
|
||||
STR_SYNC_SERVER_URL: "Sync Server URL"
|
||||
STR_DOCUMENT_MATCHING: "Document Matching"
|
||||
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_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_PASSWORD: "KOReader Password"
|
||||
STR_FILENAME: "Filename"
|
||||
@@ -238,7 +229,7 @@ STR_OK_BUTTON: "OK"
|
||||
STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter"
|
||||
STR_FILTER_CONTRAST: "Contrast"
|
||||
STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar"
|
||||
STR_CHAPTER_PAGE_COUNT: "Chapter Page Count"
|
||||
STR_CHAPTER_PAGE_COUNT: "Page Count"
|
||||
STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage"
|
||||
STR_PROGRESS_BAR: "Progress Bar"
|
||||
STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness"
|
||||
@@ -333,7 +324,6 @@ STR_UPLOAD_LOCAL: "Upload local progress"
|
||||
STR_NO_REMOTE_MSG: "No remote progress found"
|
||||
STR_UPLOAD_PROMPT: "Upload current position?"
|
||||
STR_UPLOAD_SUCCESS: "Progress uploaded!"
|
||||
STR_ALREADY_SYNCED: "Already synced"
|
||||
STR_SYNC_FAILED_MSG: "Sync failed"
|
||||
STR_SAVE_PROGRESS_FAILED: "Could not save progress"
|
||||
STR_SECTION_PREFIX: "Section "
|
||||
@@ -343,7 +333,6 @@ STR_EMBEDDED_STYLE: "Embedded Style"
|
||||
STR_FOCUS_READING: "Focus Reading"
|
||||
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
||||
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_FOOTNOTES: "Footnotes"
|
||||
STR_NO_FOOTNOTES: "No footnotes on this page"
|
||||
@@ -358,12 +347,6 @@ STR_SERVER_NAME: "Server Name"
|
||||
STR_NO_SERVERS: "No OPDS servers configured"
|
||||
STR_DELETE_SERVER: "Delete Server"
|
||||
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_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
|
||||
STR_MANAGE_FONTS: "Manage Fonts"
|
||||
|
||||
@@ -268,11 +268,6 @@ STR_BOOK_S_STYLE: "Kirjan tyyli"
|
||||
STR_EMBEDDED_STYLE: "Upotettu tyyli"
|
||||
STR_FOCUS_READING: "Keskittynyt lukeminen"
|
||||
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_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Ei koskaan"
|
||||
|
||||
@@ -301,11 +301,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Jamais"
|
||||
STR_STEP_HINT_FRONT: "Boutons avant :"
|
||||
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_AUTO_TURN_ENABLED: "Tourne-page auto : "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)"
|
||||
|
||||
@@ -315,7 +315,6 @@ STR_BOOK_S_STYLE: "Buch-Stil"
|
||||
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
||||
STR_FOCUS_READING: "Fokus-Lesen"
|
||||
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_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
|
||||
STR_FOOTNOTES: "Fußnoten"
|
||||
@@ -330,11 +329,6 @@ STR_SERVER_NAME: "Servername"
|
||||
STR_NO_SERVERS: "Keine OPDS-Server konfiguriert"
|
||||
STR_DELETE_SERVER: "Server entfernen"
|
||||
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_PAGES_PER_MIN: "Auto-Umblättern (Seiten/Min.)"
|
||||
STR_IMAGES: "Bilder"
|
||||
|
||||
@@ -306,11 +306,6 @@ STR_NO_SERVERS: "לא הוגדרו שרתי OPDS"
|
||||
STR_DELETE_SERVER: "מחק שרת"
|
||||
STR_DELETE_CONFIRM: "למחוק שרת זה?"
|
||||
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_PAGES_PER_MIN: "דפדוף אוטומטי (דפים בדקה)"
|
||||
STR_MANAGE_FONTS: "ניהול גופנים"
|
||||
|
||||
@@ -299,11 +299,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc"
|
||||
STR_SLEEP_NEVER: "Soha"
|
||||
STR_STEP_HINT_FRONT: "Elülső 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_AUTO_TURN_ENABLED: "Automatikus lapozás bekapcsolva: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatikus lapozás (oldal/perc)"
|
||||
|
||||
@@ -315,11 +315,6 @@ STR_SERVER_NAME: "Nome server"
|
||||
STR_NO_SERVERS: "Nessun server OPDS configurato"
|
||||
STR_DELETE_SERVER: "Elimina server"
|
||||
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_PAGES_PER_MIN: "Volta pagina automatico (pag/min)"
|
||||
STR_MANAGE_FONTS: "Gestisci font"
|
||||
|
||||
@@ -296,11 +296,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин"
|
||||
STR_SLEEP_NEVER: "Ешқашан"
|
||||
STR_STEP_HINT_FRONT: "Алдыңғы түймелер:"
|
||||
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_AUTO_TURN_ENABLED: "Автоматты бет аудару қосулы: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Автоматты бет аудару (минутына бет саны)"
|
||||
|
||||
@@ -297,11 +297,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min."
|
||||
STR_SLEEP_NEVER: "Niekada"
|
||||
STR_STEP_HINT_FRONT: "Priekiniai 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_AUTO_TURN_ENABLED: "Auto-vertimas: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-vertimas (psl/min)"
|
||||
|
||||
@@ -316,11 +316,6 @@ STR_SERVER_NAME: "Nazwa serwera"
|
||||
STR_NO_SERVERS: "Brak skonfigurowanych serwerów OPDS"
|
||||
STR_DELETE_SERVER: "Usuń serwer"
|
||||
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_PAGES_PER_MIN: "Auto-kartkowanie (str./min)"
|
||||
STR_DOWNLOAD_FONTS: "Pobierz czcionki"
|
||||
|
||||
@@ -339,11 +339,6 @@ STR_SERVER_NAME: "Nome do Servidor"
|
||||
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
||||
STR_DELETE_SERVER: "Excluir Servidor"
|
||||
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_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
|
||||
STR_MANAGE_FONTS: "Gerenciar fontes"
|
||||
|
||||
@@ -300,11 +300,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Niciodată"
|
||||
STR_STEP_HINT_FRONT: "Butoane frontale:"
|
||||
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_AUTO_TURN_ENABLED: "Răsfoire automată: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Pagini pe minut"
|
||||
|
||||
@@ -339,11 +339,6 @@ STR_SERVER_NAME: "Имя сервера"
|
||||
STR_NO_SERVERS: "Нет настроенных серверов OPDS"
|
||||
STR_DELETE_SERVER: "Удалить сервер"
|
||||
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_PAGES_PER_MIN: "Автоперелистывание (стр./мин)"
|
||||
STR_MANAGE_FONTS: "Управление шрифтами"
|
||||
|
||||
@@ -335,11 +335,6 @@ STR_SERVER_NAME: "Názov servera"
|
||||
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
|
||||
STR_DELETE_SERVER: "Odstrániť server"
|
||||
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_PAGES_PER_MIN: "Automatické otáčanie (strán za minútu)"
|
||||
STR_MANAGE_FONTS: "Správa písiem"
|
||||
|
||||
@@ -297,11 +297,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
|
||||
STR_SLEEP_NEVER: "Nikoli"
|
||||
STR_STEP_HINT_FRONT: "Sprednji 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_AUTO_TURN_ENABLED: "Samodejno obračanje: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
|
||||
|
||||
@@ -345,11 +345,6 @@ STR_SERVER_NAME: "Nombre de servidor"
|
||||
STR_NO_SERVERS: "No se configuraron servidores OPDS"
|
||||
STR_DELETE_SERVER: "Borrar servidor"
|
||||
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_PAGES_PER_MIN: "Avance auto. (pág./min)"
|
||||
STR_MANAGE_FONTS: "Gestionar tipografías"
|
||||
|
||||
@@ -342,11 +342,6 @@ STR_SERVER_NAME: "Servernamn"
|
||||
STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade"
|
||||
STR_DELETE_SERVER: "Ta bort server"
|
||||
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_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)"
|
||||
STR_MANAGE_FONTS: "Hantera teckensnitt"
|
||||
|
||||
@@ -291,11 +291,6 @@ STR_SLEEP_TIMER_VALUE_FORMAT: "%u dak"
|
||||
STR_SLEEP_NEVER: "Asla"
|
||||
STR_STEP_HINT_FRONT: "Ön 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_FOOTNOTES: "Bu sayfada dipnot yok"
|
||||
STR_PREVIEW: "Önizleme"
|
||||
|
||||
@@ -336,11 +336,6 @@ STR_SERVER_NAME: "Назва сервера"
|
||||
STR_NO_SERVERS: "Не налаштовано жодного сервера OPDS"
|
||||
STR_DELETE_SERVER: "Видалити сервер"
|
||||
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_PAGES_PER_MIN: "Автоперегортання (ст/хв)"
|
||||
STR_MANAGE_FONTS: "Керування шрифтами"
|
||||
|
||||
@@ -348,11 +348,6 @@ STR_SERVER_NAME: "Nom del servidor"
|
||||
STR_NO_SERVERS: "No hi ha servidors OPDS configurats"
|
||||
STR_DELETE_SERVER: "Elimina el servidor"
|
||||
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_FONT_BROWSER: "Navegador de fonts"
|
||||
STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..."
|
||||
|
||||
@@ -335,11 +335,6 @@ STR_SERVER_NAME: "Tên máy chủ"
|
||||
STR_NO_SERVERS: "Chưa cấu hình máy chủ OPDS"
|
||||
STR_DELETE_SERVER: "Xóa máy chủ"
|
||||
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_PAGES_PER_MIN: "Tự lật (số trang mỗi phút)"
|
||||
STR_MANAGE_FONTS: "Quản lý phông chữ"
|
||||
|
||||
@@ -5,27 +5,16 @@
|
||||
#include <ObfuscationUtils.h>
|
||||
|
||||
namespace {
|
||||
// Default sync server URL. crosspoint-sync speaks the full KOSync protocol, so
|
||||
// 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;
|
||||
// Default sync server URL
|
||||
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
||||
} // namespace
|
||||
|
||||
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
||||
doc["cfgVersion"] = CONFIG_VERSION;
|
||||
doc["username"] = getUsername();
|
||||
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
||||
doc["serverUrl"] = getServerUrl();
|
||||
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
|
||||
doc["sendMetadata"] = getSendMetadata();
|
||||
doc["syncBehavior"] = static_cast<uint8_t>(getSyncBehavior());
|
||||
}
|
||||
|
||||
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||
@@ -37,19 +26,6 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||
setCredentials(user, pass);
|
||||
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;
|
||||
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
||||
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
||||
@@ -59,18 +35,6 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||
}
|
||||
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) {
|
||||
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
||||
saveToFile();
|
||||
@@ -141,11 +105,3 @@ void KOReaderCredentialStore::setSendMetadata(bool enabled) {
|
||||
sendMetadata = enabled;
|
||||
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,12 +11,6 @@ enum class DocumentMatchMethod : uint8_t {
|
||||
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.
|
||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||
@@ -31,7 +25,6 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
|
||||
std::string serverUrl; // Custom sync server URL (empty = default)
|
||||
DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility
|
||||
bool sendMetadata = false; // Send document metadata with progress sync
|
||||
KOReaderSyncBehavior syncBehavior = KOReaderSyncBehavior::SMART;
|
||||
|
||||
// Private constructor for singleton
|
||||
KOReaderCredentialStore() = default;
|
||||
@@ -72,10 +65,6 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
|
||||
// Send metadata setting
|
||||
void setSendMetadata(bool enabled);
|
||||
bool getSendMetadata() const { return sendMetadata; }
|
||||
|
||||
// Sync behavior
|
||||
void setSyncBehavior(KOReaderSyncBehavior behavior);
|
||||
KOReaderSyncBehavior getSyncBehavior() const { return syncBehavior; }
|
||||
};
|
||||
|
||||
// Helper macro to access credential store
|
||||
|
||||
@@ -78,43 +78,6 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
||||
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,
|
||||
KOReaderProgress& outProgress) {
|
||||
lastHttpCode = 0;
|
||||
@@ -161,24 +124,6 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
||||
outProgress.deviceId = doc["device_id"].as<std::string>();
|
||||
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());
|
||||
return OK;
|
||||
}
|
||||
@@ -213,18 +158,6 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
|
||||
doc["percentage"] = progress.percentage;
|
||||
doc["device"] = DEVICE_NAME;
|
||||
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;
|
||||
serializeJson(doc, body);
|
||||
|
||||
@@ -14,33 +14,17 @@ struct KOReaderMetadata {
|
||||
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.
|
||||
*/
|
||||
struct KOReaderProgress {
|
||||
std::string document; // Document hash
|
||||
std::string progress; // XPath-like progress string
|
||||
float percentage; // Progress percentage (0.0 to 1.0)
|
||||
std::string device; // Device name
|
||||
std::string deviceId; // Device ID
|
||||
int64_t timestamp; // Unix timestamp of last update
|
||||
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
||||
std::optional<KOReaderRichPosition> position; // Optional rich position (crosspoint-sync servers only)
|
||||
std::string document; // Document hash
|
||||
std::string progress; // XPath-like progress string
|
||||
float percentage; // Progress percentage (0.0 to 1.0)
|
||||
std::string device; // Device name
|
||||
std::string deviceId; // Device ID
|
||||
int64_t timestamp; // Unix timestamp of last update
|
||||
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,17 +43,7 @@ struct KOReaderProgress {
|
||||
*/
|
||||
class KOReaderSyncClient {
|
||||
public:
|
||||
enum Error {
|
||||
OK = 0,
|
||||
NO_CREDENTIALS,
|
||||
NETWORK_ERROR,
|
||||
AUTH_FAILED,
|
||||
SERVER_ERROR,
|
||||
JSON_ERROR,
|
||||
NOT_FOUND,
|
||||
LOW_MEMORY,
|
||||
USER_EXISTS
|
||||
};
|
||||
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND, LOW_MEMORY };
|
||||
|
||||
/**
|
||||
* Authenticate with the sync server (validate credentials).
|
||||
@@ -77,14 +51,6 @@ class KOReaderSyncClient {
|
||||
*/
|
||||
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.
|
||||
* @param documentHash The document hash (from KOReaderDocumentId)
|
||||
|
||||
@@ -724,59 +724,6 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
||||
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,
|
||||
GfxRenderer& renderer, int currentSpineIndex,
|
||||
int totalPagesInCurrentSpine, int fallbackTotalPages) {
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "KOReaderSyncClient.h"
|
||||
|
||||
/**
|
||||
* CrossPoint position representation.
|
||||
*/
|
||||
@@ -68,20 +65,6 @@ class ProgressMapper {
|
||||
GfxRenderer& renderer, int currentSpineIndex = -1,
|
||||
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:
|
||||
/**
|
||||
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
|
||||
|
||||
+19
-8
@@ -225,17 +225,29 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||
|
||||
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
|
||||
|
||||
bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
|
||||
void HalGPIO::startDeepSleep() {
|
||||
// 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) {
|
||||
// Fast path - no duration check needed
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
|
||||
// Calibrate: subtract boot time already elapsed, assuming button held since boot.
|
||||
const unsigned long calibration = millis();
|
||||
const unsigned long calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1;
|
||||
// Calibrate: subtract boot time already elapsed, assuming button held since boot
|
||||
const uint16_t calibration = millis();
|
||||
const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1;
|
||||
|
||||
const auto start = millis();
|
||||
inputMgr.update();
|
||||
@@ -250,12 +262,11 @@ bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre
|
||||
inputMgr.update();
|
||||
} while (inputMgr.isPressed(BTN_POWER) && inputMgr.getPowerButtonHeldTime() < calibratedDuration);
|
||||
if (inputMgr.getPowerButtonHeldTime() < calibratedDuration) {
|
||||
return false;
|
||||
startDeepSleep();
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
startDeepSleep();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalGPIO::isUsbConnected() const {
|
||||
|
||||
+5
-2
@@ -72,10 +72,13 @@ class HalGPIO {
|
||||
unsigned long getHeldTime() const;
|
||||
unsigned long getPowerButtonHeldTime() const;
|
||||
|
||||
// Setup wake up GPIO and enter deep sleep
|
||||
void startDeepSleep();
|
||||
|
||||
// Verify power button was held long enough after wakeup.
|
||||
// Returns true if verification succeeded, false if device should return to sleep.
|
||||
// If verification fails, enters deep sleep and does not return.
|
||||
// Should only be called when wakeup reason is PowerButton.
|
||||
bool verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed);
|
||||
void verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed);
|
||||
|
||||
// Check if USB is connected
|
||||
bool isUsbConnected() const;
|
||||
|
||||
+12
-13
@@ -29,8 +29,8 @@ class CrossPointSettings {
|
||||
LIGHT = 1,
|
||||
CUSTOM = 2,
|
||||
COVER = 3,
|
||||
COVER_CUSTOM = 4,
|
||||
BLANK = 5,
|
||||
BLANK = 4,
|
||||
COVER_CUSTOM = 5,
|
||||
QUICK_RESUME = 6,
|
||||
SLEEP_SCREEN_MODE_COUNT
|
||||
};
|
||||
@@ -58,6 +58,14 @@ class CrossPointSettings {
|
||||
HIDE_PROGRESS = 2,
|
||||
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 {
|
||||
PROGRESS_BAR_THIN = 0,
|
||||
PROGRESS_BAR_NORMAL = 1,
|
||||
@@ -189,7 +197,8 @@ class CrossPointSettings {
|
||||
uint8_t sleepScreenCoverFilter = NO_FILTER;
|
||||
// Status bar settings (statusBar retained for migration only)
|
||||
uint8_t statusBar = FULL;
|
||||
uint8_t statusBarChapterPageCount = 1;
|
||||
// STATUS_BAR_PAGE_COUNT; persisted under the legacy "statusBarChapterPageCount" key.
|
||||
uint8_t statusBarPageCount = CHAPTER_PAGE_COUNT;
|
||||
uint8_t statusBarBookProgressPercentage = 1;
|
||||
uint8_t statusBarProgressBar = HIDE_PROGRESS;
|
||||
uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL;
|
||||
@@ -238,14 +247,6 @@ class CrossPointSettings {
|
||||
|
||||
// Reader screen margin settings
|
||||
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
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press page turn button behavior
|
||||
@@ -271,8 +272,6 @@ class CrossPointSettings {
|
||||
uint8_t removeReadBooksFromRecents = 0;
|
||||
// Move epub to /Read/ folder on SD card when finished (0 = disabled, 1 = enabled)
|
||||
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
|
||||
uint8_t imageRendering = IMAGES_DISPLAY;
|
||||
// Tilt-based page turning (X3 only — requires QMI8658 IMU)
|
||||
|
||||
@@ -21,35 +21,35 @@
|
||||
void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
|
||||
switch (static_cast<CrossPointSettings::STATUS_BAR_MODE>(settings.statusBar)) {
|
||||
case CrossPointSettings::NONE:
|
||||
settings.statusBarChapterPageCount = 0;
|
||||
settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
|
||||
settings.statusBarBookProgressPercentage = 0;
|
||||
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
||||
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
|
||||
settings.statusBarBattery = 0;
|
||||
break;
|
||||
case CrossPointSettings::NO_PROGRESS:
|
||||
settings.statusBarChapterPageCount = 0;
|
||||
settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
|
||||
settings.statusBarBookProgressPercentage = 0;
|
||||
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
||||
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
||||
settings.statusBarBattery = 1;
|
||||
break;
|
||||
case CrossPointSettings::BOOK_PROGRESS_BAR:
|
||||
settings.statusBarChapterPageCount = 1;
|
||||
settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
|
||||
settings.statusBarBookProgressPercentage = 0;
|
||||
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
|
||||
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
||||
settings.statusBarBattery = 1;
|
||||
break;
|
||||
case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR:
|
||||
settings.statusBarChapterPageCount = 1;
|
||||
settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
|
||||
settings.statusBarBookProgressPercentage = 0;
|
||||
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
|
||||
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
|
||||
settings.statusBarBattery = 0;
|
||||
break;
|
||||
case CrossPointSettings::CHAPTER_PROGRESS_BAR:
|
||||
settings.statusBarChapterPageCount = 0;
|
||||
settings.statusBarPageCount = CrossPointSettings::HIDE_PAGE_COUNT;
|
||||
settings.statusBarBookProgressPercentage = 1;
|
||||
settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS;
|
||||
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
||||
@@ -57,7 +57,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
|
||||
break;
|
||||
case CrossPointSettings::FULL:
|
||||
default:
|
||||
settings.statusBarChapterPageCount = 1;
|
||||
settings.statusBarPageCount = CrossPointSettings::CHAPTER_PAGE_COUNT;
|
||||
settings.statusBarBookProgressPercentage = 1;
|
||||
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
|
||||
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
|
||||
|
||||
+4
-22
@@ -181,8 +181,6 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
|
||||
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Toggle(StrId::STR_BACK_SHORT_TO_FILE_BROWSER, &CrossPointSettings::backShortToFileBrowser,
|
||||
"backShortToFileBrowser", StrId::STR_CAT_CONTROLS),
|
||||
|
||||
// --- System ---
|
||||
SettingInfo::Value(
|
||||
@@ -196,16 +194,6 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
SettingInfo::Toggle(StrId::STR_MOVE_FINISHED_TO_READ, &CrossPointSettings::moveFinishedToReadFolder,
|
||||
"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) ---
|
||||
SettingInfo::DynamicString(
|
||||
StrId::STR_KOREADER_USERNAME, [] { return KOREADER_STORE.getUsername(); },
|
||||
@@ -244,17 +232,11 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
KOREADER_STORE.saveToFile();
|
||||
},
|
||||
"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) ---
|
||||
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount,
|
||||
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
// Key kept from the old show/hide toggle so existing settings files load unchanged.
|
||||
SettingInfo::Enum(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarPageCount,
|
||||
{StrId::STR_HIDE, StrId::STR_CHAPTER, StrId::STR_BOOK}, "statusBarChapterPageCount",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage,
|
||||
"statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar,
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <OpdsStream.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "SilentRestart.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
@@ -17,7 +15,6 @@
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
#include "util/BookCacheUtils.h"
|
||||
#include "util/OpdsFilename.h"
|
||||
#include "util/StringUtils.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
@@ -282,26 +279,8 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
// Build full download URL relative to the current feed, not the root server URL
|
||||
const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath);
|
||||
std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href);
|
||||
// opdsDownloadFolder is already a null-terminated char[64]; use it directly —
|
||||
// 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));
|
||||
std::string filename =
|
||||
"/" + StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) + ".epub";
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
|
||||
int lastRenderedPercent = -1;
|
||||
|
||||
@@ -179,17 +179,6 @@ void HomeActivity::loop() {
|
||||
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 (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
@@ -267,8 +256,7 @@ void HomeActivity::render(RenderLock&&) {
|
||||
[&menuItems](int index) { return std::string(menuItems[index]); },
|
||||
[&menuIcons](int index) { return menuIcons[index]; });
|
||||
|
||||
const auto labels = mappedInput.mapLabels(recentBooks.empty() ? "" : tr(STR_RESUME), tr(STR_SELECT), tr(STR_DIR_UP),
|
||||
tr(STR_DIR_DOWN));
|
||||
const auto labels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -18,9 +18,6 @@ class HomeActivity final : public Activity {
|
||||
bool hasOpdsServers = false;
|
||||
bool coverRendered = false; // Track if cover has been rendered once
|
||||
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
|
||||
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
|
||||
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
|
||||
#include "CrossPointSettings.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 "fontIds.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
@@ -44,6 +44,22 @@ constexpr int PAGE_TURN_RATES[] = {1, 1, 3, 6, 12};
|
||||
constexpr size_t initialBookmarkCacheCapacity = 16;
|
||||
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) {
|
||||
if (percent < 0) {
|
||||
return 0;
|
||||
@@ -319,6 +335,25 @@ 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
|
||||
// finished. Two independent finished-book features key off this same condition.
|
||||
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
|
||||
@@ -448,15 +483,20 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
// Short press Back restores position when viewing a footnote (takes priority over navigation)
|
||||
if (footnoteDepth > 0 && mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() < ReaderUtils::GO_BACK_OR_HOME_MS) {
|
||||
restoreSavedPosition();
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
activityManager.goToFileBrowser(epub ? epub->getPath() : "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, epub ? epub->getPath().c_str() : "",
|
||||
{this, [](void* ctx) { static_cast<EpubReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||
// Short press BACK goes directly to home (or restores position if viewing footnote)
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
if (footnoteDepth > 0) {
|
||||
restoreSavedPosition();
|
||||
return;
|
||||
}
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -975,6 +1015,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
buildViewportWidth = viewportWidth;
|
||||
buildViewportHeight = viewportHeight;
|
||||
|
||||
ensureBookPages(viewportWidth, viewportHeight);
|
||||
|
||||
if (!section) {
|
||||
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
|
||||
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
||||
@@ -1217,6 +1259,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
||||
applyDeferredReposition();
|
||||
|
||||
recordCurrentSectionPages();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
if (section->pageCount == 0) {
|
||||
@@ -1326,6 +1370,59 @@ bool EpubReaderActivity::applyDeferredReposition() {
|
||||
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) {
|
||||
return EpubReaderUtils::saveProgress(*epub, spineIndex, currentPage, pageCount);
|
||||
}
|
||||
@@ -1507,10 +1604,21 @@ void EpubReaderActivity::renderStatusBar() const {
|
||||
// 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.
|
||||
const int currentPage = section->currentPage + 1;
|
||||
const float pageCount = section->estimatedTotalPages();
|
||||
const int pageCount = section->estimatedTotalPages();
|
||||
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
|
||||
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;
|
||||
|
||||
int textYOffset = 0;
|
||||
@@ -1538,8 +1646,8 @@ void EpubReaderActivity::renderStatusBar() const {
|
||||
title = epub->getTitle();
|
||||
}
|
||||
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
||||
section->isBuilding());
|
||||
GUI.drawStatusBar(renderer, bookProgress, counterPage, counterTotal, title, 0, textYOffset, true,
|
||||
currentPageBookmarked, counterIsEstimate);
|
||||
}
|
||||
|
||||
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include <Epub.h>
|
||||
#include <Epub/BookPages.h>
|
||||
#include <Epub/FootnoteEntry.h>
|
||||
#include <Epub/Section.h>
|
||||
|
||||
@@ -23,6 +24,17 @@ class EpubReaderActivity final : public Activity {
|
||||
int pagesUntilFullRefresh = 0;
|
||||
int cachedSpineIndex = 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 pageTurnDuration = 0UL;
|
||||
// Signals that the next render should reposition within the newly loaded section
|
||||
@@ -113,6 +125,14 @@ class EpubReaderActivity final : public Activity {
|
||||
// (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).
|
||||
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);
|
||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||
void jumpToPercent(int percent);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
|
||||
#include "Epub/Section.h"
|
||||
#include "EpubReaderUtils.h"
|
||||
@@ -25,19 +24,6 @@
|
||||
#include "fontIds.h"
|
||||
|
||||
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() {
|
||||
// Stop SNTP if already running (can't reconfigure while running)
|
||||
if (esp_sntp_enabled()) {
|
||||
@@ -98,21 +84,6 @@ void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
|
||||
|
||||
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) {
|
||||
if (!success) {
|
||||
LOG_DBG("KOSync", "WiFi connection failed, exiting");
|
||||
@@ -142,8 +113,12 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::performSync() {
|
||||
const DocumentMatchMethod primaryMethod = KOREADER_STORE.getMatchMethod();
|
||||
documentHash = calculateDocumentHashForMethod(epubPath, primaryMethod);
|
||||
// Calculate document hash based on user's preferred method
|
||||
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
|
||||
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
|
||||
} else {
|
||||
documentHash = KOReaderDocumentId::calculate(epubPath);
|
||||
}
|
||||
if (documentHash.empty()) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
@@ -153,9 +128,8 @@ void KOReaderSyncActivity::performSync() {
|
||||
requestUpdate(true);
|
||||
return;
|
||||
}
|
||||
const std::string primaryHash = documentHash;
|
||||
|
||||
LOG_DBG("KOSync", "Document hash (%s): %s", matchMethodName(primaryMethod), documentHash.c_str());
|
||||
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
@@ -163,42 +137,10 @@ void KOReaderSyncActivity::performSync() {
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
// Fetch remote progress. In smart mode, also probe the alternate document-id
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fetch remote progress
|
||||
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
|
||||
|
||||
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
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
@@ -232,42 +174,8 @@ void KOReaderSyncActivity::performSync() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the exact spine/page from a crosspoint-sync rich position (lossless
|
||||
// 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;
|
||||
}
|
||||
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
||||
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
|
||||
|
||||
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
|
||||
{
|
||||
@@ -298,45 +206,17 @@ void KOReaderSyncActivity::performUpload() {
|
||||
progress.progress = localProgress.xpath;
|
||||
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)
|
||||
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;
|
||||
// Extract filename from path
|
||||
const auto lastSlash = epubPath.rfind('/');
|
||||
meta.filename = (lastSlash != std::string::npos) ? epubPath.substr(lastSlash + 1) : epubPath;
|
||||
if (epub) {
|
||||
meta.title = epub->getTitle();
|
||||
meta.authors = epub->getAuthor();
|
||||
} else {
|
||||
LOG_ERR("KOSync", "Epub unavailable for metadata; sending filename only");
|
||||
}
|
||||
meta.title = epub->getTitle();
|
||||
meta.authors = epub->getAuthor();
|
||||
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);
|
||||
|
||||
// Drop the radio while user reads the result; full teardown happens at silent reboot.
|
||||
@@ -356,7 +236,6 @@ void KOReaderSyncActivity::performUpload() {
|
||||
RenderLock lock(*this);
|
||||
state = UPLOAD_COMPLETE;
|
||||
}
|
||||
markAutoReturn();
|
||||
requestUpdate(true);
|
||||
}
|
||||
|
||||
@@ -500,12 +379,10 @@ void KOReaderSyncActivity::render(RenderLock&&) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
|
||||
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top,
|
||||
state == UPLOAD_COMPLETE ? tr(STR_UPLOAD_SUCCESS) : tr(STR_ALREADY_SYNCED), true,
|
||||
EpdFontFamily::BOLD);
|
||||
if (state == UPLOAD_COMPLETE) {
|
||||
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DONE), "", "");
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
@@ -523,13 +400,8 @@ void KOReaderSyncActivity::render(RenderLock&&) {
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::loop() {
|
||||
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
|
||||
if (autoReturnAt != 0 && millis() >= autoReturnAt) {
|
||||
returnToReader();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
returnToReader();
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -40,7 +40,7 @@ class KOReaderSyncActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING || state == UPLOADING; }
|
||||
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; }
|
||||
|
||||
private:
|
||||
enum State {
|
||||
@@ -50,7 +50,6 @@ class KOReaderSyncActivity final : public Activity {
|
||||
SHOWING_RESULT,
|
||||
UPLOADING,
|
||||
UPLOAD_COMPLETE,
|
||||
SYNC_COMPLETE,
|
||||
NO_REMOTE_PROGRESS,
|
||||
SYNC_FAILED,
|
||||
NO_CREDENTIALS
|
||||
@@ -79,10 +78,6 @@ class KOReaderSyncActivity final : public Activity {
|
||||
// Selection in result screen (0=Apply, 1=Upload)
|
||||
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
|
||||
// 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,
|
||||
@@ -92,9 +87,6 @@ class KOReaderSyncActivity final : public Activity {
|
||||
void onWifiSelectionComplete(bool success);
|
||||
void performSync();
|
||||
void performUpload();
|
||||
bool smartSyncEnabled() const;
|
||||
void markAutoReturn();
|
||||
void completeAlreadySynced();
|
||||
void ensureEpubLoaded();
|
||||
void saveProgressAndReturn(int spineIndex, int page);
|
||||
void returnToReader();
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
#include <Logging.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/ActivityManager.h"
|
||||
|
||||
namespace ReaderUtils {
|
||||
|
||||
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 BOOKMARK_HOLD_MS = 400;
|
||||
constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500;
|
||||
@@ -98,37 +96,4 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) {
|
||||
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
|
||||
|
||||
@@ -60,8 +60,16 @@ void TxtReaderActivity::onExit() {
|
||||
}
|
||||
|
||||
void TxtReaderActivity::loop() {
|
||||
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, txt ? txt->getPath().c_str() : "",
|
||||
{this, [](void* ctx) { static_cast<TxtReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -101,8 +101,16 @@ void XtcReaderActivity::loop() {
|
||||
openChapterSelection();
|
||||
}
|
||||
|
||||
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, xtc ? xtc->getPath().c_str() : "",
|
||||
{this, [](void* ctx) { static_cast<XtcReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = AUTHENTICATING;
|
||||
statusMessage = mode == Mode::SIGN_UP ? tr(STR_CREATING_ACCOUNT) : tr(STR_AUTHENTICATING);
|
||||
statusMessage = tr(STR_AUTHENTICATING);
|
||||
}
|
||||
requestUpdate();
|
||||
|
||||
@@ -34,17 +34,16 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
||||
}
|
||||
|
||||
void KOReaderAuthActivity::performAuthentication() {
|
||||
const auto result = mode == Mode::SIGN_UP ? KOReaderSyncClient::createUser() : KOReaderSyncClient::authenticate();
|
||||
const auto result = KOReaderSyncClient::authenticate();
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
if (result == KOReaderSyncClient::OK) {
|
||||
state = SUCCESS;
|
||||
statusMessage = mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS);
|
||||
statusMessage = tr(STR_AUTH_SUCCESS);
|
||||
} else {
|
||||
state = FAILED;
|
||||
errorMessage =
|
||||
result == KOReaderSyncClient::USER_EXISTS ? tr(STR_USERNAME_TAKEN) : KOReaderSyncClient::errorString(result);
|
||||
errorMessage = KOReaderSyncClient::errorString(result);
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
@@ -81,21 +80,17 @@ void KOReaderAuthActivity::render(RenderLock&&) {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight},
|
||||
mode == Mode::SIGN_UP ? tr(STR_SIGN_UP) : tr(STR_KOREADER_AUTH));
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_KOREADER_AUTH));
|
||||
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const auto top = (pageHeight - height) / 2;
|
||||
|
||||
if (state == AUTHENTICATING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str());
|
||||
} else if (state == SUCCESS) {
|
||||
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, tr(STR_AUTH_SUCCESS), true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY));
|
||||
} else if (state == FAILED) {
|
||||
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, tr(STR_AUTH_FAILED), true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str());
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,13 @@
|
||||
#include "activities/Activity.h"
|
||||
|
||||
/**
|
||||
* Activity for testing KOReader credentials, or — in sign-up mode — creating a
|
||||
* new account on the sync server with the entered username/password.
|
||||
* Connects to WiFi, then authenticates or registers.
|
||||
* Activity for testing KOReader credentials.
|
||||
* Connects to WiFi and authenticates with the KOReader sync server.
|
||||
*/
|
||||
class KOReaderAuthActivity final : public Activity {
|
||||
public:
|
||||
enum class Mode { AUTHENTICATE, SIGN_UP };
|
||||
|
||||
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, Mode mode = Mode::AUTHENTICATE)
|
||||
: Activity("KOReaderAuth", renderer, mappedInput), mode(mode) {}
|
||||
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("KOReaderAuth", renderer, mappedInput) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -25,7 +22,6 @@ class KOReaderAuthActivity final : public Activity {
|
||||
private:
|
||||
enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED };
|
||||
|
||||
Mode mode = Mode::AUTHENTICATE;
|
||||
State state = WIFI_SELECTION;
|
||||
std::string statusMessage;
|
||||
std::string errorMessage;
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEMS = 8;
|
||||
constexpr int MENU_ITEMS = 6;
|
||||
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_SYNC_BEHAVIOR,
|
||||
StrId::STR_SIGN_UP, StrId::STR_AUTHENTICATE};
|
||||
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_AUTHENTICATE};
|
||||
} // namespace
|
||||
|
||||
void KOReaderSettingsActivity::onEnter() {
|
||||
@@ -104,22 +103,6 @@ void KOReaderSettingsActivity::handleSelection() {
|
||||
KOREADER_STORE.saveToFile();
|
||||
requestUpdate();
|
||||
} 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
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
// Can't authenticate without credentials - just show message briefly
|
||||
@@ -153,25 +136,13 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
|
||||
return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
} else if (index == 2) {
|
||||
auto serverUrl = KOREADER_STORE.getServerUrl();
|
||||
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;
|
||||
return serverUrl.empty() ? std::string(tr(STR_DEFAULT_VALUE)) : serverUrl;
|
||||
} else if (index == 3) {
|
||||
return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME))
|
||||
: std::string(tr(STR_BINARY));
|
||||
} else if (index == 4) {
|
||||
return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
|
||||
} 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 std::string(tr(STR_NOT_SET));
|
||||
|
||||
@@ -3,51 +3,19 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "OpdsSettingsActivity.h"
|
||||
#include "activities/ActivityManager.h"
|
||||
#include "activities/browser/OpdsBookBrowserActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.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 count = static_cast<int>(OPDS_STORE.getCount());
|
||||
// Settings mode appends three virtual items: "Add Server", "Download folder"
|
||||
// and "Filename format".
|
||||
// In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers
|
||||
if (!pickerMode) {
|
||||
count += 3;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -106,34 +74,6 @@ void OpdsServerListActivity::handleSelection() {
|
||||
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
|
||||
auto resultHandler = [this](const ActivityResult&) {
|
||||
// Reload server list when returning from editor
|
||||
@@ -171,30 +111,17 @@ void OpdsServerListActivity::render(RenderLock&&) {
|
||||
// Secondary label: server URL (shown as subtitle when name is set).
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
|
||||
[&servers, serverCount](int index) -> std::string {
|
||||
[&servers, serverCount](int index) {
|
||||
if (index < serverCount) {
|
||||
const auto& server = servers[index];
|
||||
return server.name.empty() ? server.url : server.name;
|
||||
}
|
||||
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));
|
||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||
},
|
||||
[&servers, serverCount](int index) -> std::string {
|
||||
[&servers, serverCount](int index) {
|
||||
if (index < serverCount && !servers[index].name.empty()) {
|
||||
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("");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@ std::string formatUtcOffset(uint8_t biasedQ) {
|
||||
snprintf(buf, sizeof(buf), "UTC%c%d:%02d", neg ? '-' : '+', hours, mins);
|
||||
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;
|
||||
const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE};
|
||||
|
||||
@@ -90,7 +94,11 @@ void StatusBarSettingsActivity::onEnter() {
|
||||
selectedIndex = 0;
|
||||
visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS;
|
||||
|
||||
// Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data
|
||||
// Clamp enum-valued settings 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) {
|
||||
SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS;
|
||||
}
|
||||
@@ -163,8 +171,12 @@ void StatusBarSettingsActivity::loop() {
|
||||
void StatusBarSettingsActivity::handleSelection() {
|
||||
switch (selectedIndex) {
|
||||
case ITEM_CHAPTER_PAGE_COUNT:
|
||||
SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2;
|
||||
break;
|
||||
optionPopup.show(StrId::STR_CHAPTER_PAGE_COUNT, pageCountNames, PAGE_COUNT_ITEMS, SETTINGS.statusBarPageCount,
|
||||
[this](int idx) {
|
||||
SETTINGS.statusBarPageCount = idx;
|
||||
SETTINGS.saveToFile();
|
||||
});
|
||||
return;
|
||||
case ITEM_BOOK_PROGRESS_PERCENTAGE:
|
||||
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
|
||||
break;
|
||||
@@ -236,7 +248,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
|
||||
[](int index) -> std::string {
|
||||
switch (index) {
|
||||
case ITEM_CHAPTER_PAGE_COUNT:
|
||||
return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
return I18N.get(pageCountNames[SETTINGS.statusBarPageCount]);
|
||||
case ITEM_BOOK_PROGRESS_PERCENTAGE:
|
||||
return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
case ITEM_PROGRESS_BAR:
|
||||
|
||||
@@ -132,7 +132,8 @@ int UITheme::getStatusBarHeight() {
|
||||
|
||||
// Add status bar margin
|
||||
const bool showStatusBar =
|
||||
SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
|
||||
SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT ||
|
||||
SETTINGS.statusBarBookProgressPercentage ||
|
||||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
|
||||
SETTINGS.statusBarClock != CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
|
||||
const bool showProgressBar =
|
||||
|
||||
@@ -27,7 +27,8 @@ constexpr int bookmarkStatusIconGap = 4;
|
||||
constexpr int bookmarkStatusIconTopCrop = 2;
|
||||
|
||||
bool statusBarTextLaneVisible() {
|
||||
return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
|
||||
return SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT ||
|
||||
SETTINGS.statusBarBookProgressPercentage ||
|
||||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
|
||||
(SETTINGS.statusBarClock && halClock.isAvailable());
|
||||
}
|
||||
@@ -765,20 +766,23 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
|
||||
int leftClusterWidth = 0;
|
||||
int rightClusterWidth = 0;
|
||||
|
||||
if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) {
|
||||
const bool showPageCount = SETTINGS.statusBarPageCount != CrossPointSettings::STATUS_BAR_PAGE_COUNT::HIDE_PAGE_COUNT;
|
||||
if (SETTINGS.statusBarBookProgressPercentage || showPageCount) {
|
||||
// Right aligned text for progress counter
|
||||
char progressStr[32];
|
||||
|
||||
// Prefix the page count with "~" while a still-building spine only yields an estimated total.
|
||||
// Mark the total with "~" while it is only an estimate (a still-building spine's
|
||||
// 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 ? "~" : "";
|
||||
|
||||
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) {
|
||||
snprintf(progressStr, sizeof(progressStr), "%s%d/%d %.0f%%", estimatePrefix, currentPage, pageCount,
|
||||
if (SETTINGS.statusBarBookProgressPercentage && showPageCount) {
|
||||
snprintf(progressStr, sizeof(progressStr), "%d/%s%d %.0f%%", currentPage, estimatePrefix, pageCount,
|
||||
bookProgress);
|
||||
} else if (SETTINGS.statusBarBookProgressPercentage) {
|
||||
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
|
||||
} else {
|
||||
snprintf(progressStr, sizeof(progressStr), "%s%d/%d", estimatePrefix, currentPage, pageCount);
|
||||
snprintf(progressStr, sizeof(progressStr), "%d/%s%d", currentPage, estimatePrefix, pageCount);
|
||||
}
|
||||
|
||||
int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
|
||||
|
||||
+45
-4
@@ -159,6 +159,49 @@ void silentRestartToReader() {
|
||||
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() {
|
||||
gpio.update();
|
||||
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||
@@ -314,10 +357,8 @@ void setup() {
|
||||
switch (wakeupReason) {
|
||||
case HalGPIO::WakeupReason::PowerButton:
|
||||
LOG_DBG("MAIN", "Verifying power button press duration");
|
||||
if (!gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(),
|
||||
SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP)) {
|
||||
powerManager.startDeepSleep(gpio);
|
||||
}
|
||||
gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(),
|
||||
SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP);
|
||||
break;
|
||||
case HalGPIO::WakeupReason::AfterUSBPower:
|
||||
// If USB power caused a cold boot, go back to sleep
|
||||
|
||||
@@ -132,11 +132,6 @@ void CrossPointWebServer::begin() {
|
||||
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
|
||||
LOG_DBG("WEB", "Setting up routes...");
|
||||
server->on("/", HTTP_GET, [this] { handleRoot(); });
|
||||
@@ -358,13 +353,6 @@ void CrossPointWebServer::handleJszip() 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
|
||||
// API requests (/api/*) still return 404 so XHR errors surface correctly
|
||||
// see https://en.wikipedia.org/wiki/Captive_portal#Detection
|
||||
@@ -683,15 +671,17 @@ 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] Free heap: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
// Create file path
|
||||
String filePath = state.path;
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += state.fileName;
|
||||
|
||||
// Check if file already exists - SD operations can be slow
|
||||
esp_task_wdt_reset();
|
||||
if (Storage.exists(filePath.c_str())) {
|
||||
state.error = "File already exists: " + state.fileName;
|
||||
LOG_DBG("WEB", "[UPLOAD] Collision: %s", filePath.c_str());
|
||||
return;
|
||||
LOG_DBG("WEB", "[UPLOAD] Overwriting existing file: %s", filePath.c_str());
|
||||
esp_task_wdt_reset();
|
||||
Storage.remove(filePath.c_str());
|
||||
}
|
||||
|
||||
// Open file for writing - this can be slow due to FAT cluster allocation
|
||||
@@ -759,7 +749,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
|
||||
LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime,
|
||||
writePercent);
|
||||
|
||||
// Clear epub cache after uploading the file
|
||||
// Clear epub cache to prevent stale metadata issues when overwriting files
|
||||
String filePath = state.path;
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += state.fileName;
|
||||
@@ -1634,20 +1624,20 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
||||
wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1);
|
||||
}
|
||||
|
||||
// Build file path
|
||||
String filePath = wsUploadPath;
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += wsUploadFileName;
|
||||
|
||||
esp_task_wdt_reset();
|
||||
if (Storage.exists(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());
|
||||
|
||||
// Check if file exists and remove it
|
||||
esp_task_wdt_reset();
|
||||
if (Storage.exists(filePath.c_str())) {
|
||||
Storage.remove(filePath.c_str());
|
||||
}
|
||||
|
||||
// Open file for writing
|
||||
esp_task_wdt_reset();
|
||||
if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
|
||||
@@ -1731,7 +1721,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,
|
||||
elapsed, kbps);
|
||||
|
||||
// Clear epub cache after uploading the file
|
||||
// Clear epub cache to prevent stale metadata issues when overwriting files
|
||||
String filePath = wsUploadPath;
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += wsUploadFileName;
|
||||
|
||||
+35
-244
@@ -1579,19 +1579,6 @@
|
||||
<div id="convertInfo" style="display:none; margin-bottom: 10px; font-size: 0.9em;">
|
||||
</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;">
|
||||
<span>⚫ True-Grayscale</span>
|
||||
<span id="convertSizeSummary">📏 Max 480×800px</span>
|
||||
@@ -1688,19 +1675,7 @@
|
||||
</div>
|
||||
<div class="setting-controls">
|
||||
<label class="toggle-switch">
|
||||
<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()">
|
||||
<input type="checkbox" id="export-log-checkbox">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -2110,8 +2085,12 @@
|
||||
|
||||
// Modal functions
|
||||
function openUploadModal() {
|
||||
// Reset converter variables to defaults
|
||||
ENABLE_GRAYSCALE = true;
|
||||
JPEG_QUALITY = 85;
|
||||
HANDEDNESS = 'right';
|
||||
OVERLAP_PERCENT = 5;
|
||||
imageStates = {};
|
||||
restoreUploadSettingsFromStorage();
|
||||
|
||||
// Hide convert options when opening modal (no files selected initially)
|
||||
const convertOptions = document.getElementById('convertOptions');
|
||||
@@ -2119,6 +2098,10 @@
|
||||
convertOptions.style.display = 'none';
|
||||
}
|
||||
|
||||
// Reset rotation and overlap UI
|
||||
setHandedness('right');
|
||||
setOverlap(5);
|
||||
|
||||
// Hide log section from previous session
|
||||
const logSection = document.getElementById('log-section');
|
||||
if (logSection) logSection.classList.remove('visible');
|
||||
@@ -2177,8 +2160,7 @@
|
||||
document.getElementById('progress-container').style.display = 'none';
|
||||
document.getElementById('progress-fill').style.width = '0%';
|
||||
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
|
||||
const convertOptions = document.getElementById('convertOptions');
|
||||
if (convertOptions) convertOptions.style.display = 'none';
|
||||
document.getElementById('convertBeforeUpload').checked = false;
|
||||
document.getElementById('convertInfo').style.display = 'none';
|
||||
document.getElementById('convertWarning').style.display = 'none';
|
||||
// Clear image picker cache and reset layout
|
||||
@@ -2201,7 +2183,15 @@
|
||||
advancedOptionsToggle.style.opacity = '0.5';
|
||||
advancedOptionsToggle.style.pointerEvents = 'none';
|
||||
}
|
||||
applyUploadSettings();
|
||||
// Reset to defaults
|
||||
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) {
|
||||
@@ -2238,7 +2228,6 @@
|
||||
advancedOptionsToggle.style.opacity = checked ? '1' : '0.5';
|
||||
advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none';
|
||||
}
|
||||
updateUploadSettingsPersistence();
|
||||
}
|
||||
|
||||
function toggleAdvancedOptions() {
|
||||
@@ -2276,8 +2265,12 @@
|
||||
function setQualityPreset(value) {
|
||||
document.getElementById('qualitySlider').value = value;
|
||||
document.getElementById('qualityInput').value = value;
|
||||
// Update active preset
|
||||
document.querySelectorAll('.quality-preset').forEach(btn => {
|
||||
btn.classList.toggle('active', parseInt(btn.dataset.value, 10) === value);
|
||||
btn.classList.remove('active');
|
||||
if (parseInt(btn.dataset.value, 10) === value) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
updateQualitySettings();
|
||||
}
|
||||
@@ -2299,7 +2292,6 @@
|
||||
if (isImagePickerVisible()) {
|
||||
renderImageGrid();
|
||||
}
|
||||
updateUploadSettingsPersistence();
|
||||
}
|
||||
|
||||
function setHandedness(value) {
|
||||
@@ -2312,7 +2304,6 @@
|
||||
if (isImagePickerVisible()) {
|
||||
renderImageGrid();
|
||||
}
|
||||
updateUploadSettingsPersistence();
|
||||
}
|
||||
|
||||
function setOverlap(value) {
|
||||
@@ -2321,7 +2312,6 @@
|
||||
document.querySelectorAll('.overlap-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', parseInt(btn.dataset.value) === value);
|
||||
});
|
||||
updateUploadSettingsPersistence();
|
||||
}
|
||||
|
||||
function isImagePickerVisible() {
|
||||
@@ -2990,12 +2980,7 @@
|
||||
|
||||
if (qualitySlider && qualityInput) {
|
||||
// Initialize converter variables with UI default values
|
||||
suppressUploadSettingsSave = true;
|
||||
try {
|
||||
updateQualitySettings();
|
||||
} finally {
|
||||
suppressUploadSettingsSave = false;
|
||||
}
|
||||
updateQualitySettings();
|
||||
|
||||
// Deselect all presets when slider is manually changed
|
||||
const deselectPresets = function() {
|
||||
@@ -3228,13 +3213,15 @@
|
||||
const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub'));
|
||||
if (files.length > 0 && hasEpub) {
|
||||
convertOptions.style.display = 'block';
|
||||
toggleConvertOptions();
|
||||
} else {
|
||||
convertOptions.style.display = 'none';
|
||||
uploadBtn.textContent = 'Upload';
|
||||
uploadBtn.classList.remove('optimize');
|
||||
document.getElementById('convertInfo').style.display = 'none';
|
||||
document.getElementById('convertWarning').style.display = 'none';
|
||||
// Clear stale checkbox state so the "Optimize & Upload" button doesn't linger
|
||||
// when the user re-picks a non-EPUB after having ticked Optimize for an EPUB.
|
||||
const cb = document.getElementById('convertBeforeUpload');
|
||||
if (cb && cb.checked) {
|
||||
cb.checked = false;
|
||||
toggleConvertOptions();
|
||||
}
|
||||
if (files.length === 0) clearImagePicker();
|
||||
}
|
||||
|
||||
@@ -3316,75 +3303,6 @@ let ENABLE_GRAYSCALE = DEFAULT_ENABLE_GRAYSCALE;
|
||||
let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP;
|
||||
let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed)
|
||||
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
|
||||
@@ -3462,7 +3380,6 @@ function applyDeviceTarget() {
|
||||
function setDeviceTarget(value) {
|
||||
DEVICE_TARGET = value;
|
||||
applyDeviceTarget();
|
||||
updateUploadSettingsPersistence();
|
||||
}
|
||||
|
||||
// Batch logging system for multiple files
|
||||
@@ -3871,104 +3788,6 @@ async function findOPFPath(zip) {
|
||||
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.
|
||||
* Handles multiple ../, ./, absolute /, and bare relative paths.
|
||||
@@ -5400,7 +5219,7 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) {
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
function uploadFile() {
|
||||
if (isUploadInProgress) return;
|
||||
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
@@ -5412,17 +5231,8 @@ async function uploadFile() {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
isUploadInProgress = true;
|
||||
uploadGeneration++;
|
||||
const myGeneration = uploadGeneration;
|
||||
document.getElementById('uploadModalClose').classList.add('disabled');
|
||||
@@ -5513,25 +5323,6 @@ async function uploadFile() {
|
||||
let convOriginalSize = 0; // Picked-file 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 stageText = needsConversion ? 'Converting & uploading' : 'Uploading';
|
||||
progressText.style.color = '';
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
#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";
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#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,6 +43,5 @@ add_subdirectory(release_json_parser)
|
||||
add_subdirectory(differential_rounding)
|
||||
add_subdirectory(hyphenation_eval)
|
||||
add_subdirectory(utf8_compose)
|
||||
add_subdirectory(opds_filename)
|
||||
add_subdirectory(minibidi_arabic)
|
||||
add_subdirectory(combining_marks)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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)
|
||||
@@ -1,52 +0,0 @@
|
||||
#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