Hardcover Progress Plugin

This commit is contained in:
Justin Oros
2026-04-08 19:46:23 -07:00
parent 7dade1ec72
commit bfbae77acb
5 changed files with 1021 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
#include "HardcoverPlugin.h"
#include <ArduinoJson.h>
#include <Epub.h>
#include <FsHelpers.h>
#include <HTTPClient.h>
#include <HalStorage.h>
#include <Logging.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <string>
#include <vector>
#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<const char*>(data);
const char* end = p + len;
const char* pos = p;
while (pos < end) {
const char* tagStart = static_cast<const char*>(
memmem(pos, end - pos, "<dc:identifier", 14));
if (!tagStart) break;
const char* tagEnd = static_cast<const char*>(
memchr(tagStart, '>', end - tagStart));
if (!tagEnd) break;
if (*(tagEnd - 1) == '/') { pos = tagEnd + 1; continue; }
const char* valueStart = tagEnd + 1;
const char* closeTag = static_cast<const char*>(
memmem(valueStart, end - valueStart, "</dc:identifier>", 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<unsigned char>(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<char*>(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<float>(currentPage) / static_cast<float>(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<int>(pct + 0.5f);
}
// ---- Filesystem scan -------------------------------------------------------
void scanBooks(const std::string& rootPath, std::vector<std::string>& 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<std::string>{}(filePath));
}
std::vector<InProgressBook> collectInProgressBooks() {
std::vector<std::string> allBooks;
scanBooks("/", allBooks);
std::vector<InProgressBook> 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<InProgressBook> collectCompletedBooks() {
std::vector<std::string> allBooks;
scanBooks("/", allBooks);
std::vector<InProgressBook> 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<JsonArray>();
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<JsonArray>();
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
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <Arduino.h>
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();
}
@@ -0,0 +1,94 @@
#include "HardcoverSyncActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#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;
}
}
+22
View File
@@ -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();
};
+463
View File
@@ -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<SettingInfo> systemSettings;',
' std::vector<SettingInfo> systemSettings;\n std::vector<SettingInfo> 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<TabInfo> 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<TabInfo> 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<LanguageSelectActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
' case SettingAction::None:',
' case SettingAction::Language:\n'
' startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);\n'
' break;\n'
' case SettingAction::HardcoverSync:\n'
' startActivityForResult(std::make_unique<HardcoverSyncActivity>(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 <path-to-crosspoint-repo>")
patch(sys.argv[1])