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
249 lines
6.9 KiB
C++
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;
|
|
}
|