Compare commits

...
Author SHA1 Message Date
Uri Tauber 5ba1d5747f fix: EndOfBookOptions fails to compile
ActivityManager.h forward-declares Activity but holds
std::unique_ptr<Activity> members. Instantiating that unique_ptr's
destructor requires the complete type, so any TU including
ActivityManager.h without Activity.h fails to compile.

Include Activity.h directly. Adding it to ActivityManager.h instead does
not work: Activity.h depends on HomeMenuItem, which ActivityManager.h
defines.
2026-07-19 16:45:42 +03:00
Thomas Symalla 9fe4dc5e38 feat: Add option to switch behavior for "back to browser / home" in Reader activity (#2366)
## Summary

It would be nice to switch back to the file list from an Reader activity
via a short back button press. This change adds an Reader option to
switch the default behavior, so a short back button press in the Reader
activity can now go back to the file list, and a long press on back goes
back to the home view. This does a fair bit of refactoring, introducing
a new constant for the ms limit.

* **What changes are included?**

- Changes to the translation
- Additional global Reader option 
- Refactoring of the back button behavior in the Reader activity
2026-07-19 08:29:13 +03:00
Justin Mitchell b1d037569b feat: Add kosync user registration and switch to crosspoint-sync server (#2587)
Implements createUser() endpoint to allow account creation via the
KOSync protocol. Changes default sync server from sync.koreader.rocks to
sync.crosspointreader.com with migration logic to preserve existing
users' server settings. Extends sync protocol to include position data
(spine index, page numbers, xpath) that crosspoint-sync supports while
remaining compatible with standard kosync servers.
2026-07-18 14:50:51 -04:00
Julia 9737cb335c fix: correct the settings enums for "blank" and "cover + custom" sleep screens (#2635) 2026-07-17 12:35:51 -04:00
Phạm Bình An fdffc2e5d9 fix: reduce CSS parse-time OOM risk in chapter layout (#2606) 2026-07-16 07:21:08 +03:00
a2db43d235 feat: enable CORS headers in the HTTP API (#2594)
Closes #2558.

Enables the Arduino WebServer's built-in CORS support
(`enableCORS(true)`), which adds
`Access-Control-Allow-Origin/Methods/Headers: *` to every response, and
answers preflight `OPTIONS` requests with `204` in `handleNotFound()` —
routes are registered per-method, so OPTIONS always lands there. The
AP-mode captive-portal redirect is untouched (the OPTIONS check runs
before it, and browsers don't send preflights for captive-portal
probes).

This lets web-based clients and PWAs served from other origins call the
JSON API (`/api/status`, `/api/files`, `/api/settings`, ...) directly
from the browser.

Overhead is three static response headers; no behavior change for the
built-in web UI.

Note: not yet tested on hardware.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: metoli <metoli@metoli-Mac-mini.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:35:10 -04:00
rxmmahandkira 63093e606c feat: Back on home menu opens the most recent book (#2619)
Co-authored-by: kira <rammah@tuta.io>
2026-07-15 23:50:22 +03:00
a4ac3b2788 feat: add smart KOReader progress sync (#2192)
## Summary

Make KOReader progress sync a one-click flow for common cases while
keeping the existing manual mode available.

### Changes

- Add a **Sync Behavior** setting:
  - **Smart sync** for new configurations
  - **Ask every time** for manual control
- Preserve **Ask every time** when migrating an existing credential file
that predates this setting.
- In Smart mode:
  - upload local progress when no remote record exists;
  - show a short confirmation when already synced;
  - upload when local progress is further ahead;
  - apply remote progress when remote progress is further ahead.
- Probe both KOReader document-matching hashes before making the Smart
decision, while keeping uploads on the user's configured matching
method.
- Auto-return after successful Smart terminal states, with Back/Confirm
still available.
- Persist the setting alongside the current credential, matching-method,
and send-metadata fields.
- Document both behaviors in the user guide.

## Additional context

This pairs well with #2189 (smart Wi-Fi auto-connect), but does not
depend on it. It does not add background Wi-Fi or passive network
detection; sync is still triggered by the user from the reader menu.

Smart sync uses the furthest progress because CrossPoint does not
currently persist local progress timestamps. Users who prefer explicit
conflict resolution can select **Ask every time**.

## Verification

- `git diff --check`
- `platformio check --fail-on-defect low --fail-on-defect medium
--fail-on-defect high` — no defects
- `platformio run -e default` — firmware build successful
- The original implementation was manually smoke-tested on an X4 device.

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_

AI tools were used to inspect the codebase, draft and review the
implementation, resolve the rebase against current `develop`, and
prepare the PR text. The resulting firmware was built locally.

---------

Co-authored-by: Alexander Hoffer <git@alexanderhoffer.com>
Co-authored-by: Alexander Hoffer <contact@alexanderhoffer.com>
2026-07-15 14:59:15 -04:00
Víctor Fernández 35a45f9c1e feat: add options to remember web upload settings & rename ebooks to {title} - {author} (#2534) 2026-07-15 14:43:42 -04:00
27 changed files with 873 additions and 181 deletions
+42 -29
View File
@@ -28,8 +28,10 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) - [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds) - [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup) - [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks) - [Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)](#option-a-crosspoint-sync-server-synccrosspointreadercom-default)
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose) - [Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)](#option-b-legacy-public-koreader-server-synckoreaderrocks)
- [Option C: Self-Hosted Server (Docker Compose)](#option-c-self-hosted-server-docker-compose)
- [Syncing While Reading](#syncing-while-reading)
- [3.7 Sleep Screen](#37-sleep-screen) - [3.7 Sleep Screen](#37-sleep-screen)
- [Cover settings](#cover-settings) - [Cover settings](#cover-settings)
- [Custom images](#custom-images) - [Custom images](#custom-images)
@@ -297,7 +299,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates. - **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates.
- **KOReader Sync**: Options for setting up KOReader for syncing book progress. - **KOReader Sync**: Options for setting up KOReader for syncing book progress. **Smart sync** is the default for new configurations and auto-resolves simple push/pull decisions. Existing credential files retain **Ask every time** when migrated; you can switch Sync Behavior at any time if you prefer manual confirmation.
- **OPDS Servers**: Manage one or more OPDS [(Open Publication Distribution System)](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below. - **OPDS Servers**: Manage one or more OPDS [(Open Publication Distribution System)](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below.
@@ -360,9 +362,37 @@ Behavior notes:
CrossPoint can sync reading progress with KOReader-compatible sync servers. CrossPoint can sync reading progress with KOReader-compatible sync servers.
It also interoperates with KOReader apps/devices when they use the same server and credentials. It also interoperates with KOReader apps/devices when they use the same server and credentials.
##### Option A: Free Public Server (`sync.koreader.rocks`) ##### Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)
1. Register a user once (only if needed): When **Sync Server URL** is left empty, CrossPoint uses the free CrossPoint sync server at `https://sync.crosspointreader.com`. It speaks the standard KOReader sync protocol (so KOReader apps can use it too) and additionally stores an exact spine/page position for lossless CrossPoint-to-CrossPoint sync.
1. On each CrossPoint device:
- Go to **Settings -> System -> KOReader Sync**.
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
- Leave **Sync Server URL** empty (or set it to `https://sync.crosspointreader.com`).
- On the first device, run **Sign Up** once to create the account directly from the device. On every other device, just run **Authenticate**.
Accounts are per server. Existing `sync.koreader.rocks` credentials do not exist on the CrossPoint server; either sign up again with the same username/password or use Option B to keep using the legacy server.
##### Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)
Use this if you already sync KOReader devices against the official public server.
1. On each CrossPoint device:
- Go to **Settings -> System -> KOReader Sync**.
- Set **Sync Server URL** to `https://sync.koreader.rocks` (required; an empty URL now points at the CrossPoint server instead).
- Set **Username** and **Password** to your existing KOReader Sync credentials.
- Run **Authenticate**.
2. If you do not have an account yet, run **Sign Up** on the device, or register once with curl:
```bash ```bash
USERNAME="user" USERNAME="user"
@@ -375,27 +405,9 @@ curl -i "https://sync.koreader.rocks/users/create" \
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}" --data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
``` ```
Already have KOReader Sync credentials? Skip registration; basic sync only requires using the same existing username/password on all devices.
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account. When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
2. On each CrossPoint device: ##### Option C: Self-Hosted Server (Docker Compose)
- Go to **Settings -> System -> KOReader Sync**.
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
- Set **Sync Server URL** to `https://sync.koreader.rocks`, or leave it empty (both use the same default KOReader sync server).
- Run **Authenticate**.
3. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
- Choose **Apply Remote** to jump to remote progress.
- Choose **Upload Local** to push current progress.
##### Option B: Self-Hosted Server (Docker Compose)
1. Start a sync server: 1. Start a sync server:
@@ -468,11 +480,12 @@ If this returns `HTTP 402` with `{"code":2002,"message":"Username is already reg
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing). If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
5. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**. ##### Syncing While Reading
- Choose **Apply Remote** to jump to remote progress. Once any of the options above is set up, press **Confirm** while reading to open the reader menu, then select **Sync Progress**. Alternatively, set **Settings -> Controls -> Long-press Menu** to **KOSync** and hold Confirm to launch sync directly.
- Choose **Upload Local** to push current progress. - With **Sync Behavior** set to **Ask every time**, choose **Apply Remote** to jump to remote progress or **Upload Local** to push current progress.
- With **Sync Behavior** set to **Smart sync**, CrossPoint auto-resolves simple cases: upload when no remote progress exists, confirm and leave both unchanged when local and remote progress are already synchronized, upload when local progress is further ahead, or apply remote when remote progress is further ahead.
### 3.7 Sleep Screen ### 3.7 Sleep Screen
+25 -23
View File
@@ -287,7 +287,30 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
effectiveNoSpaceBefore = true; effectiveNoSpaceBefore = true;
} }
const auto ensureTokenCapacity = [&](const size_t additionalTokens) {
if (additionalTokens == 0) return;
const size_t requiredSize = words.size() + additionalTokens;
if (words.capacity() >= requiredSize) return;
size_t newCapacity = words.capacity();
if (newCapacity < 16) {
newCapacity = 16;
}
while (newCapacity < requiredSize) {
newCapacity *= 2;
}
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordNoSpaceBefore.reserve(newCapacity);
wordIsFocusSuffix.reserve(newCapacity);
};
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) { if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
// CJK-heavy paragraphs can push hundreds of tiny tokens quickly when CSS toggles
// inline styles. Reserve once up front to avoid repeated vector growth reallocations.
ensureTokenCapacity(breakOffsets.size() + 1);
bool firstToken = true; bool firstToken = true;
size_t tokenStart = 0; size_t tokenStart = 0;
for (const size_t breakOffset : breakOffsets) { for (const size_t breakOffset : breakOffsets) {
@@ -326,29 +349,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
// --- FOCUS READING LOGIC BELOW --- // --- FOCUS READING LOGIC BELOW ---
// Pre-reserve capacity to prevent mid-word heap reallocations. // Worst case: a segment boundary on each byte (highly punctuated UTF-8 text).
size_t maxPossibleNewTokens = word.length(); ensureTokenCapacity(word.length());
size_t requiredSize = words.size() + maxPossibleNewTokens;
if (words.capacity() < requiredSize) {
// Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word.
size_t newCapacity = words.capacity() * 2;
// Ensure the doubled capacity is actually enough for this specific word
if (newCapacity < requiredSize) {
newCapacity = requiredSize;
}
// Set a sensible minimum starting size so the first few words don't trigger tiny reallocations
if (newCapacity < 16) {
newCapacity = 16;
}
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordNoSpaceBefore.reserve(newCapacity);
wordIsFocusSuffix.reserve(newCapacity);
}
// Lambda helper to process and push individual sub-segments of the string // Lambda helper to process and push individual sub-segments of the string
// Use std::string_view to avoid heap allocations when slicing // Use std::string_view to avoid heap allocations when slicing
@@ -22,6 +22,17 @@
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
constexpr size_t PARSE_BUFFER_SIZE = 1024; constexpr size_t PARSE_BUFFER_SIZE = 1024;
// This number comes from PR #73
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
// memory.
// Spotted when reading Intermezzo, there are some really long text blocks in there.
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS = 750;
// When CSS is enabled, flush earlier to save RAM. 320 is still more than enough to build a CJK
// page at font size 14
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS = 320;
// Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation // Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation
// anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per // anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per
// chapter. A runaway count usually means a converter injected machine-generated IDs on // chapter. A runaway count usually means a converter injected machine-generated IDs on
@@ -1155,12 +1166,14 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
self->partWordBuffer[self->partWordBufferIndex++] = s[i]; self->partWordBuffer[self->partWordBufferIndex++] = s[i];
} }
// If we have > 750 words buffered up, perform the layout and consume out all but the last line // Keep token growth bounded: CSS-heavy spans can fragment text into many tiny
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of // words, so flush earlier when embedded CSS is active. We still keep the
// memory. // "exclude last line" behavior to preserve paragraph flow across chunks.
// Spotted when reading Intermezzo, there are some really long text blocks in there. const size_t blockWordCount = self->currentTextBlock->size();
if (self->currentTextBlock->size() > 750) { const size_t softFlushThreshold =
LOG_DBG("EHP", "Text block too long, splitting into multiple pages"); self->embeddedStyle ? TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS : TEXT_BLOCK_SOFT_FLUSH_WORDS;
if (blockWordCount > softFlushThreshold) {
LOG_DBG("EHP", "Text block soft flush (%u words)", static_cast<unsigned>(blockWordCount));
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth) const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
? static_cast<uint16_t>(self->viewportWidth - horizontalInset) ? static_cast<uint16_t>(self->viewportWidth - horizontalInset)
+11
View File
@@ -10,6 +10,7 @@ STR_BROWSE_FILES: "Browse Files"
STR_FILE_TRANSFER: "File Transfer" STR_FILE_TRANSFER: "File Transfer"
STR_SETTINGS_TITLE: "Settings" STR_SETTINGS_TITLE: "Settings"
STR_CONTINUE_READING: "Continue Reading" STR_CONTINUE_READING: "Continue Reading"
STR_RESUME: "Resume"
STR_NO_OPEN_BOOK: "No open book" STR_NO_OPEN_BOOK: "No open book"
STR_START_READING: "Start reading below" STR_START_READING: "Start reading below"
STR_NO_FILES_FOUND: "No files found" STR_NO_FILES_FOUND: "No files found"
@@ -108,7 +109,15 @@ STR_PASSWORD: "Password"
STR_SYNC_SERVER_URL: "Sync Server URL" STR_SYNC_SERVER_URL: "Sync Server URL"
STR_DOCUMENT_MATCHING: "Document Matching" STR_DOCUMENT_MATCHING: "Document Matching"
STR_SEND_METADATA: "Send Document Metadata" STR_SEND_METADATA: "Send Document Metadata"
STR_SYNC_BEHAVIOR: "Sync Behavior"
STR_ASK_EVERY_TIME: "Ask every time"
STR_SMART_SYNC: "Smart sync"
STR_AUTHENTICATE: "Authenticate" STR_AUTHENTICATE: "Authenticate"
STR_SIGN_UP: "Sign Up"
STR_CREATING_ACCOUNT: "Creating account..."
STR_ACCOUNT_CREATED: "Account created"
STR_SIGNUP_FAILED: "Sign up failed"
STR_USERNAME_TAKEN: "Username is already registered"
STR_KOREADER_USERNAME: "KOReader Username" STR_KOREADER_USERNAME: "KOReader Username"
STR_KOREADER_PASSWORD: "KOReader Password" STR_KOREADER_PASSWORD: "KOReader Password"
STR_FILENAME: "Filename" STR_FILENAME: "Filename"
@@ -324,6 +333,7 @@ STR_UPLOAD_LOCAL: "Upload local progress"
STR_NO_REMOTE_MSG: "No remote progress found" STR_NO_REMOTE_MSG: "No remote progress found"
STR_UPLOAD_PROMPT: "Upload current position?" STR_UPLOAD_PROMPT: "Upload current position?"
STR_UPLOAD_SUCCESS: "Progress uploaded!" STR_UPLOAD_SUCCESS: "Progress uploaded!"
STR_ALREADY_SYNCED: "Already synced"
STR_SYNC_FAILED_MSG: "Sync failed" STR_SYNC_FAILED_MSG: "Sync failed"
STR_SAVE_PROGRESS_FAILED: "Could not save progress" STR_SAVE_PROGRESS_FAILED: "Could not save progress"
STR_SECTION_PREFIX: "Section " STR_SECTION_PREFIX: "Section "
@@ -333,6 +343,7 @@ STR_EMBEDDED_STYLE: "Embedded Style"
STR_FOCUS_READING: "Focus Reading" STR_FOCUS_READING: "Focus Reading"
STR_OPDS_SERVER_URL: "OPDS Server URL" STR_OPDS_SERVER_URL: "OPDS Server URL"
STR_PWR_BTN_FOOTNOTE_BACK: "Quick-return from footnotes" STR_PWR_BTN_FOOTNOTE_BACK: "Quick-return from footnotes"
STR_BACK_SHORT_TO_FILE_BROWSER: "Short Back to File Browser"
STR_SET_SLEEP_COVER: "Set Cover" STR_SET_SLEEP_COVER: "Set Cover"
STR_FOOTNOTES: "Footnotes" STR_FOOTNOTES: "Footnotes"
STR_NO_FOOTNOTES: "No footnotes on this page" STR_NO_FOOTNOTES: "No footnotes on this page"
+1
View File
@@ -315,6 +315,7 @@ STR_BOOK_S_STYLE: "Buch-Stil"
STR_EMBEDDED_STYLE: "Eingebetteter Stil" STR_EMBEDDED_STYLE: "Eingebetteter Stil"
STR_FOCUS_READING: "Fokus-Lesen" STR_FOCUS_READING: "Fokus-Lesen"
STR_OPDS_SERVER_URL: "OPDS-Server-URL" STR_OPDS_SERVER_URL: "OPDS-Server-URL"
STR_BACK_SHORT_TO_FILE_BROWSER: "Kurz zurück drücken zum Datei-Browser"
STR_SET_SLEEP_COVER: "Wähle Cover" STR_SET_SLEEP_COVER: "Wähle Cover"
STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen" STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
STR_FOOTNOTES: "Fußnoten" STR_FOOTNOTES: "Fußnoten"
+46 -2
View File
@@ -5,16 +5,27 @@
#include <ObfuscationUtils.h> #include <ObfuscationUtils.h>
namespace { namespace {
// Default sync server URL // Default sync server URL. crosspoint-sync speaks the full KOSync protocol, so
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443"; // pointing at any other kosync server (e.g. https://sync.koreader.rocks:443)
// still works via the custom server URL setting.
constexpr char DEFAULT_SERVER_URL[] = "https://sync.crosspointreader.com";
// Default before config version 2. Configs saved without a version stamp and an
// empty serverUrl were implicitly syncing here — they get pinned on upgrade.
constexpr char LEGACY_DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
// Bumped when a change to defaults would alter behavior for existing configs.
constexpr uint8_t CONFIG_VERSION = 2;
} // namespace } // namespace
void KOReaderCredentialStore::toJson(JsonDocument& doc) const { void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
doc["cfgVersion"] = CONFIG_VERSION;
doc["username"] = getUsername(); doc["username"] = getUsername();
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword()); doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
doc["serverUrl"] = getServerUrl(); doc["serverUrl"] = getServerUrl();
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod()); doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
doc["sendMetadata"] = getSendMetadata(); doc["sendMetadata"] = getSendMetadata();
doc["syncBehavior"] = static_cast<uint8_t>(getSyncBehavior());
} }
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) { bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
@@ -26,6 +37,19 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
setCredentials(user, pass); setCredentials(user, pass);
setServerUrl(doc["serverUrl"] | ""); setServerUrl(doc["serverUrl"] | "");
// The default server changed in config v2 (sync.koreader.rocks -> crosspoint-sync).
// A pre-v2 config with credentials and no explicit URL was actively syncing
// against the old default — pin that URL so the upgrade doesn't switch servers
// out from under the user. Fresh setups get the new default.
const uint8_t cfgVersion = doc["cfgVersion"] | (uint8_t)1;
if (cfgVersion < CONFIG_VERSION) {
if (getServerUrl().empty() && hasCredentials()) {
LOG_DBG("KRS", "Pre-v2 config used the old default server; pinning %s", LEGACY_DEFAULT_SERVER_URL);
setServerUrl(LEGACY_DEFAULT_SERVER_URL);
}
needsResave = true; // stamp cfgVersion so this migration runs once
}
uint8_t method = doc["matchMethod"] | (uint8_t)0; uint8_t method = doc["matchMethod"] | (uint8_t)0;
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) { if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
setMatchMethod(static_cast<DocumentMatchMethod>(method)); setMatchMethod(static_cast<DocumentMatchMethod>(method));
@@ -35,6 +59,18 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
} }
setSendMetadata(doc["sendMetadata"] | false); setSendMetadata(doc["sendMetadata"] | false);
const JsonVariantConst behaviorValue = doc["syncBehavior"];
const bool missingBehavior = behaviorValue.isNull();
uint8_t behavior = behaviorValue | static_cast<uint8_t>(KOReaderSyncBehavior::ASK_EVERY_TIME);
if (behavior <= static_cast<uint8_t>(KOReaderSyncBehavior::SMART)) {
setSyncBehavior(static_cast<KOReaderSyncBehavior>(behavior));
needsResave = needsResave || missingBehavior;
} else {
LOG_DBG("KRS", "Invalid syncBehavior %u in JSON, resetting to ASK_EVERY_TIME", behavior);
setSyncBehavior(KOReaderSyncBehavior::ASK_EVERY_TIME);
needsResave = true;
}
if (needsResave) { if (needsResave) {
LOG_DBG("KRS", "Resaved KOReader credentials to update format"); LOG_DBG("KRS", "Resaved KOReader credentials to update format");
saveToFile(); saveToFile();
@@ -105,3 +141,11 @@ void KOReaderCredentialStore::setSendMetadata(bool enabled) {
sendMetadata = enabled; sendMetadata = enabled;
LOG_DBG("KRS", "Set send metadata: %s", enabled ? "true" : "false"); LOG_DBG("KRS", "Set send metadata: %s", enabled ? "true" : "false");
} }
void KOReaderCredentialStore::setSyncBehavior(KOReaderSyncBehavior behavior) {
if (static_cast<uint8_t>(behavior) > static_cast<uint8_t>(KOReaderSyncBehavior::SMART)) {
behavior = KOReaderSyncBehavior::ASK_EVERY_TIME;
}
syncBehavior = behavior;
LOG_DBG("KRS", "Set sync behavior: %s", behavior == KOReaderSyncBehavior::SMART ? "Smart" : "Ask");
}
@@ -11,6 +11,12 @@ enum class DocumentMatchMethod : uint8_t {
BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical) BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical)
}; };
// How manual "Sync Progress" resolves differences after fetching remote progress.
enum class KOReaderSyncBehavior : uint8_t {
ASK_EVERY_TIME = 0, // Preserve legacy behavior: always show Apply/Upload choices.
SMART = 1, // Auto-resolve simple cases using furthest progress.
};
/** /**
* Singleton class for storing KOReader sync credentials on the SD card. * Singleton class for storing KOReader sync credentials on the SD card.
* Passwords are XOR-obfuscated with the device's unique hardware MAC address * Passwords are XOR-obfuscated with the device's unique hardware MAC address
@@ -25,6 +31,7 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
std::string serverUrl; // Custom sync server URL (empty = default) std::string serverUrl; // Custom sync server URL (empty = default)
DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility
bool sendMetadata = false; // Send document metadata with progress sync bool sendMetadata = false; // Send document metadata with progress sync
KOReaderSyncBehavior syncBehavior = KOReaderSyncBehavior::SMART;
// Private constructor for singleton // Private constructor for singleton
KOReaderCredentialStore() = default; KOReaderCredentialStore() = default;
@@ -65,6 +72,10 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
// Send metadata setting // Send metadata setting
void setSendMetadata(bool enabled); void setSendMetadata(bool enabled);
bool getSendMetadata() const { return sendMetadata; } bool getSendMetadata() const { return sendMetadata; }
// Sync behavior
void setSyncBehavior(KOReaderSyncBehavior behavior);
KOReaderSyncBehavior getSyncBehavior() const { return syncBehavior; }
}; };
// Helper macro to access credential store // Helper macro to access credential store
+67
View File
@@ -78,6 +78,43 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
return SERVER_ERROR; return SERVER_ERROR;
} }
KOReaderSyncClient::Error KOReaderSyncClient::createUser() {
lastHttpCode = 0;
if (!KOREADER_STORE.hasCredentials()) {
LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS;
}
const std::string url = KOREADER_STORE.getBaseUrl() + "/users/create";
LOG_DBG("KOSync", "Creating account: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
if (insufficientHeap()) return LOW_MEMORY;
JsonDocument doc;
doc["username"] = KOREADER_STORE.getUsername();
doc["password"] = KOREADER_STORE.getMd5Password();
std::string body;
serializeJson(doc, body);
freeink::SecureHttpClient http;
http.setInsecure();
if (!http.begin(url)) {
LOG_ERR("KOSync", "Bad URL: %s", url.c_str());
return NETWORK_ERROR;
}
http.addHeader("Accept", "application/vnd.koreader.v1+json");
http.addHeader("Content-Type", "application/json");
const int httpCode = http.sendRequest("POST", body);
http.end();
lastHttpCode = httpCode;
LOG_DBG("KOSync", "Create user response: %d", httpCode);
if (httpCode <= 0) return NETWORK_ERROR;
if (httpCode == 200 || httpCode == 201) return OK;
if (httpCode == 402) return USER_EXISTS;
return SERVER_ERROR;
}
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash, KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
KOReaderProgress& outProgress) { KOReaderProgress& outProgress) {
lastHttpCode = 0; lastHttpCode = 0;
@@ -124,6 +161,24 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
outProgress.deviceId = doc["device_id"].as<std::string>(); outProgress.deviceId = doc["device_id"].as<std::string>();
outProgress.timestamp = doc["timestamp"].as<int64_t>(); outProgress.timestamp = doc["timestamp"].as<int64_t>();
// Extended crosspoint-sync field; absent on plain kosync servers.
outProgress.position.reset();
const JsonObjectConst pos = doc["position"].as<JsonObjectConst>();
if (!pos.isNull()) {
KOReaderRichPosition rich;
rich.pctQ = pos["pctQ"].as<uint32_t>();
rich.spineIndex = pos["spine"].as<uint16_t>();
rich.pageNumber = pos["page"].as<uint16_t>();
const uint16_t pages = pos["pages"].as<uint16_t>();
rich.totalPages = pages > 0 ? pages : 1;
const uint16_t para = pos["para"].as<uint16_t>();
if (para > 0) rich.paragraphIndex = para;
rich.xpath = pos["xpath"].as<const char*>() ? pos["xpath"].as<const char*>() : "";
LOG_DBG("KOSync", "Got rich position: spine=%u page=%u/%u para=%u", rich.spineIndex, rich.pageNumber,
rich.totalPages, para);
outProgress.position = std::move(rich);
}
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str()); LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
return OK; return OK;
} }
@@ -158,6 +213,18 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
doc["percentage"] = progress.percentage; doc["percentage"] = progress.percentage;
doc["device"] = DEVICE_NAME; doc["device"] = DEVICE_NAME;
doc["device_id"] = DEVICE_ID; doc["device_id"] = DEVICE_ID;
if (progress.position.has_value()) {
// Extended crosspoint-sync field; kosync servers ignore unknown keys.
const auto& p = *progress.position;
auto pos = doc["position"].to<JsonObject>();
pos["pctQ"] = p.pctQ;
pos["spine"] = p.spineIndex;
pos["page"] = p.pageNumber;
pos["pages"] = p.totalPages;
if (p.paragraphIndex.has_value()) pos["para"] = *p.paragraphIndex;
// Server rejects the whole position object if xpath exceeds 120 bytes.
if (!p.xpath.empty() && p.xpath.size() <= 120) pos["xpath"] = p.xpath;
}
std::string body; std::string body;
serializeJson(doc, body); serializeJson(doc, body);
+42 -8
View File
@@ -14,17 +14,33 @@ struct KOReaderMetadata {
std::string authors; // Author(s) from EPUB metadata std::string authors; // Author(s) from EPUB metadata
}; };
/**
* Rich CrossPoint position sent alongside progress uploads. Maps 1:1 onto the
* crosspoint-sync extended `position` object (see crosspoint-sync docs/API.md).
* The official KOSync server ignores unknown fields; crosspoint-sync stores it
* so CrossPoint<->CrossPoint sync is lossless instead of xpath-approximated.
*/
struct KOReaderRichPosition {
uint32_t pctQ = 0; // Percentage quantized 0..1,000,000 (authoritative)
uint16_t spineIndex = 0; // Spine (chapter) index
uint16_t pageNumber = 0; // Page within spine (layout-dependent hint)
uint16_t totalPages = 1; // Spine page count (layout-dependent hint)
std::optional<uint16_t> paragraphIndex; // Synthetic 1-based paragraph index
std::string xpath; // KOReader-style xpath (server cap: 120 bytes)
};
/** /**
* Progress data from KOReader sync server. * Progress data from KOReader sync server.
*/ */
struct KOReaderProgress { struct KOReaderProgress {
std::string document; // Document hash std::string document; // Document hash
std::string progress; // XPath-like progress string std::string progress; // XPath-like progress string
float percentage; // Progress percentage (0.0 to 1.0) float percentage; // Progress percentage (0.0 to 1.0)
std::string device; // Device name std::string device; // Device name
std::string deviceId; // Device ID std::string deviceId; // Device ID
int64_t timestamp; // Unix timestamp of last update int64_t timestamp; // Unix timestamp of last update
std::optional<KOReaderMetadata> metadata; // Optional document metadata std::optional<KOReaderMetadata> metadata; // Optional document metadata
std::optional<KOReaderRichPosition> position; // Optional rich position (crosspoint-sync servers only)
}; };
/** /**
@@ -43,7 +59,17 @@ struct KOReaderProgress {
*/ */
class KOReaderSyncClient { class KOReaderSyncClient {
public: public:
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND, LOW_MEMORY }; enum Error {
OK = 0,
NO_CREDENTIALS,
NETWORK_ERROR,
AUTH_FAILED,
SERVER_ERROR,
JSON_ERROR,
NOT_FOUND,
LOW_MEMORY,
USER_EXISTS
};
/** /**
* Authenticate with the sync server (validate credentials). * Authenticate with the sync server (validate credentials).
@@ -51,6 +77,14 @@ class KOReaderSyncClient {
*/ */
static Error authenticate(); static Error authenticate();
/**
* Register a new account on the sync server using the stored credentials
* (POST /users/create with the MD5 auth key — the server never sees the
* plain password).
* @return OK on success, USER_EXISTS if the username is taken
*/
static Error createUser();
/** /**
* Get reading progress for a document. * Get reading progress for a document.
* @param documentHash The document hash (from KOReaderDocumentId) * @param documentHash The document hash (from KOReaderDocumentId)
+53
View File
@@ -724,6 +724,59 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
return result; return result;
} }
std::optional<CrossPointPosition> ProgressMapper::fromRichPosition(const std::shared_ptr<Epub>& epub,
const KOReaderRichPosition& rich,
GfxRenderer& renderer) {
const int spineCount = epub->getSpineItemsCount();
if (static_cast<int>(rich.spineIndex) >= spineCount) {
LOG_DBG("PM", "Rich position spine %u out of range (%d spine items)", rich.spineIndex, spineCount);
return std::nullopt;
}
CrossPointPosition result{};
result.spineIndex = rich.spineIndex;
Section tempSection(epub, result.spineIndex, renderer);
const auto cachedCount = tempSection.getCachedPageCount();
if (!cachedCount || *cachedCount <= 0) {
// No local layout for the target spine yet; the percentage/xpath mapping
// handles density estimation better than a blind copy of remote pages.
LOG_DBG("PM", "Rich position spine %u has no cached page count", rich.spineIndex);
return std::nullopt;
}
result.totalPages = *cachedCount;
const int remotePages = rich.totalPages > 0 ? rich.totalPages : 1;
if (result.totalPages == remotePages) {
// Identical layout (same render settings) — the page transfers losslessly.
result.pageNumber = std::min<int>(rich.pageNumber, result.totalPages - 1);
LOG_DBG("PM", "Rich position exact: spine=%d page=%d/%d", result.spineIndex, result.pageNumber, result.totalPages);
return result;
}
// Layout differs; the paragraph LUT is the most accurate anchor we have.
if (rich.paragraphIndex.has_value()) {
const auto lutPage = tempSection.getPageForParagraphIndex(*rich.paragraphIndex);
if (lutPage.has_value()) {
result.paragraphIndex = *rich.paragraphIndex;
result.hasParagraphIndex = true;
result.pageNumber = std::min<int>(*lutPage, result.totalPages - 1);
LOG_DBG("PM", "Rich position para %u -> spine=%d page=%d/%d", *rich.paragraphIndex, result.spineIndex,
result.pageNumber, result.totalPages);
return result;
}
}
// Fall back to the intra-spine page fraction.
const float intra =
(remotePages > 1) ? static_cast<float>(rich.pageNumber) / static_cast<float>(remotePages - 1) : 0.0f;
result.pageNumber = std::max(
0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1));
LOG_DBG("PM", "Rich position scaled: spine=%d remote %u/%d -> page=%d/%d", result.spineIndex, rich.pageNumber,
remotePages, result.pageNumber, result.totalPages);
return result;
}
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos, CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
GfxRenderer& renderer, int currentSpineIndex, GfxRenderer& renderer, int currentSpineIndex,
int totalPagesInCurrentSpine, int fallbackTotalPages) { int totalPagesInCurrentSpine, int fallbackTotalPages) {
+17
View File
@@ -3,8 +3,11 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <memory> #include <memory>
#include <optional>
#include <string> #include <string>
#include "KOReaderSyncClient.h"
/** /**
* CrossPoint position representation. * CrossPoint position representation.
*/ */
@@ -65,6 +68,20 @@ class ProgressMapper {
GfxRenderer& renderer, int currentSpineIndex = -1, GfxRenderer& renderer, int currentSpineIndex = -1,
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0); int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
/**
* Convert a rich CrossPoint position (downloaded from a crosspoint-sync
* server) directly to a CrossPoint position, without XPath approximation.
* When the local layout matches the uploader's (same spine page count) the
* page transfers losslessly; otherwise the paragraph LUT or the intra-spine
* page fraction is used.
*
* @return The position, or std::nullopt when the rich position cannot be
* applied (spine out of range, no section cache) and the caller
* should fall back to toCrossPoint().
*/
static std::optional<CrossPointPosition> fromRichPosition(const std::shared_ptr<Epub>& epub,
const KOReaderRichPosition& rich, GfxRenderer& renderer);
private: private:
/** /**
* Generate a fallback XPath by streaming the spine item's XHTML and resolving * Generate a fallback XPath by streaming the spine item's XHTML and resolving
+4 -2
View File
@@ -29,8 +29,8 @@ class CrossPointSettings {
LIGHT = 1, LIGHT = 1,
CUSTOM = 2, CUSTOM = 2,
COVER = 3, COVER = 3,
BLANK = 4, COVER_CUSTOM = 4,
COVER_CUSTOM = 5, BLANK = 5,
QUICK_RESUME = 6, QUICK_RESUME = 6,
SLEEP_SCREEN_MODE_COUNT SLEEP_SCREEN_MODE_COUNT
}; };
@@ -271,6 +271,8 @@ class CrossPointSettings {
uint8_t removeReadBooksFromRecents = 0; uint8_t removeReadBooksFromRecents = 0;
// Move epub to /Read/ folder on SD card when finished (0 = disabled, 1 = enabled) // Move epub to /Read/ folder on SD card when finished (0 = disabled, 1 = enabled)
uint8_t moveFinishedToReadFolder = 0; uint8_t moveFinishedToReadFolder = 0;
// Short press Back goes to file browser instead of home (0 = disabled, 1 = enabled)
uint8_t backShortToFileBrowser = 0;
// Image rendering mode in EPUB reader // Image rendering mode in EPUB reader
uint8_t imageRendering = IMAGES_DISPLAY; uint8_t imageRendering = IMAGES_DISPLAY;
// Tilt-based page turning (X3 only — requires QMI8658 IMU) // Tilt-based page turning (X3 only — requires QMI8658 IMU)
+10
View File
@@ -181,6 +181,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
"shortPwrBtn", StrId::STR_CAT_CONTROLS), "shortPwrBtn", StrId::STR_CAT_CONTROLS),
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack, SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS), "pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
SettingInfo::Toggle(StrId::STR_BACK_SHORT_TO_FILE_BROWSER, &CrossPointSettings::backShortToFileBrowser,
"backShortToFileBrowser", StrId::STR_CAT_CONTROLS),
// --- System --- // --- System ---
SettingInfo::Value( SettingInfo::Value(
@@ -242,6 +244,14 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
KOREADER_STORE.saveToFile(); KOREADER_STORE.saveToFile();
}, },
"koSendMetadata", StrId::STR_KOREADER_SYNC), "koSendMetadata", StrId::STR_KOREADER_SYNC),
SettingInfo::DynamicEnum(
StrId::STR_SYNC_BEHAVIOR, {StrId::STR_ASK_EVERY_TIME, StrId::STR_SMART_SYNC},
[] { return static_cast<uint8_t>(KOREADER_STORE.getSyncBehavior()); },
[](uint8_t v) {
KOREADER_STORE.setSyncBehavior(static_cast<KOReaderSyncBehavior>(v));
KOREADER_STORE.saveToFile();
},
"koSyncBehavior", StrId::STR_KOREADER_SYNC),
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount,
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
+13 -1
View File
@@ -179,6 +179,17 @@ void HomeActivity::loop() {
requestUpdate(); requestUpdate();
}); });
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) backPressSeen = true;
// Back is otherwise unused on the home menu: open the most recently read
// book directly (recentBooks is most-recent-first and already pruned of
// files missing from the SD card). backPressSeen guards against the stale
// release of the Back press that closed the previous activity.
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && backPressSeen && !recentBooks.empty()) {
onSelectBook(recentBooks[0].path);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex < recentBooks.size()) { if (selectorIndex < recentBooks.size()) {
onSelectBook(recentBooks[selectorIndex].path); onSelectBook(recentBooks[selectorIndex].path);
@@ -256,7 +267,8 @@ void HomeActivity::render(RenderLock&&) {
[&menuItems](int index) { return std::string(menuItems[index]); }, [&menuItems](int index) { return std::string(menuItems[index]); },
[&menuIcons](int index) { return menuIcons[index]; }); [&menuIcons](int index) { return menuIcons[index]; });
const auto labels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); const auto labels = mappedInput.mapLabels(recentBooks.empty() ? "" : tr(STR_RESUME), tr(STR_SELECT), tr(STR_DIR_UP),
tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
+3
View File
@@ -18,6 +18,9 @@ class HomeActivity final : public Activity {
bool hasOpdsServers = false; bool hasOpdsServers = false;
bool coverRendered = false; // Track if cover has been rendered once bool coverRendered = false; // Track if cover has been rendered once
bool coverBufferStored = false; // Track if cover buffer is stored bool coverBufferStored = false; // Track if cover buffer is stored
// Home can be entered while Back is still held (e.g. leaving Settings with
// Back): ignore that stale release until a fresh press is seen here.
bool backPressSeen = false;
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
// Logical rect last passed to drawRecentBookCover. The cover snapshot only // Logical rect last passed to drawRecentBookCover. The cover snapshot only
@@ -6,6 +6,10 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "ReaderUtils.h" #include "ReaderUtils.h"
// ReaderUtils.h pulls in ActivityManager.h, which only forward-declares Activity while holding
// std::unique_ptr<Activity> members. Destroying that unique_ptr needs the complete type, so the
// definition must be visible here.
#include "activities/Activity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/ButtonNavigator.h" #include "util/ButtonNavigator.h"
+6 -11
View File
@@ -448,20 +448,15 @@ void EpubReaderActivity::loop() {
} }
} }
// Long press BACK (1s+) goes to file selection // Short press Back restores position when viewing a footnote (takes priority over navigation)
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { if (footnoteDepth > 0 && mappedInput.wasReleased(MappedInputManager::Button::Back) &&
activityManager.goToFileBrowser(epub ? epub->getPath() : ""); mappedInput.getHeldTime() < ReaderUtils::GO_BACK_OR_HOME_MS) {
restoreSavedPosition();
return; return;
} }
// Short press BACK goes directly to home (or restores position if viewing footnote) if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, epub ? epub->getPath().c_str() : "",
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && {this, [](void* ctx) { static_cast<EpubReaderActivity*>(ctx)->onGoHome(); }})) {
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
if (footnoteDepth > 0) {
restoreSavedPosition();
return;
}
onGoHome();
return; return;
} }
+132 -16
View File
@@ -10,6 +10,7 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <cmath>
#include "Epub/Section.h" #include "Epub/Section.h"
#include "EpubReaderUtils.h" #include "EpubReaderUtils.h"
@@ -24,6 +25,19 @@
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
std::string calculateDocumentHashForMethod(const std::string& path, const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? KOReaderDocumentId::calculateFromFilename(path)
: KOReaderDocumentId::calculate(path);
}
DocumentMatchMethod alternateMatchMethod(const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? DocumentMatchMethod::BINARY : DocumentMatchMethod::FILENAME;
}
const char* matchMethodName(const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? "filename" : "binary";
}
void syncTimeWithNTP() { void syncTimeWithNTP() {
// Stop SNTP if already running (can't reconfigure while running) // Stop SNTP if already running (can't reconfigure while running)
if (esp_sntp_enabled()) { if (esp_sntp_enabled()) {
@@ -84,6 +98,21 @@ void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); } void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); }
bool KOReaderSyncActivity::smartSyncEnabled() const {
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART;
}
void KOReaderSyncActivity::markAutoReturn() { autoReturnAt = millis() + AUTO_RETURN_DELAY_MS; }
void KOReaderSyncActivity::completeAlreadySynced() {
{
RenderLock lock(*this);
state = SYNC_COMPLETE;
}
markAutoReturn();
requestUpdate(true);
}
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
if (!success) { if (!success) {
LOG_DBG("KOSync", "WiFi connection failed, exiting"); LOG_DBG("KOSync", "WiFi connection failed, exiting");
@@ -113,12 +142,8 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
} }
void KOReaderSyncActivity::performSync() { void KOReaderSyncActivity::performSync() {
// Calculate document hash based on user's preferred method const DocumentMatchMethod primaryMethod = KOREADER_STORE.getMatchMethod();
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) { documentHash = calculateDocumentHashForMethod(epubPath, primaryMethod);
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
} else {
documentHash = KOReaderDocumentId::calculate(epubPath);
}
if (documentHash.empty()) { if (documentHash.empty()) {
{ {
RenderLock lock(*this); RenderLock lock(*this);
@@ -128,8 +153,9 @@ void KOReaderSyncActivity::performSync() {
requestUpdate(true); requestUpdate(true);
return; return;
} }
const std::string primaryHash = documentHash;
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str()); LOG_DBG("KOSync", "Document hash (%s): %s", matchMethodName(primaryMethod), documentHash.c_str());
{ {
RenderLock lock(*this); RenderLock lock(*this);
@@ -137,10 +163,42 @@ void KOReaderSyncActivity::performSync() {
} }
requestUpdateAndWait(); requestUpdateAndWait();
// Fetch remote progress // Fetch remote progress. In smart mode, also probe the alternate document-id
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); // method and use the furthest remote state we can find. This avoids a stale
// local upload when another KOReader device synced the same book with a
// different document matching method.
auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
LOG_DBG("KOSync", "Primary remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
matchMethodName(primaryMethod), result, KOReaderSyncClient::lastHttpCode, documentHash.c_str(),
localProgress.percentage, remoteProgress.percentage, remoteProgress.progress.c_str());
if (smartSyncEnabled()) {
const DocumentMatchMethod altMethod = alternateMatchMethod(primaryMethod);
const std::string altHash = calculateDocumentHashForMethod(epubPath, altMethod);
if (!altHash.empty() && altHash != documentHash) {
KOReaderProgress altProgress;
const auto altResult = KOReaderSyncClient::getProgress(altHash, altProgress);
LOG_DBG("KOSync", "Alternate remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
matchMethodName(altMethod), altResult, KOReaderSyncClient::lastHttpCode, altHash.c_str(),
localProgress.percentage, altProgress.percentage, altProgress.progress.c_str());
if (altResult == KOReaderSyncClient::OK &&
(result == KOReaderSyncClient::NOT_FOUND || altProgress.percentage > remoteProgress.percentage)) {
documentHash = altHash;
remoteProgress = std::move(altProgress);
result = KOReaderSyncClient::OK;
}
}
}
if (result == KOReaderSyncClient::NOT_FOUND) { if (result == KOReaderSyncClient::NOT_FOUND) {
if (smartSyncEnabled()) {
LOG_DBG("KOSync", "Smart sync: no remote progress found for known document hashes; uploading local %.6f",
localProgress.percentage);
performUpload();
return;
}
// No remote progress - offer to upload // No remote progress - offer to upload
{ {
RenderLock lock(*this); RenderLock lock(*this);
@@ -174,8 +232,42 @@ void KOReaderSyncActivity::performSync() {
return; return;
} }
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; // Prefer the exact spine/page from a crosspoint-sync rich position (lossless
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine); // CrossPoint<->CrossPoint sync); fall back to the approximate XPath mapping
// for plain kosync servers or when the rich position cannot be applied.
std::optional<CrossPointPosition> richMapped;
if (remoteProgress.position.has_value()) {
richMapped = ProgressMapper::fromRichPosition(epub, *remoteProgress.position, renderer);
}
if (richMapped.has_value()) {
remotePosition = *richMapped;
} else {
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
}
if (smartSyncEnabled()) {
static constexpr float SAME_PROGRESS_EPSILON = 0.001f; // 0.1 percentage points
const float delta = localProgress.percentage - remoteProgress.percentage;
LOG_DBG("KOSync", "Smart decision: doc=%s local=%.6f remote=%.6f delta=%.6f remoteXpath=%s mapped=%d/%d",
documentHash.c_str(), localProgress.percentage, remoteProgress.percentage, delta,
remoteProgress.progress.c_str(), remotePosition.spineIndex, remotePosition.pageNumber);
if (std::fabs(delta) <= SAME_PROGRESS_EPSILON) {
completeAlreadySynced();
return;
}
if (delta > 0) {
// Alternate hashes are only probes for newer remote state. Keep uploads
// on the user's configured matching method so its primary record heals.
documentHash = primaryHash;
performUpload();
return;
}
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
return;
}
// localProgress was pre-computed in EpubReaderActivity before the Epub was released. // localProgress was pre-computed in EpubReaderActivity before the Epub was released.
{ {
@@ -206,6 +298,22 @@ void KOReaderSyncActivity::performUpload() {
progress.progress = localProgress.xpath; progress.progress = localProgress.xpath;
progress.percentage = localProgress.percentage; progress.percentage = localProgress.percentage;
// Rich CrossPoint position for crosspoint-sync servers (lossless
// CrossPoint<->CrossPoint sync); plain kosync servers ignore the extra field.
{
KOReaderRichPosition pos;
const float pct = localProgress.percentage < 0.0f ? 0.0f
: localProgress.percentage > 1.0f ? 1.0f
: localProgress.percentage;
pos.pctQ = static_cast<uint32_t>(pct * 1000000.0f + 0.5f);
pos.spineIndex = static_cast<uint16_t>(currentSpineIndex);
pos.pageNumber = static_cast<uint16_t>(currentPage);
pos.totalPages = static_cast<uint16_t>(totalPagesInSpine > 0 ? totalPagesInSpine : 1);
pos.paragraphIndex = currentParagraphIndex;
pos.xpath = localProgress.xpath;
progress.position = std::move(pos);
}
// Optionally include document metadata (KOReader PR #15306) // Optionally include document metadata (KOReader PR #15306)
if (KOREADER_STORE.getSendMetadata()) { if (KOREADER_STORE.getSendMetadata()) {
// The Epub is released before the sync network calls and is only reloaded on the // The Epub is released before the sync network calls and is only reloaded on the
@@ -248,6 +356,7 @@ void KOReaderSyncActivity::performUpload() {
RenderLock lock(*this); RenderLock lock(*this);
state = UPLOAD_COMPLETE; state = UPLOAD_COMPLETE;
} }
markAutoReturn();
requestUpdate(true); requestUpdate(true);
} }
@@ -391,10 +500,12 @@ void KOReaderSyncActivity::render(RenderLock&&) {
return; return;
} }
if (state == UPLOAD_COMPLETE) { if (state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD); UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top,
state == UPLOAD_COMPLETE ? tr(STR_UPLOAD_SUCCESS) : tr(STR_ALREADY_SYNCED), true,
EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DONE), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
@@ -412,8 +523,13 @@ void KOReaderSyncActivity::render(RenderLock&&) {
} }
void KOReaderSyncActivity::loop() { void KOReaderSyncActivity::loop() {
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (autoReturnAt != 0 && millis() >= autoReturnAt) {
returnToReader();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
returnToReader(); returnToReader();
} }
return; return;
+9 -1
View File
@@ -40,7 +40,7 @@ class KOReaderSyncActivity final : public Activity {
void onExit() override; void onExit() override;
void loop() override; void loop() override;
void render(RenderLock&&) override; void render(RenderLock&&) override;
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; } bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING || state == UPLOADING; }
private: private:
enum State { enum State {
@@ -50,6 +50,7 @@ class KOReaderSyncActivity final : public Activity {
SHOWING_RESULT, SHOWING_RESULT,
UPLOADING, UPLOADING,
UPLOAD_COMPLETE, UPLOAD_COMPLETE,
SYNC_COMPLETE,
NO_REMOTE_PROGRESS, NO_REMOTE_PROGRESS,
SYNC_FAILED, SYNC_FAILED,
NO_CREDENTIALS NO_CREDENTIALS
@@ -78,6 +79,10 @@ class KOReaderSyncActivity final : public Activity {
// Selection in result screen (0=Apply, 1=Upload) // Selection in result screen (0=Apply, 1=Upload)
int selectedOption = 0; int selectedOption = 0;
// Timed return for successful smart-sync terminal states.
unsigned long autoReturnAt = 0;
static constexpr unsigned long AUTO_RETURN_DELAY_MS = 1200;
// Tracks whether this session activated WiFi. Set in onEnter past the credentials // Tracks whether this session activated WiFi. Set in onEnter past the credentials
// check; checked in onExit to decide whether to silent-reboot. Can't rely on // check; checked in onExit to decide whether to silent-reboot. Can't rely on
// WiFi.getMode() because performUpload() calls esp_wifi_stop() on the way out, // WiFi.getMode() because performUpload() calls esp_wifi_stop() on the way out,
@@ -87,6 +92,9 @@ class KOReaderSyncActivity final : public Activity {
void onWifiSelectionComplete(bool success); void onWifiSelectionComplete(bool success);
void performSync(); void performSync();
void performUpload(); void performUpload();
bool smartSyncEnabled() const;
void markAutoReturn();
void completeAlreadySynced();
void ensureEpubLoaded(); void ensureEpubLoaded();
void saveProgressAndReturn(int spineIndex, int page); void saveProgressAndReturn(int spineIndex, int page);
void returnToReader(); void returnToReader();
+35
View File
@@ -6,10 +6,12 @@
#include <Logging.h> #include <Logging.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/ActivityManager.h"
namespace ReaderUtils { namespace ReaderUtils {
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
constexpr unsigned long GO_BACK_OR_HOME_MS = GO_HOME_MS;
constexpr unsigned long SKIP_HOLD_MS = 700; constexpr unsigned long SKIP_HOLD_MS = 700;
constexpr unsigned long BOOKMARK_HOLD_MS = 400; constexpr unsigned long BOOKMARK_HOLD_MS = 400;
constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500; constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500;
@@ -96,4 +98,37 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) {
renderer.restoreBwBuffer(); renderer.restoreBwBuffer();
} }
struct BackNavCallback {
void* ctx;
void (*fn)(void*);
};
// Returns true if the back button was consumed (caller should return).
// Long press (>= GO_BACK_OR_HOME_MS):
// - default: go to file browser
// - with backShortToFileBrowser: go home
// Short press (< GO_BACK_OR_HOME_MS):
// - default: go home
// - with backShortToFileBrowser: go to file browser.
inline bool handleBackNavigation(const MappedInputManager& mappedInput, ActivityManager& activityManager,
const char* filePath, BackNavCallback goHome) {
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_BACK_OR_HOME_MS) {
if (SETTINGS.backShortToFileBrowser) {
goHome.fn(goHome.ctx);
} else {
activityManager.goToFileBrowser(filePath);
}
return true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < GO_BACK_OR_HOME_MS) {
if (SETTINGS.backShortToFileBrowser) {
activityManager.goToFileBrowser(filePath);
} else {
goHome.fn(goHome.ctx);
}
return true;
}
return false;
}
} // namespace ReaderUtils } // namespace ReaderUtils
+2 -10
View File
@@ -60,16 +60,8 @@ void TxtReaderActivity::onExit() {
} }
void TxtReaderActivity::loop() { void TxtReaderActivity::loop() {
// Long press BACK (1s+) goes to file selection if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, txt ? txt->getPath().c_str() : "",
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { {this, [](void* ctx) { static_cast<TxtReaderActivity*>(ctx)->onGoHome(); }})) {
activityManager.goToFileBrowser(txt ? txt->getPath() : "");
return;
}
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
onGoHome();
return; return;
} }
+2 -10
View File
@@ -101,16 +101,8 @@ void XtcReaderActivity::loop() {
openChapterSelection(); openChapterSelection();
} }
// Long press BACK (1s+) goes to file selection if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, xtc ? xtc->getPath().c_str() : "",
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { {this, [](void* ctx) { static_cast<XtcReaderActivity*>(ctx)->onGoHome(); }})) {
activityManager.goToFileBrowser(xtc ? xtc->getPath() : "");
return;
}
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
onGoHome();
return; return;
} }
@@ -26,7 +26,7 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
{ {
RenderLock lock(*this); RenderLock lock(*this);
state = AUTHENTICATING; state = AUTHENTICATING;
statusMessage = tr(STR_AUTHENTICATING); statusMessage = mode == Mode::SIGN_UP ? tr(STR_CREATING_ACCOUNT) : tr(STR_AUTHENTICATING);
} }
requestUpdate(); requestUpdate();
@@ -34,16 +34,17 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
} }
void KOReaderAuthActivity::performAuthentication() { void KOReaderAuthActivity::performAuthentication() {
const auto result = KOReaderSyncClient::authenticate(); const auto result = mode == Mode::SIGN_UP ? KOReaderSyncClient::createUser() : KOReaderSyncClient::authenticate();
{ {
RenderLock lock(*this); RenderLock lock(*this);
if (result == KOReaderSyncClient::OK) { if (result == KOReaderSyncClient::OK) {
state = SUCCESS; state = SUCCESS;
statusMessage = tr(STR_AUTH_SUCCESS); statusMessage = mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS);
} else { } else {
state = FAILED; state = FAILED;
errorMessage = KOReaderSyncClient::errorString(result); errorMessage =
result == KOReaderSyncClient::USER_EXISTS ? tr(STR_USERNAME_TAKEN) : KOReaderSyncClient::errorString(result);
} }
} }
requestUpdate(); requestUpdate();
@@ -80,17 +81,21 @@ void KOReaderAuthActivity::render(RenderLock&&) {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_KOREADER_AUTH)); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight},
mode == Mode::SIGN_UP ? tr(STR_SIGN_UP) : tr(STR_KOREADER_AUTH));
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height) / 2; const auto top = (pageHeight - height) / 2;
if (state == AUTHENTICATING) { if (state == AUTHENTICATING) {
renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str());
} else if (state == SUCCESS) { } else if (state == SUCCESS) {
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_SUCCESS), true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, top,
mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS), true,
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY)); renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY));
} else if (state == FAILED) { } else if (state == FAILED) {
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_FAILED), true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, top, mode == Mode::SIGN_UP ? tr(STR_SIGNUP_FAILED) : tr(STR_AUTH_FAILED),
true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str());
} }
@@ -5,13 +5,16 @@
#include "activities/Activity.h" #include "activities/Activity.h"
/** /**
* Activity for testing KOReader credentials. * Activity for testing KOReader credentials, or — in sign-up mode — creating a
* Connects to WiFi and authenticates with the KOReader sync server. * new account on the sync server with the entered username/password.
* Connects to WiFi, then authenticates or registers.
*/ */
class KOReaderAuthActivity final : public Activity { class KOReaderAuthActivity final : public Activity {
public: public:
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) enum class Mode { AUTHENTICATE, SIGN_UP };
: Activity("KOReaderAuth", renderer, mappedInput) {}
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, Mode mode = Mode::AUTHENTICATE)
: Activity("KOReaderAuth", renderer, mappedInput), mode(mode) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -22,6 +25,7 @@ class KOReaderAuthActivity final : public Activity {
private: private:
enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED }; enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED };
Mode mode = Mode::AUTHENTICATE;
State state = WIFI_SELECTION; State state = WIFI_SELECTION;
std::string statusMessage; std::string statusMessage;
std::string errorMessage; std::string errorMessage;
@@ -13,9 +13,10 @@
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
constexpr int MENU_ITEMS = 6; constexpr int MENU_ITEMS = 8;
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL, const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_AUTHENTICATE}; StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_SYNC_BEHAVIOR,
StrId::STR_SIGN_UP, StrId::STR_AUTHENTICATE};
} // namespace } // namespace
void KOReaderSettingsActivity::onEnter() { void KOReaderSettingsActivity::onEnter() {
@@ -103,6 +104,22 @@ void KOReaderSettingsActivity::handleSelection() {
KOREADER_STORE.saveToFile(); KOREADER_STORE.saveToFile();
requestUpdate(); requestUpdate();
} else if (selectedIndex == 5) { } else if (selectedIndex == 5) {
// Sync behavior - toggle between Ask and Smart
const auto current = KOREADER_STORE.getSyncBehavior();
const auto newBehavior = (current == KOReaderSyncBehavior::ASK_EVERY_TIME) ? KOReaderSyncBehavior::SMART
: KOReaderSyncBehavior::ASK_EVERY_TIME;
KOREADER_STORE.setSyncBehavior(newBehavior);
KOREADER_STORE.saveToFile();
requestUpdate();
} else if (selectedIndex == 6) {
// Sign Up - create a new account on the sync server with the entered credentials
if (!KOREADER_STORE.hasCredentials()) {
return;
}
startActivityForResult(
std::make_unique<KOReaderAuthActivity>(renderer, mappedInput, KOReaderAuthActivity::Mode::SIGN_UP),
[](const ActivityResult&) {});
} else if (selectedIndex == 7) {
// Authenticate // Authenticate
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
// Can't authenticate without credentials - just show message briefly // Can't authenticate without credentials - just show message briefly
@@ -136,13 +153,25 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******"); return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
} else if (index == 2) { } else if (index == 2) {
auto serverUrl = KOREADER_STORE.getServerUrl(); auto serverUrl = KOREADER_STORE.getServerUrl();
return serverUrl.empty() ? std::string(tr(STR_DEFAULT_VALUE)) : serverUrl; if (!serverUrl.empty()) {
return serverUrl;
}
// Show which server the default actually is, scheme stripped for space
std::string defaultUrl = KOREADER_STORE.getBaseUrl();
const auto schemeEnd = defaultUrl.find("://");
if (schemeEnd != std::string::npos) {
defaultUrl.erase(0, schemeEnd + 3);
}
return std::string(tr(STR_DEFAULT_VALUE)) + ": " + defaultUrl;
} else if (index == 3) { } else if (index == 3) {
return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME)) return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME))
: std::string(tr(STR_BINARY)); : std::string(tr(STR_BINARY));
} else if (index == 4) { } else if (index == 4) {
return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF)); return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
} else if (index == 5) { } else if (index == 5) {
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART ? std::string(tr(STR_SMART_SYNC))
: std::string(tr(STR_ASK_EVERY_TIME));
} else if (index == 6 || index == 7) {
return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]"; return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]";
} }
return std::string(tr(STR_NOT_SET)); return std::string(tr(STR_NOT_SET));
+23 -13
View File
@@ -132,6 +132,11 @@ void CrossPointWebServer::begin() {
return; return;
} }
// Add Access-Control-Allow-* headers to every response so web-based clients
// and PWAs on other origins can use the HTTP API. Preflight OPTIONS requests
// are answered in handleNotFound().
server->enableCORS(true);
// Setup routes // Setup routes
LOG_DBG("WEB", "Setting up routes..."); LOG_DBG("WEB", "Setting up routes...");
server->on("/", HTTP_GET, [this] { handleRoot(); }); server->on("/", HTTP_GET, [this] { handleRoot(); });
@@ -353,6 +358,13 @@ void CrossPointWebServer::handleJszip() const {
} }
void CrossPointWebServer::handleNotFound() const { void CrossPointWebServer::handleNotFound() const {
// CORS preflight: routes are registered per-method, so OPTIONS requests land
// here. The Access-Control-Allow-* headers are added by enableCORS().
if (server->method() == HTTP_OPTIONS) {
server->send(204, "text/plain", "");
return;
}
// in AP mode, redirect unmatched browser/captive-portal requests to "/" so the OS auto-opens the browser // in AP mode, redirect unmatched browser/captive-portal requests to "/" so the OS auto-opens the browser
// API requests (/api/*) still return 404 so XHR errors surface correctly // API requests (/api/*) still return 404 so XHR errors surface correctly
// see https://en.wikipedia.org/wiki/Captive_portal#Detection // see https://en.wikipedia.org/wiki/Captive_portal#Detection
@@ -671,17 +683,15 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str()); LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str());
LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap()); LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap());
// Create file path
String filePath = state.path; String filePath = state.path;
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += state.fileName; filePath += state.fileName;
// Check if file already exists - SD operations can be slow
esp_task_wdt_reset(); esp_task_wdt_reset();
if (Storage.exists(filePath.c_str())) { if (Storage.exists(filePath.c_str())) {
LOG_DBG("WEB", "[UPLOAD] Overwriting existing file: %s", filePath.c_str()); state.error = "File already exists: " + state.fileName;
esp_task_wdt_reset(); LOG_DBG("WEB", "[UPLOAD] Collision: %s", filePath.c_str());
Storage.remove(filePath.c_str()); return;
} }
// Open file for writing - this can be slow due to FAT cluster allocation // Open file for writing - this can be slow due to FAT cluster allocation
@@ -749,7 +759,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime, LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime,
writePercent); writePercent);
// Clear epub cache to prevent stale metadata issues when overwriting files // Clear epub cache after uploading the file
String filePath = state.path; String filePath = state.path;
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += state.fileName; filePath += state.fileName;
@@ -1624,20 +1634,20 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1); wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1);
} }
// Build file path
String filePath = wsUploadPath; String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName; filePath += wsUploadFileName;
LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize,
filePath.c_str());
// Check if file exists and remove it
esp_task_wdt_reset(); esp_task_wdt_reset();
if (Storage.exists(filePath.c_str())) { if (Storage.exists(filePath.c_str())) {
Storage.remove(filePath.c_str()); LOG_DBG("WS", "Upload collision: %s", filePath.c_str());
wsServer->sendTXT(num, "ERROR:File already exists: " + wsUploadFileName);
return;
} }
LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize,
filePath.c_str());
// Open file for writing // Open file for writing
esp_task_wdt_reset(); esp_task_wdt_reset();
if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) { if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
@@ -1721,7 +1731,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize, LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize,
elapsed, kbps); elapsed, kbps);
// Clear epub cache to prevent stale metadata issues when overwriting files // Clear epub cache after uploading the file
String filePath = wsUploadPath; String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName; filePath += wsUploadFileName;
+244 -35
View File
@@ -1579,6 +1579,19 @@
<div id="convertInfo" style="display:none; margin-bottom: 10px; font-size: 0.9em;"> <div id="convertInfo" style="display:none; margin-bottom: 10px; font-size: 0.9em;">
</div> </div>
<div class="advanced-setting-row">
<div class="setting-label">
<div class="setting-title">Rename from Book Metadata</div>
<div class="setting-desc">Use Title - Author.epub when available</div>
</div>
<div class="setting-controls">
<label class="toggle-switch">
<input type="checkbox" id="renameFromMetadataToggle" onchange="updateUploadSettingsPersistence()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="convert-settings" id="convertSettings" style="display:block;"> <div class="convert-settings" id="convertSettings" style="display:block;">
<span>⚫ True-Grayscale</span> <span>⚫ True-Grayscale</span>
<span id="convertSizeSummary">📏 Max 480×800px</span> <span id="convertSizeSummary">📏 Max 480×800px</span>
@@ -1675,7 +1688,19 @@
</div> </div>
<div class="setting-controls"> <div class="setting-controls">
<label class="toggle-switch"> <label class="toggle-switch">
<input type="checkbox" id="export-log-checkbox"> <input type="checkbox" id="export-log-checkbox" onchange="updateUploadSettingsPersistence()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="advanced-setting-row">
<div class="setting-label">
<div class="setting-title">Remember Settings</div>
<div class="setting-desc">Store these upload options in this browser</div>
</div>
<div class="setting-controls">
<label class="toggle-switch">
<input type="checkbox" id="rememberUploadSettings" onchange="updateUploadSettingsPersistence()">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
@@ -2085,12 +2110,8 @@
// Modal functions // Modal functions
function openUploadModal() { function openUploadModal() {
// Reset converter variables to defaults
ENABLE_GRAYSCALE = true;
JPEG_QUALITY = 85;
HANDEDNESS = 'right';
OVERLAP_PERCENT = 5;
imageStates = {}; imageStates = {};
restoreUploadSettingsFromStorage();
// Hide convert options when opening modal (no files selected initially) // Hide convert options when opening modal (no files selected initially)
const convertOptions = document.getElementById('convertOptions'); const convertOptions = document.getElementById('convertOptions');
@@ -2098,10 +2119,6 @@
convertOptions.style.display = 'none'; convertOptions.style.display = 'none';
} }
// Reset rotation and overlap UI
setHandedness('right');
setOverlap(5);
// Hide log section from previous session // Hide log section from previous session
const logSection = document.getElementById('log-section'); const logSection = document.getElementById('log-section');
if (logSection) logSection.classList.remove('visible'); if (logSection) logSection.classList.remove('visible');
@@ -2160,7 +2177,8 @@
document.getElementById('progress-container').style.display = 'none'; document.getElementById('progress-container').style.display = 'none';
document.getElementById('progress-fill').style.width = '0%'; document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-fill').style.backgroundColor = '#27ae60'; document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
document.getElementById('convertBeforeUpload').checked = false; const convertOptions = document.getElementById('convertOptions');
if (convertOptions) convertOptions.style.display = 'none';
document.getElementById('convertInfo').style.display = 'none'; document.getElementById('convertInfo').style.display = 'none';
document.getElementById('convertWarning').style.display = 'none'; document.getElementById('convertWarning').style.display = 'none';
// Clear image picker cache and reset layout // Clear image picker cache and reset layout
@@ -2183,15 +2201,7 @@
advancedOptionsToggle.style.opacity = '0.5'; advancedOptionsToggle.style.opacity = '0.5';
advancedOptionsToggle.style.pointerEvents = 'none'; advancedOptionsToggle.style.pointerEvents = 'none';
} }
// Reset to defaults applyUploadSettings();
document.getElementById('qualitySlider').value = 85;
document.getElementById('qualityInput').value = 85;
const autoCropToggle = document.getElementById('autoCropToggle');
if (autoCropToggle) autoCropToggle.checked = false;
setHandedness('right');
setOverlap(5);
// Update converter variables
updateQualitySettings();
} }
function updateBatchModeUI(isBatch) { function updateBatchModeUI(isBatch) {
@@ -2228,6 +2238,7 @@
advancedOptionsToggle.style.opacity = checked ? '1' : '0.5'; advancedOptionsToggle.style.opacity = checked ? '1' : '0.5';
advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none'; advancedOptionsToggle.style.pointerEvents = checked ? 'auto' : 'none';
} }
updateUploadSettingsPersistence();
} }
function toggleAdvancedOptions() { function toggleAdvancedOptions() {
@@ -2265,12 +2276,8 @@
function setQualityPreset(value) { function setQualityPreset(value) {
document.getElementById('qualitySlider').value = value; document.getElementById('qualitySlider').value = value;
document.getElementById('qualityInput').value = value; document.getElementById('qualityInput').value = value;
// Update active preset
document.querySelectorAll('.quality-preset').forEach(btn => { document.querySelectorAll('.quality-preset').forEach(btn => {
btn.classList.remove('active'); btn.classList.toggle('active', parseInt(btn.dataset.value, 10) === value);
if (parseInt(btn.dataset.value, 10) === value) {
btn.classList.add('active');
}
}); });
updateQualitySettings(); updateQualitySettings();
} }
@@ -2292,6 +2299,7 @@
if (isImagePickerVisible()) { if (isImagePickerVisible()) {
renderImageGrid(); renderImageGrid();
} }
updateUploadSettingsPersistence();
} }
function setHandedness(value) { function setHandedness(value) {
@@ -2304,6 +2312,7 @@
if (isImagePickerVisible()) { if (isImagePickerVisible()) {
renderImageGrid(); renderImageGrid();
} }
updateUploadSettingsPersistence();
} }
function setOverlap(value) { function setOverlap(value) {
@@ -2312,6 +2321,7 @@
document.querySelectorAll('.overlap-btn').forEach(btn => { document.querySelectorAll('.overlap-btn').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.value) === value); btn.classList.toggle('active', parseInt(btn.dataset.value) === value);
}); });
updateUploadSettingsPersistence();
} }
function isImagePickerVisible() { function isImagePickerVisible() {
@@ -2980,7 +2990,12 @@
if (qualitySlider && qualityInput) { if (qualitySlider && qualityInput) {
// Initialize converter variables with UI default values // Initialize converter variables with UI default values
updateQualitySettings(); suppressUploadSettingsSave = true;
try {
updateQualitySettings();
} finally {
suppressUploadSettingsSave = false;
}
// Deselect all presets when slider is manually changed // Deselect all presets when slider is manually changed
const deselectPresets = function() { const deselectPresets = function() {
@@ -3213,15 +3228,13 @@
const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub')); const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub'));
if (files.length > 0 && hasEpub) { if (files.length > 0 && hasEpub) {
convertOptions.style.display = 'block'; convertOptions.style.display = 'block';
toggleConvertOptions();
} else { } else {
convertOptions.style.display = 'none'; convertOptions.style.display = 'none';
// Clear stale checkbox state so the "Optimize & Upload" button doesn't linger uploadBtn.textContent = 'Upload';
// when the user re-picks a non-EPUB after having ticked Optimize for an EPUB. uploadBtn.classList.remove('optimize');
const cb = document.getElementById('convertBeforeUpload'); document.getElementById('convertInfo').style.display = 'none';
if (cb && cb.checked) { document.getElementById('convertWarning').style.display = 'none';
cb.checked = false;
toggleConvertOptions();
}
if (files.length === 0) clearImagePicker(); if (files.length === 0) clearImagePicker();
} }
@@ -3303,6 +3316,75 @@ let ENABLE_GRAYSCALE = DEFAULT_ENABLE_GRAYSCALE;
let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP; let ENABLE_AUTO_CROP = DEFAULT_ENABLE_AUTO_CROP;
let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed) let HANDEDNESS = 'right'; // 'right' = clockwise (right-handed), 'left' = counter-clockwise (left-handed)
let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%) let OVERLAP_PERCENT = 5; // Minimum overlap percentage for splits (5%, 10%, 15%)
const UPLOAD_SETTINGS_STORAGE_KEY = 'crosspoint.files.uploadSettings.v1';
const DEFAULT_UPLOAD_SETTINGS = Object.freeze({
convertBeforeUpload: false,
renameFromMetadata: false,
quality: DEFAULT_JPEG_QUALITY,
autoCrop: DEFAULT_ENABLE_AUTO_CROP,
deviceTarget: 'auto',
handedness: 'right',
overlap: 5,
exportLog: false
});
let suppressUploadSettingsSave = false;
function getCurrentUploadSettings() {
return {
convertBeforeUpload: !!document.getElementById('convertBeforeUpload')?.checked,
renameFromMetadata: !!document.getElementById('renameFromMetadataToggle')?.checked,
quality: parseInt(document.getElementById('qualitySlider')?.value || JPEG_QUALITY, 10),
autoCrop: !!document.getElementById('autoCropToggle')?.checked,
deviceTarget: DEVICE_TARGET,
handedness: HANDEDNESS,
overlap: OVERLAP_PERCENT,
exportLog: !!document.getElementById('export-log-checkbox')?.checked
};
}
function applyUploadSettings(settings = {}) {
const merged = { ...DEFAULT_UPLOAD_SETTINGS, ...settings };
suppressUploadSettingsSave = true;
try {
document.getElementById('convertBeforeUpload').checked = !!merged.convertBeforeUpload;
document.getElementById('renameFromMetadataToggle').checked = !!merged.renameFromMetadata;
document.getElementById('autoCropToggle').checked = !!merged.autoCrop;
document.getElementById('export-log-checkbox').checked = !!merged.exportLog;
document.getElementById('rememberUploadSettings').checked = !!settings.rememberSettings;
setQualityPreset(Math.max(1, Math.min(95, parseInt(merged.quality, 10) || DEFAULT_JPEG_QUALITY)));
setDeviceTarget(['auto', 'X3', 'X4'].includes(merged.deviceTarget) ? merged.deviceTarget : 'auto');
setHandedness(merged.handedness === 'left' ? 'left' : 'right');
setOverlap([5, 10, 15].includes(Number(merged.overlap)) ? Number(merged.overlap) : 5);
toggleConvertOptions();
} finally {
suppressUploadSettingsSave = false;
}
}
function restoreUploadSettingsFromStorage() {
try {
const saved = JSON.parse(localStorage.getItem(UPLOAD_SETTINGS_STORAGE_KEY) || 'null');
applyUploadSettings(saved?.rememberSettings ? saved : undefined);
} catch (e) {
console.warn('Could not read remembered upload settings:', e);
applyUploadSettings();
}
}
function updateUploadSettingsPersistence() {
if (suppressUploadSettingsSave) return;
try {
if (!document.getElementById('rememberUploadSettings')?.checked) {
localStorage.removeItem(UPLOAD_SETTINGS_STORAGE_KEY);
return;
}
const settings = { ...getCurrentUploadSettings(), rememberSettings: true };
localStorage.setItem(UPLOAD_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} catch (e) {
console.warn('Could not save upload settings:', e);
}
}
// ============================================================================ // ============================================================================
// Image Picker State Management // Image Picker State Management
@@ -3380,6 +3462,7 @@ function applyDeviceTarget() {
function setDeviceTarget(value) { function setDeviceTarget(value) {
DEVICE_TARGET = value; DEVICE_TARGET = value;
applyDeviceTarget(); applyDeviceTarget();
updateUploadSettingsPersistence();
} }
// Batch logging system for multiple files // Batch logging system for multiple files
@@ -3788,6 +3871,104 @@ async function findOPFPath(zip) {
return fallback; return fallback;
} }
function sanitizeMetadataFilenamePart(value) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return (text.normalize ? text.normalize('NFC') : text)
.replace(/[<>:"/\\|?*\x00-\x1F]/g, ' ')
.replace(/\s+/g, ' ')
.replace(/^[. ]+/g, '')
.replace(/[. ]+$/g, '')
.trim();
}
function buildMetadataFilename(title, author) {
title = sanitizeMetadataFilenamePart(title);
author = sanitizeMetadataFilenamePart(author);
if (!title) return '';
let base = author ? `${title} - ${author}` : title;
if (base.length > 180) base = base.substring(0, 180).replace(/\s+\S*$/g, '').trim() || base.substring(0, 180).trim();
return `${base}.epub`;
}
async function getMetadataFilenameForEpub(file) {
if (typeof JSZip === 'undefined') return '';
const zip = await JSZip.loadAsync(file);
const opfPath = await findOPFPath(zip);
if (!opfPath || !zip.files[opfPath]) return '';
const doc = new DOMParser().parseFromString(await safeReadText(zip.files[opfPath]), 'application/xml');
if (doc.querySelector('parsererror')) return '';
const title = doc.getElementsByTagNameNS('*', 'title')[0]?.textContent || '';
const creators = Array.from(doc.getElementsByTagNameNS('*', 'creator'));
const getCreatorRole = el => (
el.getAttribute('role') ||
el.getAttribute('opf:role') ||
el.getAttributeNS('http://www.idpf.org/2007/opf', 'role') ||
''
).toLowerCase();
const authorEl = creators.find(el => getCreatorRole(el) === 'aut') ||
creators.find(el => !getCreatorRole(el));
const authorText = authorEl?.textContent?.trim();
const author = authorText ||
authorEl?.getAttribute('file-as') ||
authorEl?.getAttribute('opf:file-as') ||
authorEl?.getAttributeNS('http://www.idpf.org/2007/opf', 'file-as') ||
'';
return buildMetadataFilename(title, author);
}
async function maybeRenameEbookFile(file) {
const renameToggle = document.getElementById('renameFromMetadataToggle');
if (!renameToggle || !renameToggle.checked || !file.name.toLowerCase().endsWith('.epub')) {
return file;
}
try {
const metadataName = await getMetadataFilenameForEpub(file);
if (!metadataName || metadataName === file.name) return file;
return new File([file], metadataName, {
type: file.type || 'application/epub+zip',
lastModified: file.lastModified
});
} catch (e) {
console.warn('Could not rename EPUB from metadata:', e);
return file;
}
}
function reserveAvailableUploadFilename(fileName, usedFileNames) {
const normalize = name => name.toLowerCase();
if (!usedFileNames.has(normalize(fileName))) {
usedFileNames.add(normalize(fileName));
return fileName;
}
const dotIndex = fileName.lastIndexOf('.');
const extensionIndex = dotIndex > 0 ? dotIndex : fileName.length;
const baseName = fileName.substring(0, extensionIndex);
const extension = fileName.substring(extensionIndex);
let nextSuffix = 2;
let candidateName;
do {
candidateName = `${baseName} (${nextSuffix})${extension}`;
nextSuffix++;
} while (usedFileNames.has(normalize(candidateName)));
usedFileNames.add(normalize(candidateName));
return candidateName;
}
async function fetchExistingUploadNames() {
const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now());
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
const entries = await response.json();
return new Set(entries.map(entry => entry.name.toLowerCase()));
}
/** /**
* Resolve a relative href against a base file path. * Resolve a relative href against a base file path.
* Handles multiple ../, ./, absolute /, and bare relative paths. * Handles multiple ../, ./, absolute /, and bare relative paths.
@@ -5219,7 +5400,7 @@ function uploadFileHTTP(file, onProgress, onComplete, onError) {
}); });
} }
function uploadFile() { async function uploadFile() {
if (isUploadInProgress) return; if (isUploadInProgress) return;
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
@@ -5231,8 +5412,17 @@ function uploadFile() {
return; return;
} }
// Prevent modal close during upload
isUploadInProgress = true; isUploadInProgress = true;
let usedFileNames;
try {
usedFileNames = await fetchExistingUploadNames();
} catch (error) {
isUploadInProgress = false;
alert('Failed to check existing files: ' + error.message);
return;
}
// Prevent modal close during upload
uploadGeneration++; uploadGeneration++;
const myGeneration = uploadGeneration; const myGeneration = uploadGeneration;
document.getElementById('uploadModalClose').classList.add('disabled'); document.getElementById('uploadModalClose').classList.add('disabled');
@@ -5323,6 +5513,25 @@ function uploadFile() {
let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded
let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded
if (isEpub && document.getElementById('renameFromMetadataToggle').checked) {
const originalName = file.name;
progressText.style.color = '';
progressText.textContent = `Reading metadata for ${file.name} (${currentIndex + 1}/${files.length})...`;
file = await maybeRenameEbookFile(file);
if (file.name !== originalName) {
console.log(`[Upload] Renamed from metadata: ${originalName} -> ${file.name}`);
}
}
const availableName = reserveAvailableUploadFilename(file.name, usedFileNames);
if (availableName !== file.name) {
console.log(`[Upload] Renamed to avoid collision: ${file.name} -> ${availableName}`);
file = new File([file], availableName, {
type: file.type,
lastModified: file.lastModified
});
}
const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; const methodText = useWebSocket ? ' [WS]' : ' [HTTP]';
const stageText = needsConversion ? 'Converting & uploading' : 'Uploading'; const stageText = needsConversion ? 'Converting & uploading' : 'Uploading';
progressText.style.color = ''; progressText.style.color = '';