crosspoint-reader/src/BookListStore.cpp
cottongin 2c24ee3f81
Add reading lists feature with pinning and management
Adds full support for book lists managed by the Companion App:
- New /list API endpoints (GET/POST) for uploading, retrieving, and deleting lists
- BookListStore for binary serialization of lists to /.lists/ directory
- ListViewActivity for viewing list contents with book thumbnails
- Reading Lists tab in My Library with pin/unpin and delete actions
- Pinnable list shortcut on home screen (split button layout)
- Automatic cleanup of pinned status when lists are deleted
2026-01-26 02:08:59 -05:00

249 lines
6.9 KiB
C++

#include "BookListStore.h"
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include <algorithm>
#include <sstream>
namespace {
constexpr const char* LOG_TAG = "BLS";
constexpr uint8_t LIST_FILE_VERSION = 1;
// Helper to trim whitespace from a string
std::string trim(const std::string& str) {
const size_t first = str.find_first_not_of(" \t\r\n");
if (first == std::string::npos) return "";
const size_t last = str.find_last_not_of(" \t\r\n");
return str.substr(first, last - first + 1);
}
// Helper to parse a single CSV line
// Format: order,Title,Author,/path/to/file.epub
bool parseCsvLine(const std::string& line, BookListItem& item) {
if (line.empty()) return false;
// Find first comma (after order)
size_t pos1 = line.find(',');
if (pos1 == std::string::npos) return false;
// Find second comma (after title)
size_t pos2 = line.find(',', pos1 + 1);
if (pos2 == std::string::npos) return false;
// Find third comma (after author)
size_t pos3 = line.find(',', pos2 + 1);
if (pos3 == std::string::npos) return false;
// Parse order
std::string orderStr = trim(line.substr(0, pos1));
int order = atoi(orderStr.c_str());
if (order <= 0 || order > 255) return false;
item.order = static_cast<uint8_t>(order);
// Parse title, author, path
item.title = trim(line.substr(pos1 + 1, pos2 - pos1 - 1));
item.author = trim(line.substr(pos2 + 1, pos3 - pos2 - 1));
item.path = trim(line.substr(pos3 + 1));
// Validate we have at least a path
if (item.path.empty()) return false;
return true;
}
} // namespace
bool BookListStore::parseFromText(const std::string& text, BookList& list) {
list.books.clear();
std::istringstream stream(text);
std::string line;
while (std::getline(stream, line)) {
line = trim(line);
if (line.empty()) continue;
BookListItem item;
if (parseCsvLine(line, item)) {
list.books.push_back(item);
} else {
Serial.printf("[%lu] [%s] Failed to parse line: %s\n", millis(), LOG_TAG, line.c_str());
}
}
// Sort by order
std::sort(list.books.begin(), list.books.end(),
[](const BookListItem& a, const BookListItem& b) { return a.order < b.order; });
Serial.printf("[%lu] [%s] Parsed %d books from text\n", millis(), LOG_TAG, list.books.size());
return !list.books.empty();
}
bool BookListStore::saveList(const BookList& list) {
if (list.name.empty()) {
Serial.printf("[%lu] [%s] Cannot save list with empty name\n", millis(), LOG_TAG);
return false;
}
// Ensure lists directory exists
SdMan.mkdir(LISTS_DIR);
const std::string path = getListPath(list.name);
FsFile outputFile;
if (!SdMan.openFileForWrite(LOG_TAG, path, outputFile)) {
Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), LOG_TAG, path.c_str());
return false;
}
// Write version
serialization::writePod(outputFile, LIST_FILE_VERSION);
// Write book count
const uint8_t count = static_cast<uint8_t>(std::min(list.books.size(), size_t(255)));
serialization::writePod(outputFile, count);
// Write each book
for (size_t i = 0; i < count; i++) {
const auto& book = list.books[i];
serialization::writePod(outputFile, book.order);
serialization::writeString(outputFile, book.title);
serialization::writeString(outputFile, book.author);
serialization::writeString(outputFile, book.path);
}
outputFile.close();
Serial.printf("[%lu] [%s] Saved list '%s' with %d books to %s\n", millis(), LOG_TAG, list.name.c_str(), count,
path.c_str());
return true;
}
bool BookListStore::loadList(const std::string& name, BookList& list) {
const std::string path = getListPath(name);
FsFile inputFile;
if (!SdMan.openFileForRead(LOG_TAG, path, inputFile)) {
Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), LOG_TAG, path.c_str());
return false;
}
// Read version
uint8_t version;
serialization::readPod(inputFile, version);
if (version != LIST_FILE_VERSION) {
Serial.printf("[%lu] [%s] Unknown file version: %d\n", millis(), LOG_TAG, version);
inputFile.close();
return false;
}
// Read book count
uint8_t count;
serialization::readPod(inputFile, count);
list.name = name;
list.books.clear();
list.books.reserve(count);
// Read each book
for (uint8_t i = 0; i < count; i++) {
BookListItem item;
serialization::readPod(inputFile, item.order);
serialization::readString(inputFile, item.title);
serialization::readString(inputFile, item.author);
serialization::readString(inputFile, item.path);
list.books.push_back(item);
}
inputFile.close();
// Sort by order (should already be sorted, but ensure it)
std::sort(list.books.begin(), list.books.end(),
[](const BookListItem& a, const BookListItem& b) { return a.order < b.order; });
Serial.printf("[%lu] [%s] Loaded list '%s' with %d books\n", millis(), LOG_TAG, name.c_str(), count);
return true;
}
bool BookListStore::deleteList(const std::string& name) {
const std::string path = getListPath(name);
if (!SdMan.exists(path.c_str())) {
Serial.printf("[%lu] [%s] List not found: %s\n", millis(), LOG_TAG, path.c_str());
return false;
}
if (!SdMan.remove(path.c_str())) {
Serial.printf("[%lu] [%s] Failed to delete list: %s\n", millis(), LOG_TAG, path.c_str());
return false;
}
Serial.printf("[%lu] [%s] Deleted list: %s\n", millis(), LOG_TAG, name.c_str());
return true;
}
std::vector<std::string> BookListStore::listAllLists() {
std::vector<std::string> lists;
FsFile dir = SdMan.open(LISTS_DIR);
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
return lists;
}
char name[128];
FsFile entry;
while ((entry = dir.openNextFile())) {
if (!entry.isDirectory()) {
entry.getName(name, sizeof(name));
std::string filename(name);
// Only include .bin files
if (filename.length() > 4 && filename.substr(filename.length() - 4) == ".bin") {
// Strip .bin extension
lists.push_back(filename.substr(0, filename.length() - 4));
}
}
entry.close();
}
dir.close();
// Sort alphabetically
std::sort(lists.begin(), lists.end());
return lists;
}
bool BookListStore::listExists(const std::string& name) {
const std::string path = getListPath(name);
return SdMan.exists(path.c_str());
}
std::string BookListStore::getListPath(const std::string& name) {
return std::string(LISTS_DIR) + "/" + name + ".bin";
}
int BookListStore::getBookCount(const std::string& name) {
const std::string path = getListPath(name);
FsFile inputFile;
if (!SdMan.openFileForRead(LOG_TAG, path, inputFile)) {
return -1;
}
// Read version
uint8_t version;
serialization::readPod(inputFile, version);
if (version != LIST_FILE_VERSION) {
inputFile.close();
return -1;
}
// Read book count
uint8_t count;
serialization::readPod(inputFile, count);
inputFile.close();
return count;
}