## Summary Add ability to long press 'confirm' on a book in the recent books list to be prompted to remove it from the list. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? *Yes, Claude* ---------
219 lines
7.2 KiB
C++
219 lines
7.2 KiB
C++
#include "RecentBooksStore.h"
|
|
|
|
#include <Epub.h>
|
|
#include <FsHelpers.h>
|
|
#include <HalStorage.h>
|
|
#include <JsonSettingsIO.h>
|
|
#include <Logging.h>
|
|
#include <Serialization.h>
|
|
#include <Xtc.h>
|
|
|
|
#include <algorithm>
|
|
#include <iterator>
|
|
|
|
namespace {
|
|
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3;
|
|
constexpr char RECENT_BOOKS_FILE_BIN[] = "/.crosspoint/recent.bin";
|
|
constexpr char RECENT_BOOKS_FILE_JSON[] = "/.crosspoint/recent.json";
|
|
constexpr char RECENT_BOOKS_FILE_BAK[] = "/.crosspoint/recent.bin.bak";
|
|
constexpr int MAX_RECENT_BOOKS = 10;
|
|
} // namespace
|
|
|
|
RecentBooksStore RecentBooksStore::instance;
|
|
|
|
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
|
|
const std::string& coverBmpPath) {
|
|
// Drop stale entries first so a new add can't evict a valid book in their stead.
|
|
pruneMissing();
|
|
|
|
// Remove existing entry if present
|
|
auto it =
|
|
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
|
|
if (it != recentBooks.end()) {
|
|
recentBooks.erase(it);
|
|
}
|
|
|
|
// Add to front
|
|
recentBooks.insert(recentBooks.begin(), {path, title, author, coverBmpPath});
|
|
|
|
// Trim to max size
|
|
if (recentBooks.size() > MAX_RECENT_BOOKS) {
|
|
recentBooks.resize(MAX_RECENT_BOOKS);
|
|
}
|
|
|
|
saveToFile();
|
|
}
|
|
|
|
void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author,
|
|
const std::string& coverBmpPath) {
|
|
auto it =
|
|
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
|
|
if (it != recentBooks.end()) {
|
|
RecentBook& book = *it;
|
|
book.title = title;
|
|
book.author = author;
|
|
book.coverBmpPath = coverBmpPath;
|
|
saveToFile();
|
|
}
|
|
}
|
|
|
|
bool RecentBooksStore::removeByPath(const std::string& path) {
|
|
auto it =
|
|
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
|
|
if (it == recentBooks.end()) {
|
|
return false;
|
|
}
|
|
recentBooks.erase(it);
|
|
if (!saveToFile()) {
|
|
LOG_ERR("RBS", "Failed to persist removal of recent book: %s", path.c_str());
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void RecentBooksStore::updatePath(const std::string& oldPath, const std::string& newPath,
|
|
const std::string& oldCachePath, const std::string& newCachePath) {
|
|
auto it = std::find_if(recentBooks.begin(), recentBooks.end(),
|
|
[&](const RecentBook& book) { return book.path == oldPath; });
|
|
if (it == recentBooks.end()) {
|
|
return;
|
|
}
|
|
it->path = newPath;
|
|
if (!oldCachePath.empty() && !it->coverBmpPath.empty() && it->coverBmpPath.rfind(oldCachePath, 0) == 0) {
|
|
it->coverBmpPath = newCachePath + it->coverBmpPath.substr(oldCachePath.size());
|
|
}
|
|
saveToFile();
|
|
}
|
|
|
|
bool RecentBooksStore::isMissing(const RecentBook& book) { return !Storage.exists(book.path.c_str()); }
|
|
|
|
bool RecentBooksStore::pruneMissing() {
|
|
const size_t before = recentBooks.size();
|
|
recentBooks.erase(std::remove_if(recentBooks.begin(), recentBooks.end(), &isMissing), recentBooks.end());
|
|
return recentBooks.size() != before;
|
|
}
|
|
|
|
bool RecentBooksStore::saveToFile() const {
|
|
Storage.mkdir("/.crosspoint");
|
|
return JsonSettingsIO::saveRecentBooks(*this, RECENT_BOOKS_FILE_JSON);
|
|
}
|
|
|
|
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
|
std::string lastBookFileName = "";
|
|
const size_t lastSlash = path.find_last_of('/');
|
|
if (lastSlash != std::string::npos) {
|
|
lastBookFileName = path.substr(lastSlash + 1);
|
|
}
|
|
|
|
LOG_DBG("RBS", "Loading recent book: %s", path.c_str());
|
|
|
|
// If epub, try to load the metadata for title/author and cover.
|
|
// Use buildIfMissing=false to avoid heavy epub loading on boot; getTitle()/getAuthor() may be
|
|
// blank until the book is opened, and entries with missing title are omitted from recent list.
|
|
if (FsHelpers::hasEpubExtension(lastBookFileName)) {
|
|
Epub epub(path, "/.crosspoint");
|
|
epub.load(false, true);
|
|
return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()};
|
|
} else if (FsHelpers::hasXtcExtension(lastBookFileName)) {
|
|
// Handle XTC file
|
|
Xtc xtc(path, "/.crosspoint");
|
|
if (xtc.load()) {
|
|
return RecentBook{path, xtc.getTitle(), xtc.getAuthor(), xtc.getThumbBmpPath()};
|
|
}
|
|
} else if (FsHelpers::hasTxtExtension(lastBookFileName) || FsHelpers::hasMarkdownExtension(lastBookFileName)) {
|
|
return RecentBook{path, lastBookFileName, "", ""};
|
|
}
|
|
return RecentBook{path, "", "", ""};
|
|
}
|
|
|
|
bool RecentBooksStore::loadFromFile() {
|
|
// Try JSON first
|
|
if (Storage.exists(RECENT_BOOKS_FILE_JSON)) {
|
|
String json = Storage.readFile(RECENT_BOOKS_FILE_JSON);
|
|
if (!json.isEmpty()) {
|
|
return JsonSettingsIO::loadRecentBooks(*this, json.c_str());
|
|
}
|
|
}
|
|
|
|
// Fall back to binary migration
|
|
if (Storage.exists(RECENT_BOOKS_FILE_BIN)) {
|
|
if (loadFromBinaryFile()) {
|
|
saveToFile();
|
|
Storage.rename(RECENT_BOOKS_FILE_BIN, RECENT_BOOKS_FILE_BAK);
|
|
LOG_DBG("RBS", "Migrated recent.bin to recent.json");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool RecentBooksStore::loadFromBinaryFile() {
|
|
FsFile inputFile;
|
|
if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE_BIN, inputFile)) {
|
|
return false;
|
|
}
|
|
|
|
uint8_t version;
|
|
serialization::readPod(inputFile, version);
|
|
if (version == 1 || version == 2) {
|
|
// Old version, just read paths
|
|
uint8_t count;
|
|
serialization::readPod(inputFile, count);
|
|
recentBooks.clear();
|
|
recentBooks.reserve(count);
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
std::string path;
|
|
serialization::readString(inputFile, path);
|
|
|
|
// load book to get missing data
|
|
RecentBook book = getDataFromBook(path);
|
|
if (book.title.empty() && book.author.empty() && version == 2) {
|
|
// Fall back to loading what we can from the store
|
|
std::string title, author;
|
|
serialization::readString(inputFile, title);
|
|
serialization::readString(inputFile, author);
|
|
recentBooks.push_back({path, title, author, ""});
|
|
} else {
|
|
recentBooks.push_back(book);
|
|
}
|
|
}
|
|
} else if (version == 3) {
|
|
uint8_t count;
|
|
serialization::readPod(inputFile, count);
|
|
|
|
recentBooks.clear();
|
|
recentBooks.reserve(count);
|
|
uint8_t omitted = 0;
|
|
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
std::string path, title, author, coverBmpPath;
|
|
serialization::readString(inputFile, path);
|
|
serialization::readString(inputFile, title);
|
|
serialization::readString(inputFile, author);
|
|
serialization::readString(inputFile, coverBmpPath);
|
|
|
|
// Omit books with missing title (e.g. saved before metadata was available)
|
|
if (title.empty()) {
|
|
omitted++;
|
|
continue;
|
|
}
|
|
|
|
recentBooks.push_back({path, title, author, coverBmpPath});
|
|
}
|
|
|
|
if (omitted > 0) {
|
|
// Explicitly close() file before saveToFile() rewrites the same file
|
|
inputFile.close();
|
|
saveToFile();
|
|
LOG_DBG("RBS", "Omitted %u recent book(s) with missing title", omitted);
|
|
return true;
|
|
}
|
|
} else {
|
|
LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version);
|
|
return false;
|
|
}
|
|
|
|
LOG_DBG("RBS", "Recent books loaded from binary file (%d entries)", static_cast<int>(recentBooks.size()));
|
|
return true;
|
|
}
|