Adds KOReader Sync support (#232)

## Summary

- Adds KOReader progress sync integration, allowing CrossPoint to sync
reading positions with other
KOReader-compatible devices
- Stores credentials securely with XOR obfuscation
- Uses KOReader's partial MD5 document hashing for cross-device book
matching
  - Syncs position via percentage with estimated XPath for compatibility

# Features
- Settings: KOReader Username, Password, and Authenticate options
- Sync from chapters menu: "Sync Progress" option appears when
credentials are configured
- Bidirectional sync: Can apply remote progress or upload local progress

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
Justin Mitchell
2026-01-19 06:55:35 -05:00
committed by GitHub
parent 7185e5d287
commit f69cddf2cc
21 changed files with 1974 additions and 39 deletions

View File

@@ -609,14 +609,15 @@ int Epub::getSpineIndexForTextReference() const {
return 0;
}
// Calculate progress in book
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
// Calculate progress in book (returns 0.0-1.0)
float Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
const size_t bookSize = getBookSize();
if (bookSize == 0) {
return 0;
return 0.0f;
}
const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
const size_t sectionProgSize = currentSpineRead * curChapterSize;
return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0);
const float sectionProgSize = currentSpineRead * static_cast<float>(curChapterSize);
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
return totalProgress / static_cast<float>(bookSize);
}

View File

@@ -62,5 +62,5 @@ class Epub {
int getSpineIndexForTextReference() const;
size_t getBookSize() const;
uint8_t calculateProgress(int currentSpineIndex, float currentSpineRead) const;
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
};

View File

@@ -0,0 +1,168 @@
#include "KOReaderCredentialStore.h"
#include <HardwareSerial.h>
#include <MD5Builder.h>
#include <SDCardManager.h>
#include <Serialization.h>
// Initialize the static instance
KOReaderCredentialStore KOReaderCredentialStore::instance;
namespace {
// File format version
constexpr uint8_t KOREADER_FILE_VERSION = 1;
// KOReader credentials file path
constexpr char KOREADER_FILE[] = "/.crosspoint/koreader.bin";
// Default sync server URL
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
// Obfuscation key - "KOReader" in ASCII
// This is NOT cryptographic security, just prevents casual file reading
constexpr uint8_t OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72};
constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY);
} // namespace
void KOReaderCredentialStore::obfuscate(std::string& data) const {
for (size_t i = 0; i < data.size(); i++) {
data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH];
}
}
bool KOReaderCredentialStore::saveToFile() const {
// Make sure the directory exists
SdMan.mkdir("/.crosspoint");
FsFile file;
if (!SdMan.openFileForWrite("KRS", KOREADER_FILE, file)) {
return false;
}
// Write header
serialization::writePod(file, KOREADER_FILE_VERSION);
// Write username (plaintext - not particularly sensitive)
serialization::writeString(file, username);
Serial.printf("[%lu] [KRS] Saving username: %s\n", millis(), username.c_str());
// Write password (obfuscated)
std::string obfuscatedPwd = password;
obfuscate(obfuscatedPwd);
serialization::writeString(file, obfuscatedPwd);
// Write server URL
serialization::writeString(file, serverUrl);
// Write match method
serialization::writePod(file, static_cast<uint8_t>(matchMethod));
file.close();
Serial.printf("[%lu] [KRS] Saved KOReader credentials to file\n", millis());
return true;
}
bool KOReaderCredentialStore::loadFromFile() {
FsFile file;
if (!SdMan.openFileForRead("KRS", KOREADER_FILE, file)) {
Serial.printf("[%lu] [KRS] No credentials file found\n", millis());
return false;
}
// Read and verify version
uint8_t version;
serialization::readPod(file, version);
if (version != KOREADER_FILE_VERSION) {
Serial.printf("[%lu] [KRS] Unknown file version: %u\n", millis(), version);
file.close();
return false;
}
// Read username
if (file.available()) {
serialization::readString(file, username);
} else {
username.clear();
}
// Read and deobfuscate password
if (file.available()) {
serialization::readString(file, password);
obfuscate(password); // XOR is symmetric, so same function deobfuscates
} else {
password.clear();
}
// Read server URL
if (file.available()) {
serialization::readString(file, serverUrl);
} else {
serverUrl.clear();
}
// Read match method
if (file.available()) {
uint8_t method;
serialization::readPod(file, method);
matchMethod = static_cast<DocumentMatchMethod>(method);
} else {
matchMethod = DocumentMatchMethod::FILENAME;
}
file.close();
Serial.printf("[%lu] [KRS] Loaded KOReader credentials for user: %s\n", millis(), username.c_str());
return true;
}
void KOReaderCredentialStore::setCredentials(const std::string& user, const std::string& pass) {
username = user;
password = pass;
Serial.printf("[%lu] [KRS] Set credentials for user: %s\n", millis(), user.c_str());
}
std::string KOReaderCredentialStore::getMd5Password() const {
if (password.empty()) {
return "";
}
// Calculate MD5 hash of password using ESP32's MD5Builder
MD5Builder md5;
md5.begin();
md5.add(password.c_str());
md5.calculate();
return md5.toString().c_str();
}
bool KOReaderCredentialStore::hasCredentials() const { return !username.empty() && !password.empty(); }
void KOReaderCredentialStore::clearCredentials() {
username.clear();
password.clear();
saveToFile();
Serial.printf("[%lu] [KRS] Cleared KOReader credentials\n", millis());
}
void KOReaderCredentialStore::setServerUrl(const std::string& url) {
serverUrl = url;
Serial.printf("[%lu] [KRS] Set server URL: %s\n", millis(), url.empty() ? "(default)" : url.c_str());
}
std::string KOReaderCredentialStore::getBaseUrl() const {
if (serverUrl.empty()) {
return DEFAULT_SERVER_URL;
}
// Normalize URL: add http:// if no protocol specified (local servers typically don't have SSL)
if (serverUrl.find("://") == std::string::npos) {
return "http://" + serverUrl;
}
return serverUrl;
}
void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) {
matchMethod = method;
Serial.printf("[%lu] [KRS] Set match method: %s\n", millis(),
method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary");
}

View File

@@ -0,0 +1,69 @@
#pragma once
#include <cstdint>
#include <string>
// Document matching method for KOReader sync
enum class DocumentMatchMethod : uint8_t {
FILENAME = 0, // Match by filename (simpler, works across different file sources)
BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical)
};
/**
* Singleton class for storing KOReader sync credentials on the SD card.
* Credentials are stored in /sd/.crosspoint/koreader.bin with basic
* XOR obfuscation to prevent casual reading (not cryptographically secure).
*/
class KOReaderCredentialStore {
private:
static KOReaderCredentialStore instance;
std::string username;
std::string password;
std::string serverUrl; // Custom sync server URL (empty = default)
DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility
// Private constructor for singleton
KOReaderCredentialStore() = default;
// XOR obfuscation (symmetric - same for encode/decode)
void obfuscate(std::string& data) const;
public:
// Delete copy constructor and assignment
KOReaderCredentialStore(const KOReaderCredentialStore&) = delete;
KOReaderCredentialStore& operator=(const KOReaderCredentialStore&) = delete;
// Get singleton instance
static KOReaderCredentialStore& getInstance() { return instance; }
// Save/load from SD card
bool saveToFile() const;
bool loadFromFile();
// Credential management
void setCredentials(const std::string& user, const std::string& pass);
const std::string& getUsername() const { return username; }
const std::string& getPassword() const { return password; }
// Get MD5 hash of password for API authentication
std::string getMd5Password() const;
// Check if credentials are set
bool hasCredentials() const;
// Clear credentials
void clearCredentials();
// Server URL management
void setServerUrl(const std::string& url);
const std::string& getServerUrl() const { return serverUrl; }
// Get base URL for API calls (with http:// normalization if no protocol, falls back to default)
std::string getBaseUrl() const;
// Document matching method
void setMatchMethod(DocumentMatchMethod method);
DocumentMatchMethod getMatchMethod() const { return matchMethod; }
};
// Helper macro to access credential store
#define KOREADER_STORE KOReaderCredentialStore::getInstance()

View File

@@ -0,0 +1,96 @@
#include "KOReaderDocumentId.h"
#include <HardwareSerial.h>
#include <MD5Builder.h>
#include <SDCardManager.h>
namespace {
// Extract filename from path (everything after last '/')
std::string getFilename(const std::string& path) {
const size_t pos = path.rfind('/');
if (pos == std::string::npos) {
return path;
}
return path.substr(pos + 1);
}
} // namespace
std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) {
const std::string filename = getFilename(filePath);
if (filename.empty()) {
return "";
}
MD5Builder md5;
md5.begin();
md5.add(filename.c_str());
md5.calculate();
std::string result = md5.toString().c_str();
Serial.printf("[%lu] [KODoc] Filename hash: %s (from '%s')\n", millis(), result.c_str(), filename.c_str());
return result;
}
size_t KOReaderDocumentId::getOffset(int i) {
// Offset = 1024 << (2*i)
// For i = -1: 1024 >> 2 = 256
// For i >= 0: 1024 << (2*i)
if (i < 0) {
return CHUNK_SIZE >> (-2 * i);
}
return CHUNK_SIZE << (2 * i);
}
std::string KOReaderDocumentId::calculate(const std::string& filePath) {
FsFile file;
if (!SdMan.openFileForRead("KODoc", filePath, file)) {
Serial.printf("[%lu] [KODoc] Failed to open file: %s\n", millis(), filePath.c_str());
return "";
}
const size_t fileSize = file.fileSize();
Serial.printf("[%lu] [KODoc] Calculating hash for file: %s (size: %zu)\n", millis(), filePath.c_str(), fileSize);
// Initialize MD5 builder
MD5Builder md5;
md5.begin();
// Buffer for reading chunks
uint8_t buffer[CHUNK_SIZE];
size_t totalBytesRead = 0;
// Read from each offset (i = -1 to 10)
for (int i = -1; i < OFFSET_COUNT - 1; i++) {
const size_t offset = getOffset(i);
// Skip if offset is beyond file size
if (offset >= fileSize) {
continue;
}
// Seek to offset
if (!file.seekSet(offset)) {
Serial.printf("[%lu] [KODoc] Failed to seek to offset %zu\n", millis(), offset);
continue;
}
// Read up to CHUNK_SIZE bytes
const size_t bytesToRead = std::min(CHUNK_SIZE, fileSize - offset);
const size_t bytesRead = file.read(buffer, bytesToRead);
if (bytesRead > 0) {
md5.add(buffer, bytesRead);
totalBytesRead += bytesRead;
}
}
file.close();
// Calculate final hash
md5.calculate();
std::string result = md5.toString().c_str();
Serial.printf("[%lu] [KODoc] Hash calculated: %s (from %zu bytes)\n", millis(), result.c_str(), totalBytesRead);
return result;
}

View File

@@ -0,0 +1,45 @@
#pragma once
#include <string>
/**
* Calculate KOReader document ID (partial MD5 hash).
*
* KOReader identifies documents using a partial MD5 hash of the file content.
* The algorithm reads 1024 bytes at specific offsets and computes the MD5 hash
* of the concatenated data.
*
* Offsets are calculated as: 1024 << (2*i) for i = -1 to 10
* Producing: 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304,
* 16777216, 67108864, 268435456, 1073741824 bytes
*
* If an offset is beyond the file size, it is skipped.
*/
class KOReaderDocumentId {
public:
/**
* Calculate the KOReader document hash for a file (binary/content-based).
*
* @param filePath Path to the file (typically an EPUB)
* @return 32-character lowercase hex string, or empty string on failure
*/
static std::string calculate(const std::string& filePath);
/**
* Calculate document hash from filename only (filename-based sync mode).
* This is simpler and works when files have the same name across devices.
*
* @param filePath Path to the file (only the filename portion is used)
* @return 32-character lowercase hex MD5 of the filename
*/
static std::string calculateFromFilename(const std::string& filePath);
private:
// Size of each chunk to read at each offset
static constexpr size_t CHUNK_SIZE = 1024;
// Number of offsets to try (i = -1 to 10, so 12 offsets)
static constexpr int OFFSET_COUNT = 12;
// Calculate offset for index i: 1024 << (2*i)
static size_t getOffset(int i);
};

View File

@@ -0,0 +1,198 @@
#include "KOReaderSyncClient.h"
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <HardwareSerial.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <ctime>
#include "KOReaderCredentialStore.h"
namespace {
// Device identifier for CrossPoint reader
constexpr char DEVICE_NAME[] = "CrossPoint";
constexpr char DEVICE_ID[] = "crosspoint-reader";
void addAuthHeaders(HTTPClient& http) {
http.addHeader("Accept", "application/vnd.koreader.v1+json");
http.addHeader("x-auth-user", KOREADER_STORE.getUsername().c_str());
http.addHeader("x-auth-key", KOREADER_STORE.getMd5Password().c_str());
}
bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; }
} // namespace
KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
return NO_CREDENTIALS;
}
std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
Serial.printf("[%lu] [KOSync] Authenticating: %s\n", millis(), url.c_str());
HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient;
WiFiClient plainClient;
if (isHttpsUrl(url)) {
secureClient.reset(new WiFiClientSecure);
secureClient->setInsecure();
http.begin(*secureClient, url.c_str());
} else {
http.begin(plainClient, url.c_str());
}
addAuthHeaders(http);
const int httpCode = http.GET();
http.end();
Serial.printf("[%lu] [KOSync] Auth response: %d\n", millis(), httpCode);
if (httpCode == 200) {
return OK;
} else if (httpCode == 401) {
return AUTH_FAILED;
} else if (httpCode < 0) {
return NETWORK_ERROR;
}
return SERVER_ERROR;
}
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
KOReaderProgress& outProgress) {
if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
return NO_CREDENTIALS;
}
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
Serial.printf("[%lu] [KOSync] Getting progress: %s\n", millis(), url.c_str());
HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient;
WiFiClient plainClient;
if (isHttpsUrl(url)) {
secureClient.reset(new WiFiClientSecure);
secureClient->setInsecure();
http.begin(*secureClient, url.c_str());
} else {
http.begin(plainClient, url.c_str());
}
addAuthHeaders(http);
const int httpCode = http.GET();
if (httpCode == 200) {
// Parse JSON response from response string
String responseBody = http.getString();
http.end();
JsonDocument doc;
const DeserializationError error = deserializeJson(doc, responseBody);
if (error) {
Serial.printf("[%lu] [KOSync] JSON parse failed: %s\n", millis(), error.c_str());
return JSON_ERROR;
}
outProgress.document = documentHash;
outProgress.progress = doc["progress"].as<std::string>();
outProgress.percentage = doc["percentage"].as<float>();
outProgress.device = doc["device"].as<std::string>();
outProgress.deviceId = doc["device_id"].as<std::string>();
outProgress.timestamp = doc["timestamp"].as<int64_t>();
Serial.printf("[%lu] [KOSync] Got progress: %.2f%% at %s\n", millis(), outProgress.percentage * 100,
outProgress.progress.c_str());
return OK;
}
http.end();
Serial.printf("[%lu] [KOSync] Get progress response: %d\n", millis(), httpCode);
if (httpCode == 401) {
return AUTH_FAILED;
} else if (httpCode == 404) {
return NOT_FOUND;
} else if (httpCode < 0) {
return NETWORK_ERROR;
}
return SERVER_ERROR;
}
KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) {
if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
return NO_CREDENTIALS;
}
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
Serial.printf("[%lu] [KOSync] Updating progress: %s\n", millis(), url.c_str());
HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient;
WiFiClient plainClient;
if (isHttpsUrl(url)) {
secureClient.reset(new WiFiClientSecure);
secureClient->setInsecure();
http.begin(*secureClient, url.c_str());
} else {
http.begin(plainClient, url.c_str());
}
addAuthHeaders(http);
http.addHeader("Content-Type", "application/json");
// Build JSON body (timestamp not required per API spec)
JsonDocument doc;
doc["document"] = progress.document;
doc["progress"] = progress.progress;
doc["percentage"] = progress.percentage;
doc["device"] = DEVICE_NAME;
doc["device_id"] = DEVICE_ID;
std::string body;
serializeJson(doc, body);
Serial.printf("[%lu] [KOSync] Request body: %s\n", millis(), body.c_str());
const int httpCode = http.PUT(body.c_str());
http.end();
Serial.printf("[%lu] [KOSync] Update progress response: %d\n", millis(), httpCode);
if (httpCode == 200 || httpCode == 202) {
return OK;
} else if (httpCode == 401) {
return AUTH_FAILED;
} else if (httpCode < 0) {
return NETWORK_ERROR;
}
return SERVER_ERROR;
}
const char* KOReaderSyncClient::errorString(Error error) {
switch (error) {
case OK:
return "Success";
case NO_CREDENTIALS:
return "No credentials configured";
case NETWORK_ERROR:
return "Network error";
case AUTH_FAILED:
return "Authentication failed";
case SERVER_ERROR:
return "Server error (try again later)";
case JSON_ERROR:
return "JSON parse error";
case NOT_FOUND:
return "No progress found";
default:
return "Unknown error";
}
}

View File

@@ -0,0 +1,59 @@
#pragma once
#include <string>
/**
* Progress data from KOReader sync server.
*/
struct KOReaderProgress {
std::string document; // Document hash
std::string progress; // XPath-like progress string
float percentage; // Progress percentage (0.0 to 1.0)
std::string device; // Device name
std::string deviceId; // Device ID
int64_t timestamp; // Unix timestamp of last update
};
/**
* HTTP client for KOReader sync API.
*
* Base URL: https://sync.koreader.rocks:443/
*
* API Endpoints:
* GET /users/auth - Authenticate (validate credentials)
* GET /syncs/progress/:document - Get progress for a document
* PUT /syncs/progress - Update progress for a document
*
* Authentication:
* x-auth-user: username
* x-auth-key: MD5 hash of password
*/
class KOReaderSyncClient {
public:
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND };
/**
* Authenticate with the sync server (validate credentials).
* @return OK on success, error code on failure
*/
static Error authenticate();
/**
* Get reading progress for a document.
* @param documentHash The document hash (from KOReaderDocumentId)
* @param outProgress Output: the progress data
* @return OK on success, NOT_FOUND if no progress exists, error code on failure
*/
static Error getProgress(const std::string& documentHash, KOReaderProgress& outProgress);
/**
* Update reading progress for a document.
* @param progress The progress data to upload
* @return OK on success, error code on failure
*/
static Error updateProgress(const KOReaderProgress& progress);
/**
* Get human-readable error message.
*/
static const char* errorString(Error error);
};

View File

@@ -0,0 +1,112 @@
#include "ProgressMapper.h"
#include <HardwareSerial.h>
#include <cmath>
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
KOReaderPosition result;
// Calculate page progress within current spine item
float intraSpineProgress = 0.0f;
if (pos.totalPages > 0) {
intraSpineProgress = static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages);
}
// Calculate overall book progress (0.0-1.0)
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
// Generate XPath with estimated paragraph position based on page
result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages);
// Get chapter info for logging
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown";
Serial.printf("[%lu] [ProgressMapper] CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s\n", millis(),
chapterName.c_str(), pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str());
return result;
}
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos,
int totalPagesInSpine) {
CrossPointPosition result;
result.spineIndex = 0;
result.pageNumber = 0;
result.totalPages = totalPagesInSpine;
const size_t bookSize = epub->getBookSize();
if (bookSize == 0) {
return result;
}
// First, try to get spine index from XPath (DocFragment)
int xpathSpineIndex = parseDocFragmentIndex(koPos.xpath);
if (xpathSpineIndex >= 0 && xpathSpineIndex < epub->getSpineItemsCount()) {
result.spineIndex = xpathSpineIndex;
// When we have XPath, go to page 0 of the spine - byte-based page calculation is unreliable
result.pageNumber = 0;
} else {
// Fall back to percentage-based lookup for both spine and page
const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage);
// Find the spine item that contains this byte position
for (int i = 0; i < epub->getSpineItemsCount(); i++) {
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
if (cumulativeSize >= targetBytes) {
result.spineIndex = i;
break;
}
}
// Estimate page number within the spine item using percentage (only when no XPath)
if (totalPagesInSpine > 0 && result.spineIndex < epub->getSpineItemsCount()) {
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
const size_t spineSize = currentCumSize - prevCumSize;
if (spineSize > 0) {
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
result.pageNumber = static_cast<int>(clampedProgress * totalPagesInSpine);
result.pageNumber = std::max(0, std::min(result.pageNumber, totalPagesInSpine - 1));
}
}
}
Serial.printf("[%lu] [ProgressMapper] KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d\n", millis(),
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
return result;
}
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
// KOReader uses 1-based DocFragment indices
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning
// Avoid specifying paragraph numbers as they may not exist in the target document
return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
}
int ProgressMapper::parseDocFragmentIndex(const std::string& xpath) {
// Look for DocFragment[N] pattern
const size_t start = xpath.find("DocFragment[");
if (start == std::string::npos) {
return -1;
}
const size_t numStart = start + 12; // Length of "DocFragment["
const size_t numEnd = xpath.find(']', numStart);
if (numEnd == std::string::npos) {
return -1;
}
try {
const int docFragmentIndex = std::stoi(xpath.substr(numStart, numEnd - numStart));
// KOReader uses 1-based indices, we use 0-based
return docFragmentIndex - 1;
} catch (...) {
return -1;
}
}

View File

@@ -0,0 +1,72 @@
#pragma once
#include <Epub.h>
#include <memory>
#include <string>
/**
* CrossPoint position representation.
*/
struct CrossPointPosition {
int spineIndex; // Current spine item (chapter) index
int pageNumber; // Current page within the spine item
int totalPages; // Total pages in the current spine item
};
/**
* KOReader position representation.
*/
struct KOReaderPosition {
std::string xpath; // XPath-like progress string
float percentage; // Progress percentage (0.0 to 1.0)
};
/**
* Maps between CrossPoint and KOReader position formats.
*
* CrossPoint tracks position as (spineIndex, pageNumber).
* KOReader uses XPath-like strings + percentage.
*
* Since CrossPoint discards HTML structure during parsing, we generate
* synthetic XPath strings based on spine index, using percentage as the
* primary sync mechanism.
*/
class ProgressMapper {
public:
/**
* Convert CrossPoint position to KOReader format.
*
* @param epub The EPUB book
* @param pos CrossPoint position
* @return KOReader position
*/
static KOReaderPosition toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos);
/**
* Convert KOReader position to CrossPoint format.
*
* Note: The returned pageNumber may be approximate since different
* rendering settings produce different page counts.
*
* @param epub The EPUB book
* @param koPos KOReader position
* @param totalPagesInSpine Total pages in the target spine item (for page estimation)
* @return CrossPoint position
*/
static CrossPointPosition toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos,
int totalPagesInSpine = 0);
private:
/**
* Generate XPath for KOReader compatibility.
* Format: /body/DocFragment[spineIndex+1]/body/p[estimatedParagraph]
* Paragraph is estimated based on page position within the chapter.
*/
static std::string generateXPath(int spineIndex, int pageNumber, int totalPages);
/**
* Parse DocFragment index from XPath string.
* Returns -1 if not found.
*/
static int parseDocFragmentIndex(const std::string& xpath);
};