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