Hardcover Progress Plugin
This commit is contained in:
1 parent
7dade1ec72
commit
bfbae77acb
5 files changed
+1021
No files matched your search
@@ -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
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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])
|
||||||
Reference in new issue
Block a user