From bfbae77acba996c1e86ec94fb282927a8136abf5 Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Wed, 8 Apr 2026 19:46:23 -0700 Subject: [PATCH] Hardcover Progress Plugin --- plugins/hardcover/HardcoverPlugin.cpp | 426 ++++++++++++++++++ plugins/hardcover/HardcoverPlugin.h | 16 + plugins/hardcover/HardcoverSyncActivity.cpp | 94 ++++ plugins/hardcover/HardcoverSyncActivity.h | 22 + plugins/hardcover/patch.py | 463 ++++++++++++++++++++ 5 files changed, 1021 insertions(+) create mode 100644 plugins/hardcover/HardcoverPlugin.cpp create mode 100644 plugins/hardcover/HardcoverPlugin.h create mode 100644 plugins/hardcover/HardcoverSyncActivity.cpp create mode 100644 plugins/hardcover/HardcoverSyncActivity.h create mode 100644 plugins/hardcover/patch.py diff --git a/plugins/hardcover/HardcoverPlugin.cpp b/plugins/hardcover/HardcoverPlugin.cpp new file mode 100644 index 0000000..8f480ad --- /dev/null +++ b/plugins/hardcover/HardcoverPlugin.cpp @@ -0,0 +1,426 @@ +#include "HardcoverPlugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "CrossPointSettings.h" + +// --------------------------------------------------------------------------- +// Internal types & helpers +// --------------------------------------------------------------------------- + +namespace { + +constexpr char HARDCOVER_API[] = "https://api.hardcover.app/v1/graphql"; +constexpr char CACHE_DIR[] = "/.crosspoint"; + +struct InProgressBook { + std::string isbn; + int page; +}; + +// ---- ISBN extraction ------------------------------------------------------- + +std::string extractIsbnFromOpf(const uint8_t* data, size_t len) { + const char* p = reinterpret_cast(data); + const char* end = p + len; + const char* pos = p; + + while (pos < end) { + const char* tagStart = static_cast( + memmem(pos, end - pos, "( + memchr(tagStart, '>', end - tagStart)); + if (!tagEnd) break; + + if (*(tagEnd - 1) == '/') { pos = tagEnd + 1; continue; } + + const char* valueStart = tagEnd + 1; + const char* closeTag = static_cast( + memmem(valueStart, end - valueStart, "", 16)); + if (!closeTag) break; + + std::string value(valueStart, closeTag - valueStart); + pos = closeTag + 16; + + for (const char* prefix : {"urn:isbn:", "isbn:", "ISBN:", "URN:ISBN:"}) { + size_t plen = strlen(prefix); + if (value.size() >= plen && value.compare(0, plen, prefix) == 0) { + value = value.substr(plen); + break; + } + } + + std::string digits; + for (char c : value) { + if (isdigit(static_cast(c)) || c == 'X' || c == 'x') + digits += c; + } + + if (digits.size() == 13 || digits.size() == 10) + return digits; + } + return {}; +} + +std::string getIsbnFromEpub(const std::string& path) { + Epub epub(path, CACHE_DIR); + if (!epub.load(false, true) && !epub.load(true, true)) { + LOG_DBG("HCV", "Could not load epub: %s", path.c_str()); + return {}; + } + + const char* opfCandidates[] = { + "OEBPS/content.opf", "OPS/content.opf", "content.opf", + "EPUB/content.opf", nullptr + }; + for (int i = 0; opfCandidates[i]; i++) { + size_t size = 0; + uint8_t* data = epub.readItemContentsToBytes(opfCandidates[i], &size, false); + if (!data || size == 0) { if (data) free(data); continue; } + std::string isbn = extractIsbnFromOpf(data, size); + free(data); + if (!isbn.empty()) return isbn; + } + + size_t cSize = 0; + uint8_t* cData = epub.readItemContentsToBytes("META-INF/container.xml", &cSize, true); + if (cData && cSize > 0) { + std::string container(reinterpret_cast(cData), cSize); + free(cData); + const char* marker = "full-path=\""; + auto mpos = container.find(marker); + if (mpos != std::string::npos) { + mpos += strlen(marker); + auto mend = container.find('"', mpos); + if (mend != std::string::npos) { + std::string opfPath = container.substr(mpos, mend - mpos); + size_t oSize = 0; + uint8_t* oData = epub.readItemContentsToBytes(opfPath.c_str(), &oSize, false); + if (oData && oSize > 0) { + std::string isbn = extractIsbnFromOpf(oData, oSize); + free(oData); + if (!isbn.empty()) return isbn; + } else { if (oData) free(oData); } + } + } + } else { if (cData) free(cData); } + + LOG_DBG("HCV", "No ISBN found in %s", path.c_str()); + return {}; +} + +// ---- Progress reading ------------------------------------------------------- + +int epubProgressPercent(const std::string& path, const std::string& cachePath, int& outPage) { + FsFile f; + if (!Storage.openFileForRead("HCV", (cachePath + "/progress.bin").c_str(), f)) + return -1; + uint8_t data[6] = {}; + bool ok = (f.read(data, 6) == 6); + f.close(); + if (!ok) return -1; + + int spineIndex = data[0] | (data[1] << 8); + int currentPage = data[2] | (data[3] << 8); + int pageCount = data[4] | (data[5] << 8); + if (pageCount <= 0) return -1; + + outPage = currentPage; + + float spineProgress = static_cast(currentPage) / static_cast(pageCount); + + Epub epub(path, CACHE_DIR); + if (!epub.load(false, true) && !epub.load(true, true)) return -1; + + float pct = epub.calculateProgress(spineIndex, spineProgress) * 100.0f; + return static_cast(pct + 0.5f); +} + +// ---- Filesystem scan ------------------------------------------------------- + +void scanBooks(const std::string& rootPath, std::vector& out) { + auto dir = Storage.open(rootPath.c_str()); + if (!dir || !dir.isDirectory()) { if (dir) dir.close(); return; } + dir.rewindDirectory(); + char name[500]; + for (auto entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { + entry.getName(name, sizeof(name)); + std::string_view sv{name}; + if (sv.empty() || sv[0] == '.') { entry.close(); continue; } + std::string fullPath = rootPath; + if (fullPath.back() != '/') fullPath += '/'; + fullPath += name; + if (entry.isDirectory()) { + entry.close(); + scanBooks(fullPath, out); + } else if (FsHelpers::hasEpubExtension(sv)) { + out.push_back(fullPath); + } + entry.close(); + } + dir.close(); +} + +std::string buildCachePath(const std::string& filePath) { + return std::string(CACHE_DIR) + "/epub_" + + std::to_string(std::hash{}(filePath)); +} + +std::vector collectInProgressBooks() { + std::vector allBooks; + scanBooks("/", allBooks); + + std::vector result; + for (const auto& path : allBooks) { + std::string cachePath = buildCachePath(path); + int page = 0; + int pct = epubProgressPercent(path, cachePath, page); + if (pct < 1) continue; // Skip 0% books, include 1-100%+ + + std::string isbn = getIsbnFromEpub(path); + if (isbn.empty()) continue; + + result.push_back({isbn, page}); + } + return result; +} + +// New function to collect completed books (100%+) +std::vector collectCompletedBooks() { + std::vector allBooks; + scanBooks("/", allBooks); + + std::vector result; + for (const auto& path : allBooks) { + std::string cachePath = buildCachePath(path); + int page = 0; + int pct = epubProgressPercent(path, cachePath, page); + if (pct < 100) continue; // Only include 100%+ books + + std::string isbn = getIsbnFromEpub(path); + if (isbn.empty()) continue; + + result.push_back({isbn, page}); + } + return result; +} + +// ---- HTTP helper ----------------------------------------------------------- + +String graphqlPost(const char* body, const std::string& token) { + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + http.begin(client, HARDCOVER_API); + http.addHeader("Content-Type", "application/json"); + + char authHeader[648]; + snprintf(authHeader, sizeof(authHeader), "Bearer %s", token.c_str()); + http.addHeader("Authorization", authHeader); + + int code = http.POST(body); + if (code != 200) { + LOG_ERR("HCV", "HTTP %d", code); + http.end(); + return ""; + } + String resp = http.getString(); + http.end(); + return resp; +} + +// ---- Hardcover API --------------------------------------------------------- + +// Get the authenticated user's ID. +int getUserId(const std::string& token) { + const char* body = "{\"query\":\"{ me { id } }\"}"; + String resp = graphqlPost(body, token); + if (resp.isEmpty() || resp.indexOf("\"errors\"") != -1) { + LOG_ERR("HCV", "getUserId failed: %s", resp.c_str()); + return 0; + } + JsonDocument doc; + if (deserializeJson(doc, resp) != DeserializationError::Ok) return 0; + + // me returns an array, so access the first element + JsonArray meArray = doc["data"]["me"]; + if (meArray.size() == 0) return 0; + + int id = meArray[0]["id"] | 0; + LOG_DBG("HCV", "user_id=%d", id); + return id; +} + +// Step 1: Look up book_id from ISBN-13 via editions table. +// Also returns existing user_book id (0 if none). +bool lookupIds(const std::string& isbn, const std::string& token, + int& outBookId, int& outUserBookId) { + char body[400]; + snprintf(body, sizeof(body), + "{\"query\":\"{" + "editions(where:{isbn_13:{_eq:\\\"%s\\\"}},limit:1){" + "book_id " + "book{id user_books(limit:1){id}}" + "}}\"}", + isbn.c_str()); + + String resp = graphqlPost(body, token); + if (resp.isEmpty()) return false; + if (resp.indexOf("\"errors\"") != -1) { + LOG_ERR("HCV", "Lookup error ISBN %s: %s", isbn.c_str(), resp.c_str()); + return false; + } + + JsonDocument doc; + if (deserializeJson(doc, resp) != DeserializationError::Ok) return false; + + JsonArray editions = doc["data"]["editions"].as(); + if (editions.size() == 0) { + LOG_DBG("HCV", "No edition for ISBN %s", isbn.c_str()); + return false; + } + + outBookId = editions[0]["book"]["id"] | 0; + if (outBookId == 0) return false; + + JsonArray ubs = editions[0]["book"]["user_books"].as(); + outUserBookId = (ubs.size() > 0) ? (ubs[0]["id"] | 0) : 0; + + LOG_DBG("HCV", "ISBN %s -> book_id=%d", isbn.c_str(), outBookId); + return true; +} + +// Step 2: Upsert user_book status to Currently Reading (status_id=2). +// Returns the user_book id (new or existing). +int upsertUserBook(int bookId, int userBookId, const std::string& token) { + char body[300]; + // insert_user_book acts as upsert via its error field + snprintf(body, sizeof(body), + "{\"query\":\"mutation{" + "insert_user_book(object:{book_id:%d,status_id:2}){" + "user_book{id} error" + "}}\"}", + bookId); + + String resp = graphqlPost(body, token); + if (resp.isEmpty()) return 0; + if (resp.indexOf("\"errors\"") != -1) { + LOG_ERR("HCV", "insert_user_book error book_id=%d: %s", bookId, resp.c_str()); + return 0; + } + + JsonDocument doc; + if (deserializeJson(doc, resp) != DeserializationError::Ok) return 0; + + // If there was a conflict (book already in library), returns existing id + int id = doc["data"]["insert_user_book"]["user_book"]["id"] | 0; + if (id == 0 && userBookId != 0) id = userBookId; // fallback to known id + + return id; +} + +bool updateProgress(int userId, int bookId, int page, const std::string& token) { + char body[512]; + snprintf(body, sizeof(body), + "{\"query\":\"mutation{" + "insert_user_book_read(user_book_id:%d,user_book_read:{progress_pages:%d}){" + "id}}\"}", + bookId, page); + String resp = graphqlPost(body, token); + LOG_DBG("HCV", "insert_user_book_read: %s", resp.c_str()); + if (!resp.isEmpty() && resp.indexOf("\"errors\"") == -1) return true; + LOG_ERR("HCV", "updateProgress failed: %s", resp.c_str()); + return false; +} + +// New function to mark books as Read (status_id=3) +bool markAsRead(const std::string& isbn, const std::string& token) { + int bookId = 0, userBookId = 0; + if (!lookupIds(isbn, token, bookId, userBookId)) return false; + if (userBookId == 0) return false; + + char body[300]; + snprintf(body, sizeof(body), + "{\"query\":\"mutation{" + "update_user_book(id:%d,object:{status_id:3}){" + "id}}\"}", + userBookId); + String resp = graphqlPost(body, token); + LOG_DBG("HCV", "markAsRead response: %s", resp.c_str()); + return (!resp.isEmpty() && resp.indexOf("\"errors\"") == -1); +} + +bool syncBook(const InProgressBook& book, int userId, const std::string& token) { + int bookId = 0, userBookId = 0; + if (!lookupIds(book.isbn, token, bookId, userBookId)) return false; + + // Ensure book is in library as Currently Reading + int resolvedUserBookId = upsertUserBook(bookId, userBookId, token); + if (resolvedUserBookId == 0) return false; + + return updateProgress(userId, resolvedUserBookId, book.page, token); +} + +} // anonymous namespace + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +namespace HardcoverPlugin { + +SyncResult syncProgress() { + if (WiFi.status() != WL_CONNECTED) { + LOG_ERR("HCV", "Not connected to WiFi"); + return SyncResult::NO_WIFI; + } + + if (SETTINGS.hardcoverApiToken[0] == '\0') { + LOG_ERR("HCV", "No Hardcover API token configured"); + return SyncResult::NO_TOKEN; + } + const std::string token(SETTINGS.hardcoverApiToken); + + auto books = collectInProgressBooks(); + + if (books.empty()) { + LOG_DBG("HCV", "No in-progress epub books with ISBN found."); + return SyncResult::NO_BOOKS; + } + + int userId = getUserId(token); + if (userId == 0) return SyncResult::API_ERROR; + + bool allOk = true; + + // Sync progress for all books with > 0% completion + for (const auto& b : books) { + if (!syncBook(b, userId, token)) + allOk = false; + } + + // Mark 100%+ books as Read + auto completedBooks = collectCompletedBooks(); + for (const auto& b : completedBooks) { + if (!markAsRead(b.isbn, token)) + allOk = false; + } + + return allOk ? SyncResult::OK : SyncResult::API_ERROR; +} + +} // namespace HardcoverPlugin + diff --git a/plugins/hardcover/HardcoverPlugin.h b/plugins/hardcover/HardcoverPlugin.h new file mode 100644 index 0000000..3083afa --- /dev/null +++ b/plugins/hardcover/HardcoverPlugin.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace HardcoverPlugin { + + enum class SyncResult { + OK, // All books synced successfully + NO_BOOKS, // No in-progress epub books with ISBN found + NO_TOKEN, // API token not configured + NO_WIFI, // Not connected to WiFi + API_ERROR, // HTTP or GraphQL error from Hardcover + }; + + SyncResult syncProgress(); +} diff --git a/plugins/hardcover/HardcoverSyncActivity.cpp b/plugins/hardcover/HardcoverSyncActivity.cpp new file mode 100644 index 0000000..b28859a --- /dev/null +++ b/plugins/hardcover/HardcoverSyncActivity.cpp @@ -0,0 +1,94 @@ +#include "HardcoverSyncActivity.h" + +#include +#include + +#include "HardcoverPlugin.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +void HardcoverSyncActivity::onEnter() { + Activity::onEnter(); + state = SYNCING; + result = HardcoverPlugin::SyncResult::OK; + requestUpdate(); +} + +void HardcoverSyncActivity::onExit() { Activity::onExit(); } + +void HardcoverSyncActivity::render(RenderLock&&) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Hardcover"); + + if (state == SYNCING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Syncing..."); + renderer.displayBuffer(); + return; + } + + // DONE state - show result + const char* line1 = nullptr; + const char* line2 = nullptr; + + switch (result) { + case HardcoverPlugin::SyncResult::OK: + line1 = "Sync complete."; + break; + case HardcoverPlugin::SyncResult::NO_BOOKS: + line1 = "Nothing to sync."; + line2 = "No in-progress epubs with ISBN found."; + break; + case HardcoverPlugin::SyncResult::NO_WIFI: + line1 = "Not connected to WiFi."; + line2 = "Connect to WiFi and try again."; + break; + case HardcoverPlugin::SyncResult::NO_TOKEN: + line1 = "API token not set."; + line2 = "Enter token in Settings > Plugins > Hardcover."; + break; + case HardcoverPlugin::SyncResult::API_ERROR: + line1 = "Sync failed."; + line2 = "Check serial output for details."; + break; + } + + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - (line2 ? 15 : 0), line1, true, + result == HardcoverPlugin::SyncResult::OK ? EpdFontFamily::BOLD + : EpdFontFamily::REGULAR); + if (line2) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 15, line2); + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); +} + +void HardcoverSyncActivity::doSync() { + result = HardcoverPlugin::syncProgress(); + state = DONE; + requestUpdate(); +} + +void HardcoverSyncActivity::loop() { + if (state == SYNCING) { + { + RenderLock lock(*this); + } + requestUpdateAndWait(); + doSync(); + return; + } + + if (state == DONE) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + } + return; + } +} diff --git a/plugins/hardcover/HardcoverSyncActivity.h b/plugins/hardcover/HardcoverSyncActivity.h new file mode 100644 index 0000000..3766cd2 --- /dev/null +++ b/plugins/hardcover/HardcoverSyncActivity.h @@ -0,0 +1,22 @@ +#pragma once + +#include "HardcoverPlugin.h" +#include "activities/Activity.h" + +class HardcoverSyncActivity final : public Activity { + public: + explicit HardcoverSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("HardcoverSync", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + bool skipLoopDelay() override { return true; } + void render(RenderLock&&) override; + + private: + enum State { SYNCING, DONE }; + State state = SYNCING; + HardcoverPlugin::SyncResult result = HardcoverPlugin::SyncResult::OK; + void doSync(); +}; diff --git a/plugins/hardcover/patch.py b/plugins/hardcover/patch.py new file mode 100644 index 0000000..ab43311 --- /dev/null +++ b/plugins/hardcover/patch.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +""" +Hardcover Progress plugin patch. + +Adds a "Hardcover Progress" entry to the Plugins settings tab. +When the user presses Sync, the device: + + 1. Recursively scans the SD card for .epub files. + 2. For each file whose progress.bin shows 1-99% read, parses the + epub OPF metadata to extract the ISBN-13. + 3. POSTs a GraphQL mutation to https://api.hardcover.app/v1/graphql, + setting that book's status to "Currently Reading" with the current + page number. + +The Hardcover API bearer token is stored in CrossPointSettings as a +char[256] field (hardcoverApiToken) and exposed in the web UI. +""" + +import os +import sys +import glob +import shutil + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def read_file(path): + with open(path, "r", encoding="utf-8") as f: + return f.read() + +def write_file(path, content): + with open(path, "w", encoding="utf-8") as f: + f.write(content) + +def find_first(filename, repo_dir): + results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True) + if not results: + sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}") + return results[0] + + +# --------------------------------------------------------------------------- +# Copy plugin sources into the repo +# --------------------------------------------------------------------------- + +def copy_plugin_sources(plugin_dir, repo_dir): + dest = os.path.join(repo_dir, "src", "activities", "settings") + os.makedirs(dest, exist_ok=True) + for fname in os.listdir(plugin_dir): + if fname.endswith((".h", ".cpp", ".hpp")): + src = os.path.join(plugin_dir, fname) + dst = os.path.join(dest, fname) + shutil.copy2(src, dst) + print(f" \u2713 {fname}") + + +# --------------------------------------------------------------------------- +# CrossPointSettings.h - add hardcoverApiToken char array with default token +# --------------------------------------------------------------------------- + +def patch_cross_point_settings_h(repo_dir, token=""): + path = find_first("CrossPointSettings.h", repo_dir) + content = read_file(path) + + if "hardcoverApiToken" in content: + print(" CrossPointSettings.h already patched, skipping.") + return + + # Escape token for use in a C string literal (tokens are JWTs, no special chars needed + # beyond basic safety) + safe_token = token.replace('\\', '\\\\').replace('"', '\\"') + + content = content.replace( + ' uint8_t imageRendering = IMAGES_DISPLAY;', + f' uint8_t imageRendering = IMAGES_DISPLAY;\n char hardcoverApiToken[640] = "{safe_token}";' + ) + + write_file(path, content) + print(f" CrossPointSettings.h patched{' (token embedded)' if token else ' (no token)'}.") + + +# --------------------------------------------------------------------------- +# SettingsList.h - add hardcoverApiToken String for web UI exposure +# --------------------------------------------------------------------------- + +def patch_settings_list_h(repo_dir): + path = find_first("SettingsList.h", repo_dir) + content = read_file(path) + + if "hardcoverApiToken" in content: + print(" SettingsList.h already patched, skipping.") + return + + content = content.replace( + ' };\n return list;\n}\n', + ' SettingInfo::String(StrId::STR_NONE_OPT, SETTINGS.hardcoverApiToken,\n' + ' sizeof(SETTINGS.hardcoverApiToken), "hardcoverApiToken")\n' + ' .withObfuscated(),\n' + ' };\n return list;\n}\n' + ) + + write_file(path, content) + print(" SettingsList.h patched.") + + +# --------------------------------------------------------------------------- +# SettingAction enum - add HardcoverSync value +# The enum lives in SettingsActivity.h. +# --------------------------------------------------------------------------- + +def patch_setting_action_enum(repo_dir): + path = find_first("SettingsActivity.h", repo_dir) + content = read_file(path) + + if "HardcoverSync" in content: + print(" SettingAction enum already patched, skipping.") + return + + content = content.replace( + ' Language,\n};', + ' Language,\n HardcoverSync,\n};' + ) + + write_file(path, content) + print(" SettingAction::HardcoverSync added.") + + +# --------------------------------------------------------------------------- +# SettingsActivity.h - add pluginsSettings vector (if not already done by +# darkmode/smallerfonts) and categoryCount = 5 +# --------------------------------------------------------------------------- + +def patch_settings_h(repo_dir): + path = find_first("SettingsActivity.h", repo_dir) + content = read_file(path) + + already_has_tab = "categoryCount = 5" in content + already_has_vec = "pluginsSettings" in content + + if already_has_tab and already_has_vec: + print(" SettingsActivity.h already patched, skipping.") + return + + if not already_has_tab: + content = content.replace( + 'static constexpr int categoryCount = 4;', + 'static constexpr int categoryCount = 5;' + ) + + if not already_has_vec: + content = content.replace( + ' std::vector systemSettings;', + ' std::vector systemSettings;\n std::vector pluginsSettings;' + ) + + write_file(path, content) + print(" SettingsActivity.h patched.") + + +# --------------------------------------------------------------------------- +# SettingsActivity.cpp - wire up Plugins tab + Hardcover action +# --------------------------------------------------------------------------- + +def patch_settings_cpp(repo_dir): + path = find_first("SettingsActivity.cpp", repo_dir) + content = read_file(path) + + if "HardcoverPlugin::syncProgress" in content: + print(" SettingsActivity.cpp already patched, skipping.") + return + + # ---- include HardcoverPlugin.h and HardcoverSyncActivity.h ------------ + content = content.replace( + '#include "SettingsList.h"', + '#include "SettingsList.h"\n#include "HardcoverPlugin.h"\n#include "HardcoverSyncActivity.h"' + ) + + # ---- categoryNames: add STR_NONE_OPT for Plugins tab (if not done yet) - + if 'StrId::STR_NONE_OPT};' not in content and 'STR_NONE_OPT' not in content.split('categoryNames')[1].split(';')[0]: + content = content.replace( + 'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n' + ' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};', + 'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n' + ' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n' + ' StrId::STR_NONE_OPT};' + ) + + # ---- pluginsSettings.clear() ------------------------------------------- + if "pluginsSettings.clear();" not in content: + content = content.replace( + ' systemSettings.clear();', + ' systemSettings.clear();\n pluginsSettings.clear();' + ) + + # ---- push Hardcover action into pluginsSettings ------------------------- + # Darkmode pushes darkModeState and smallerFontsMode here. We append after. + # Handle three cases: both other plugins present, only darkmode, or neither. + hardcover_push = ( + ' pluginsSettings.push_back(SettingInfo::Action(\n' + ' StrId::STR_NONE_OPT, SettingAction::HardcoverSync\n' + ' ));\n' + ) + + if 'SettingAction::HardcoverSync' not in content: + anchors = [ + # After smallerFontsMode push (both other plugins installed) + ' pluginsSettings.push_back(SettingInfo::Enum(\n' + ' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n' + ' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n' + ' ));\n', + # After darkModeState push (only darkmode installed) + ' pluginsSettings.push_back(SettingInfo::Enum(\n' + ' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n' + ' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n' + ' ));\n', + # No other plugins: append after CustomiseStatusBar push + ' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n', + ] + for anchor in anchors: + if anchor in content: + content = content.replace(anchor, anchor + hardcover_push) + break + + # ---- case 4 -> pluginsSettings (if not done yet) ----------------------- + if 'case 4:\n currentSettings = &pluginsSettings;' not in content: + content = content.replace( + ' case 3:\n currentSettings = &systemSettings;\n break;', + ' case 3:\n currentSettings = &systemSettings;\n break;\n' + ' case 4:\n currentSettings = &pluginsSettings;\n break;' + ) + + # ---- "Plugins" tab label (if not done yet) ----------------------------- + if '"Plugins"' not in content: + content = content.replace( + ' std::vector tabs;\n' + ' tabs.reserve(categoryCount);\n' + ' for (int i = 0; i < categoryCount; i++) {\n' + ' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n' + ' }', + ' std::vector tabs;\n' + ' tabs.reserve(categoryCount);\n' + ' for (int i = 0; i < categoryCount; i++) {\n' + ' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n' + ' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n' + ' }' + ) + + # ---- nextCatLabel fix (if not done yet) -------------------------------- + if 'nextCatLabel' not in content: + content = content.replace( + ' const auto confirmLabel = (selectedSettingIndex == 0)\n' + ' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n' + ' : tr(STR_TOGGLE);', + ' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n' + ' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n' + ' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);' + ) + + # ---- Row label lambda -------------------------------------------------- + # ACTION rows never have a key, so we must match on s.action, not s.key. + # The darkmode patch already converted the lambda to the multi-line form + # and added key-based checks. We extend it with our action-based check. + hardcover_label_line = ( + ' if (s.type == SettingType::ACTION &&\n' + ' s.action == SettingAction::HardcoverSync) return "Hardcover Progress";\n' + ) + + if 'Hardcover Progress' not in content: + if 'selectedCategoryIndex == 4' in content: + # Lambda already patched by darkmode/smallerfonts - insert after last key check. + # Exact closing from real patched file: + closing = ( + ' }\n' + ' return std::string(I18N.get(settings[index].nameId));\n' + ' }, nullptr, nullptr,' + ) + for last_key_line in [ + ' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n', + ' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n', + ]: + anchor = last_key_line + closing + if anchor in content: + content = content.replace(anchor, last_key_line + hardcover_label_line + closing) + break + else: + # No other plugin has patched the lambda yet - create it from scratch. + # Exact line from real unpatched file (6 leading spaces): + old_lambda = ( + ' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,' + ) + new_lambda = ( + ' [&settings, this](int index) -> std::string {\n' + ' if (selectedCategoryIndex == 4) {\n' + ' const auto& s = settings[index];\n' + + hardcover_label_line + + ' }\n' + ' return std::string(I18N.get(settings[index].nameId));\n' + ' }, nullptr, nullptr,' + ) + content = content.replace(old_lambda, new_lambda) + + # ---- Value text: "Sync" for the HardcoverSync action row --------------- + if '"Sync"' not in content: + content = content.replace( + ' if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {', + ' if (setting.type == SettingType::ACTION &&\n' + ' setting.action == SettingAction::HardcoverSync) {\n' + ' valueText = "Sync";\n' + ' } else if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {' + ) + + # ---- HardcoverSync case in toggleCurrentSetting switch ----------------- + content = content.replace( + ' case SettingAction::Language:\n' + ' startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler);\n' + ' break;\n' + ' case SettingAction::None:', + ' case SettingAction::Language:\n' + ' startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler);\n' + ' break;\n' + ' case SettingAction::HardcoverSync:\n' + ' startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler);\n' + ' break;\n' + ' case SettingAction::None:' + ) + + write_file(path, content) + print(" SettingsActivity.cpp patched.") + + +# --------------------------------------------------------------------------- +# CrossPointWebServer.cpp - expose hardcoverApiToken + Hardcover label +# --------------------------------------------------------------------------- + +def patch_web_server(repo_dir): + path = find_first("CrossPointWebServer.cpp", repo_dir) + content = read_file(path) + + if '"hardcoverApiToken"' in content or '"Hardcover API Token"' in content: + print(" CrossPointWebServer.cpp already patched, skipping.") + return + + hardcover_block = ( + ' if (strcmp(s.key, "hardcoverApiToken") == 0) {\n' + ' doc["name"] = "Hardcover API Token";\n' + ' doc["category"] = "Plugins";\n' + ' }\n' + ) + + # Try to insert inside the existing s.key block added by darkmode patch + if 'if (s.key) {' in content and 'darkModeState' in content: + content = content.replace( + ' if (strcmp(s.key, "darkModeState") == 0) {\n', + hardcover_block + + ' if (strcmp(s.key, "darkModeState") == 0) {\n' + ) + else: + # No darkmode patch present - add our own block + content = content.replace( + ' doc["category"] = I18N.get(s.category);\n' + '\n' + ' switch (s.type) {', + ' doc["category"] = I18N.get(s.category);\n' + '\n' + ' if (s.key) {\n' + + hardcover_block + + ' }\n' + '\n' + ' switch (s.type) {' + ) + + write_file(path, content) + print(" CrossPointWebServer.cpp patched.") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def patch(repo_dir: str, yes_all: bool = False): + plugin_dir = os.path.dirname(os.path.abspath(__file__)) + + # ---- Resolve API token from ~/.hardcover or prompt --------------------- + # The token prompt is always shown even with --yes, since it is required + # input, not an optional confirmation. + hardcover_file = os.path.expanduser("~/.hardcover") + token = "" + + if os.path.exists(hardcover_file): + raw = open(hardcover_file).read().strip() + if raw.startswith("Bearer "): + raw = raw[len("Bearer "):] + token = raw + + if token: + print(f" Using Hardcover API token from ~/.hardcover.") + else: + print(" Hardcover API token required.") + print(" Get yours from https://hardcover.app/account/api") + print(" Paste the token (with or without the 'Bearer ' prefix).") + while not token: + raw = input(" Hardcover API token: ").strip() + if raw.startswith("Bearer "): + raw = raw[len("Bearer "):] + token = raw + if not token: + print(" Token cannot be empty. Please enter your Hardcover API token.") + with open(hardcover_file, "w") as f: + f.write(f"Bearer {token}\n") + print(f" Token saved to ~/.hardcover for future installs.") + + if len(token) >= 640: + sys.exit(f"ERROR: Hardcover API token is too long ({len(token)} chars, max 639).") + + print(" Copying plugin sources...") + copy_plugin_sources(plugin_dir, repo_dir) + + print(" Patching CrossPointSettings.h...") + patch_cross_point_settings_h(repo_dir, token) + + print(" Patching SettingsList.h...") + patch_settings_list_h(repo_dir) + + print(" Patching SettingAction enum...") + patch_setting_action_enum(repo_dir) + + print(" Patching SettingsActivity.h...") + patch_settings_h(repo_dir) + + print(" Patching SettingsActivity.cpp...") + patch_settings_cpp(repo_dir) + + print(" Patching CrossPointWebServer.cpp (web UI)...") + patch_web_server(repo_dir) + + # ---- Verification ------------------------------------------------------ + import glob as _glob + cpp_results = _glob.glob(os.path.join(repo_dir, "**", "SettingsActivity.cpp"), recursive=True) + if cpp_results: + cpp_content = open(cpp_results[0]).read() + checks = [ + ("label lambda: 'Hardcover Progress'", "Hardcover Progress" in cpp_content), + ("switch case: HardcoverSync", "case SettingAction::HardcoverSync:" in cpp_content), + ("HardcoverSyncActivity launched", "HardcoverSyncActivity" in cpp_content), + ("pluginsSettings.clear()", "pluginsSettings.clear();" in cpp_content), + ("case 4 -> pluginsSettings", "case 4:" in cpp_content), + ] + all_ok = True + for label, ok in checks: + print(f" [{'PASS' if ok else 'FAIL'}] {label}") + if not ok: + all_ok = False + if not all_ok: + sys.exit("ERROR: One or more Hardcover patch checks failed.") + print(" Hardcover plugin patch verified OK.") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit("Usage: python patch.py ") + patch(sys.argv[1]) \ No newline at end of file