adds delete and archive abilities
This commit is contained in:
parent
6b533207e1
commit
d5a9873bd7
3
.gitignore
vendored
3
.gitignore
vendored
@ -7,4 +7,5 @@ lib/EpdFont/fontsrc
|
||||
*.generated.h
|
||||
build
|
||||
**/__pycache__/
|
||||
test/epubs/
|
||||
test/epubs/
|
||||
TODO.md
|
||||
349
src/BookManager.cpp
Normal file
349
src/BookManager.cpp
Normal file
@ -0,0 +1,349 @@
|
||||
#include "BookManager.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
|
||||
#include "RecentBooksStore.h"
|
||||
|
||||
namespace {
|
||||
constexpr const char* LOG_TAG = "BM";
|
||||
|
||||
// Supported book extensions
|
||||
const char* SUPPORTED_EXTENSIONS[] = {".epub", ".txt", ".xtc", ".xtch"};
|
||||
constexpr size_t SUPPORTED_EXTENSIONS_COUNT = sizeof(SUPPORTED_EXTENSIONS) / sizeof(SUPPORTED_EXTENSIONS[0]);
|
||||
} // namespace
|
||||
|
||||
std::string BookManager::getFilename(const std::string& path) {
|
||||
const size_t lastSlash = path.find_last_of('/');
|
||||
if (lastSlash == std::string::npos) {
|
||||
return path;
|
||||
}
|
||||
return path.substr(lastSlash + 1);
|
||||
}
|
||||
|
||||
std::string BookManager::getExtension(const std::string& path) {
|
||||
const size_t lastDot = path.find_last_of('.');
|
||||
if (lastDot == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
std::string ext = path.substr(lastDot);
|
||||
// Convert to lowercase
|
||||
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
||||
return ext;
|
||||
}
|
||||
|
||||
size_t BookManager::computePathHash(const std::string& path) {
|
||||
return std::hash<std::string>{}(path);
|
||||
}
|
||||
|
||||
std::string BookManager::getCachePrefix(const std::string& path) {
|
||||
const std::string ext = getExtension(path);
|
||||
if (ext == ".epub") {
|
||||
return "epub_";
|
||||
} else if (ext == ".txt") {
|
||||
return "txt_";
|
||||
} else if (ext == ".xtc" || ext == ".xtch") {
|
||||
return "xtc_";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string BookManager::getCacheDir(const std::string& bookPath) {
|
||||
const std::string prefix = getCachePrefix(bookPath);
|
||||
if (prefix.empty()) {
|
||||
return "";
|
||||
}
|
||||
const size_t hash = computePathHash(bookPath);
|
||||
return std::string(CROSSPOINT_DIR) + "/" + prefix + std::to_string(hash);
|
||||
}
|
||||
|
||||
bool BookManager::writeMetaFile(const std::string& archivedPath, const std::string& originalPath) {
|
||||
const std::string metaPath = archivedPath + ".meta";
|
||||
FsFile metaFile;
|
||||
if (!SdMan.openFileForWrite(LOG_TAG, metaPath, metaFile)) {
|
||||
Serial.printf("[%lu] [%s] Failed to create meta file: %s\n", millis(), LOG_TAG, metaPath.c_str());
|
||||
return false;
|
||||
}
|
||||
metaFile.print(originalPath.c_str());
|
||||
metaFile.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string BookManager::readMetaFile(const std::string& archivedPath) {
|
||||
const std::string metaPath = archivedPath + ".meta";
|
||||
FsFile metaFile;
|
||||
if (!SdMan.openFileForRead(LOG_TAG, metaPath, metaFile)) {
|
||||
Serial.printf("[%lu] [%s] Failed to read meta file: %s\n", millis(), LOG_TAG, metaPath.c_str());
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string originalPath;
|
||||
originalPath.reserve(256);
|
||||
while (metaFile.available()) {
|
||||
char c = metaFile.read();
|
||||
if (c == '\n' || c == '\r') {
|
||||
break; // Stop at newline
|
||||
}
|
||||
originalPath += c;
|
||||
}
|
||||
metaFile.close();
|
||||
return originalPath;
|
||||
}
|
||||
|
||||
bool BookManager::ensureParentDirExists(const std::string& path) {
|
||||
// Find the last slash to get the parent directory
|
||||
const size_t lastSlash = path.find_last_of('/');
|
||||
if (lastSlash == std::string::npos || lastSlash == 0) {
|
||||
return true; // Root or no parent directory
|
||||
}
|
||||
|
||||
const std::string parentDir = path.substr(0, lastSlash);
|
||||
if (SdMan.exists(parentDir.c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create parent directories recursively
|
||||
return SdMan.mkdir(parentDir.c_str());
|
||||
}
|
||||
|
||||
bool BookManager::isSupportedBookFormat(const std::string& path) {
|
||||
const std::string ext = getExtension(path);
|
||||
for (size_t i = 0; i < SUPPORTED_EXTENSIONS_COUNT; i++) {
|
||||
if (ext == SUPPORTED_EXTENSIONS[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool BookManager::archiveBook(const std::string& bookPath) {
|
||||
Serial.printf("[%lu] [%s] Archiving book: %s\n", millis(), LOG_TAG, bookPath.c_str());
|
||||
|
||||
// Validate the book exists
|
||||
if (!SdMan.exists(bookPath.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Book not found: %s\n", millis(), LOG_TAG, bookPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create archive directories if needed
|
||||
SdMan.mkdir(ARCHIVE_DIR);
|
||||
SdMan.mkdir(ARCHIVE_CACHE_DIR);
|
||||
|
||||
const std::string filename = getFilename(bookPath);
|
||||
const std::string archivedBookPath = std::string(ARCHIVE_DIR) + "/" + filename;
|
||||
|
||||
// Check if already archived (same filename exists)
|
||||
if (SdMan.exists(archivedBookPath.c_str())) {
|
||||
Serial.printf("[%lu] [%s] A book with this name is already archived: %s\n", millis(), LOG_TAG, filename.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write meta file with original path
|
||||
if (!writeMetaFile(archivedBookPath, bookPath)) {
|
||||
Serial.printf("[%lu] [%s] Failed to write meta file\n", millis(), LOG_TAG);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move the book file
|
||||
if (!SdMan.rename(bookPath.c_str(), archivedBookPath.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Failed to move book file\n", millis(), LOG_TAG);
|
||||
// Clean up meta file
|
||||
SdMan.remove((archivedBookPath + ".meta").c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move cache directory if it exists
|
||||
const std::string cacheDir = getCacheDir(bookPath);
|
||||
if (!cacheDir.empty() && SdMan.exists(cacheDir.c_str())) {
|
||||
const std::string prefix = getCachePrefix(bookPath);
|
||||
const size_t hash = computePathHash(bookPath);
|
||||
const std::string archivedCacheDir = std::string(ARCHIVE_CACHE_DIR) + "/" + prefix + std::to_string(hash);
|
||||
|
||||
if (!SdMan.rename(cacheDir.c_str(), archivedCacheDir.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Warning: Failed to move cache directory (book still archived)\n", millis(), LOG_TAG);
|
||||
// Don't fail the archive operation, the cache can be regenerated
|
||||
} else {
|
||||
Serial.printf("[%lu] [%s] Moved cache to: %s\n", millis(), LOG_TAG, archivedCacheDir.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from recent books
|
||||
RECENT_BOOKS.removeBook(bookPath);
|
||||
|
||||
Serial.printf("[%lu] [%s] Successfully archived: %s\n", millis(), LOG_TAG, bookPath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookManager::unarchiveBook(const std::string& archivedFilename) {
|
||||
Serial.printf("[%lu] [%s] Unarchiving book: %s\n", millis(), LOG_TAG, archivedFilename.c_str());
|
||||
|
||||
const std::string archivedBookPath = std::string(ARCHIVE_DIR) + "/" + archivedFilename;
|
||||
|
||||
// Validate archived book exists
|
||||
if (!SdMan.exists(archivedBookPath.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Archived book not found: %s\n", millis(), LOG_TAG, archivedBookPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read original path from meta file
|
||||
const std::string originalPath = readMetaFile(archivedBookPath);
|
||||
if (originalPath.empty()) {
|
||||
Serial.printf("[%lu] [%s] Failed to read original path from meta file\n", millis(), LOG_TAG);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if original location already has a file
|
||||
if (SdMan.exists(originalPath.c_str())) {
|
||||
Serial.printf("[%lu] [%s] A file already exists at original location: %s\n", millis(), LOG_TAG,
|
||||
originalPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if (!ensureParentDirExists(originalPath)) {
|
||||
Serial.printf("[%lu] [%s] Failed to create parent directory for: %s\n", millis(), LOG_TAG, originalPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move the book file back
|
||||
if (!SdMan.rename(archivedBookPath.c_str(), originalPath.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Failed to move book file back\n", millis(), LOG_TAG);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move cache directory back if it exists
|
||||
const std::string prefix = getCachePrefix(archivedFilename);
|
||||
if (!prefix.empty()) {
|
||||
const size_t hash = computePathHash(originalPath);
|
||||
const std::string archivedCacheDir = std::string(ARCHIVE_CACHE_DIR) + "/" + prefix + std::to_string(hash);
|
||||
const std::string originalCacheDir = std::string(CROSSPOINT_DIR) + "/" + prefix + std::to_string(hash);
|
||||
|
||||
if (SdMan.exists(archivedCacheDir.c_str())) {
|
||||
if (!SdMan.rename(archivedCacheDir.c_str(), originalCacheDir.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Warning: Failed to restore cache directory\n", millis(), LOG_TAG);
|
||||
// Don't fail, cache can be regenerated
|
||||
} else {
|
||||
Serial.printf("[%lu] [%s] Restored cache to: %s\n", millis(), LOG_TAG, originalCacheDir.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the meta file
|
||||
SdMan.remove((archivedBookPath + ".meta").c_str());
|
||||
|
||||
Serial.printf("[%lu] [%s] Successfully unarchived to: %s\n", millis(), LOG_TAG, originalPath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookManager::deleteBook(const std::string& bookPath, bool isArchived) {
|
||||
Serial.printf("[%lu] [%s] Deleting book (archived=%d): %s\n", millis(), LOG_TAG, isArchived, bookPath.c_str());
|
||||
|
||||
std::string actualPath;
|
||||
std::string originalPath;
|
||||
|
||||
if (isArchived) {
|
||||
// bookPath is just the filename in /.archive/
|
||||
actualPath = std::string(ARCHIVE_DIR) + "/" + bookPath;
|
||||
// Read original path to compute correct cache hash
|
||||
originalPath = readMetaFile(actualPath);
|
||||
} else {
|
||||
actualPath = bookPath;
|
||||
originalPath = bookPath;
|
||||
}
|
||||
|
||||
// Validate the book exists
|
||||
if (!SdMan.exists(actualPath.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Book not found: %s\n", millis(), LOG_TAG, actualPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete the book file
|
||||
if (!SdMan.remove(actualPath.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Failed to delete book file\n", millis(), LOG_TAG);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete cache directory
|
||||
// Use originalPath for hash calculation (for archived books, this is the path before archiving)
|
||||
const std::string pathForCache = originalPath.empty() ? actualPath : originalPath;
|
||||
std::string cacheDir;
|
||||
|
||||
if (isArchived && !originalPath.empty()) {
|
||||
// For archived books, cache is in /.archive/.cache/
|
||||
const std::string prefix = getCachePrefix(pathForCache);
|
||||
if (!prefix.empty()) {
|
||||
const size_t hash = computePathHash(originalPath);
|
||||
cacheDir = std::string(ARCHIVE_CACHE_DIR) + "/" + prefix + std::to_string(hash);
|
||||
}
|
||||
} else {
|
||||
// For regular books, cache is in /.crosspoint/
|
||||
cacheDir = getCacheDir(pathForCache);
|
||||
}
|
||||
|
||||
if (!cacheDir.empty() && SdMan.exists(cacheDir.c_str())) {
|
||||
if (SdMan.removeDir(cacheDir.c_str())) {
|
||||
Serial.printf("[%lu] [%s] Deleted cache directory: %s\n", millis(), LOG_TAG, cacheDir.c_str());
|
||||
} else {
|
||||
Serial.printf("[%lu] [%s] Warning: Failed to delete cache directory\n", millis(), LOG_TAG);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete meta file if archived
|
||||
if (isArchived) {
|
||||
SdMan.remove((actualPath + ".meta").c_str());
|
||||
}
|
||||
|
||||
// Remove from recent books (use original path)
|
||||
if (!originalPath.empty()) {
|
||||
RECENT_BOOKS.removeBook(originalPath);
|
||||
} else {
|
||||
RECENT_BOOKS.removeBook(bookPath);
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [%s] Successfully deleted book\n", millis(), LOG_TAG);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<std::string> BookManager::listArchivedBooks() {
|
||||
std::vector<std::string> archivedBooks;
|
||||
|
||||
FsFile archiveDir = SdMan.open(ARCHIVE_DIR);
|
||||
if (!archiveDir || !archiveDir.isDirectory()) {
|
||||
if (archiveDir) {
|
||||
archiveDir.close();
|
||||
}
|
||||
return archivedBooks;
|
||||
}
|
||||
|
||||
char name[128];
|
||||
FsFile entry;
|
||||
while ((entry = archiveDir.openNextFile())) {
|
||||
if (!entry.isDirectory()) {
|
||||
entry.getName(name, sizeof(name));
|
||||
const std::string filename(name);
|
||||
|
||||
// Skip .meta files and hidden files
|
||||
if (filename[0] != '.' && filename.find(".meta") == std::string::npos) {
|
||||
// Only include supported book formats
|
||||
if (isSupportedBookFormat(filename)) {
|
||||
archivedBooks.push_back(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
archiveDir.close();
|
||||
|
||||
// Sort alphabetically
|
||||
std::sort(archivedBooks.begin(), archivedBooks.end());
|
||||
|
||||
return archivedBooks;
|
||||
}
|
||||
|
||||
std::string BookManager::getArchivedBookOriginalPath(const std::string& archivedFilename) {
|
||||
const std::string archivedBookPath = std::string(ARCHIVE_DIR) + "/" + archivedFilename;
|
||||
return readMetaFile(archivedBookPath);
|
||||
}
|
||||
80
src/BookManager.h
Normal file
80
src/BookManager.h
Normal file
@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* BookManager - Handles book archiving, unarchiving, and deletion
|
||||
*
|
||||
* Archive: Moves book and its cache to /.archive/, preserving reading progress
|
||||
* Unarchive: Restores book and cache to original location
|
||||
* Delete: Permanently removes book and its cache
|
||||
*/
|
||||
class BookManager {
|
||||
public:
|
||||
static constexpr const char* ARCHIVE_DIR = "/.archive";
|
||||
static constexpr const char* ARCHIVE_CACHE_DIR = "/.archive/.cache";
|
||||
static constexpr const char* CROSSPOINT_DIR = "/.crosspoint";
|
||||
|
||||
/**
|
||||
* Archive a book - moves file and cache to /.archive/
|
||||
* @param bookPath Full path to the book file (e.g., "/Books/mybook.epub")
|
||||
* @return true if successful
|
||||
*/
|
||||
static bool archiveBook(const std::string& bookPath);
|
||||
|
||||
/**
|
||||
* Unarchive a book - restores file and cache to original location
|
||||
* @param archivedFilename Filename in /.archive/ (e.g., "mybook.epub")
|
||||
* @return true if successful
|
||||
*/
|
||||
static bool unarchiveBook(const std::string& archivedFilename);
|
||||
|
||||
/**
|
||||
* Delete a book permanently - removes file and cache
|
||||
* @param bookPath Full path to the book file
|
||||
* @param isArchived If true, treats path as archived filename and also deletes .meta file
|
||||
* @return true if successful
|
||||
*/
|
||||
static bool deleteBook(const std::string& bookPath, bool isArchived = false);
|
||||
|
||||
/**
|
||||
* List archived books
|
||||
* @return Vector of archived filenames (without path)
|
||||
*/
|
||||
static std::vector<std::string> listArchivedBooks();
|
||||
|
||||
/**
|
||||
* Get the original path of an archived book
|
||||
* @param archivedFilename Filename in /.archive/
|
||||
* @return Original path, or empty string if not found
|
||||
*/
|
||||
static std::string getArchivedBookOriginalPath(const std::string& archivedFilename);
|
||||
|
||||
private:
|
||||
// Extract filename from a full path
|
||||
static std::string getFilename(const std::string& path);
|
||||
|
||||
// Get the file extension (lowercase, including the dot)
|
||||
static std::string getExtension(const std::string& path);
|
||||
|
||||
// Compute the hash used for cache directory naming
|
||||
static size_t computePathHash(const std::string& path);
|
||||
|
||||
// Get cache directory prefix for a file type (epub_, txt_, xtc_)
|
||||
static std::string getCachePrefix(const std::string& path);
|
||||
|
||||
// Get the full cache directory path for a book
|
||||
static std::string getCacheDir(const std::string& bookPath);
|
||||
|
||||
// Write the .meta file for an archived book
|
||||
static bool writeMetaFile(const std::string& archivedPath, const std::string& originalPath);
|
||||
|
||||
// Read the original path from a .meta file
|
||||
static std::string readMetaFile(const std::string& archivedPath);
|
||||
|
||||
// Create parent directories for a path if they don't exist
|
||||
static bool ensureParentDirExists(const std::string& path);
|
||||
|
||||
// Check if a path is a supported book format
|
||||
static bool isSupportedBookFormat(const std::string& path);
|
||||
};
|
||||
@ -32,6 +32,18 @@ void RecentBooksStore::addBook(const std::string& path) {
|
||||
saveToFile();
|
||||
}
|
||||
|
||||
bool RecentBooksStore::removeBook(const std::string& path) {
|
||||
auto it = std::find(recentBooks.begin(), recentBooks.end(), path);
|
||||
if (it == recentBooks.end()) {
|
||||
return false; // Book not found in recent list
|
||||
}
|
||||
|
||||
recentBooks.erase(it);
|
||||
saveToFile();
|
||||
Serial.printf("[%lu] [RBS] Removed book from recent list: %s\n", millis(), path.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RecentBooksStore::saveToFile() const {
|
||||
// Make sure the directory exists
|
||||
SdMan.mkdir("/.crosspoint");
|
||||
|
||||
@ -17,6 +17,10 @@ class RecentBooksStore {
|
||||
// Add a book path to the recent list (moves to front if already exists)
|
||||
void addBook(const std::string& path);
|
||||
|
||||
// Remove a book path from the recent list (e.g., when archived or deleted)
|
||||
// Returns true if the book was found and removed
|
||||
bool removeBook(const std::string& path);
|
||||
|
||||
// Get the list of recent book paths (most recent first)
|
||||
const std::vector<std::string>& getBooks() const { return recentBooks; }
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "BookManager.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "ScreenComponents.h"
|
||||
@ -22,6 +23,7 @@ constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator
|
||||
// Timing thresholds
|
||||
constexpr int SKIP_PAGE_MS = 700;
|
||||
constexpr unsigned long GO_HOME_MS = 1000;
|
||||
constexpr unsigned long ACTION_MENU_MS = 700; // Long press to open action menu
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs) {
|
||||
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
|
||||
@ -180,7 +182,122 @@ void MyLibraryActivity::onExit() {
|
||||
files.clear();
|
||||
}
|
||||
|
||||
bool MyLibraryActivity::isSelectedItemAFile() const {
|
||||
if (currentTab == Tab::Recent) {
|
||||
return !bookPaths.empty() && selectorIndex < static_cast<int>(bookPaths.size());
|
||||
} else {
|
||||
// Files tab - check if it's a file (not a directory)
|
||||
if (files.empty() || selectorIndex >= static_cast<int>(files.size())) {
|
||||
return false;
|
||||
}
|
||||
return files[selectorIndex].back() != '/';
|
||||
}
|
||||
}
|
||||
|
||||
void MyLibraryActivity::openActionMenu() {
|
||||
if (!isSelectedItemAFile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTab == Tab::Recent) {
|
||||
actionTargetPath = bookPaths[selectorIndex];
|
||||
actionTargetName = bookTitles[selectorIndex];
|
||||
} else {
|
||||
if (basepath.back() != '/') {
|
||||
actionTargetPath = basepath + "/" + files[selectorIndex];
|
||||
} else {
|
||||
actionTargetPath = basepath + files[selectorIndex];
|
||||
}
|
||||
actionTargetName = files[selectorIndex];
|
||||
}
|
||||
|
||||
uiState = UIState::ActionMenu;
|
||||
menuSelection = 0; // Default to Archive
|
||||
ignoreNextConfirmRelease = true; // Ignore the release from the long-press that opened this menu
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
void MyLibraryActivity::executeAction() {
|
||||
bool success = false;
|
||||
|
||||
if (selectedAction == ActionType::Archive) {
|
||||
success = BookManager::archiveBook(actionTargetPath);
|
||||
} else {
|
||||
success = BookManager::deleteBook(actionTargetPath);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Reload data
|
||||
loadRecentBooks();
|
||||
loadFiles();
|
||||
|
||||
// Adjust selector if needed
|
||||
const int itemCount = getCurrentItemCount();
|
||||
if (selectorIndex >= itemCount && itemCount > 0) {
|
||||
selectorIndex = itemCount - 1;
|
||||
} else if (itemCount == 0) {
|
||||
selectorIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
uiState = UIState::Normal;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
void MyLibraryActivity::loop() {
|
||||
// Handle action menu state
|
||||
if (uiState == UIState::ActionMenu) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
uiState = UIState::Normal;
|
||||
ignoreNextConfirmRelease = false;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
||||
menuSelection = 0; // Archive
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||
menuSelection = 1; // Delete
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Ignore the release from the long-press that opened this menu
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete;
|
||||
uiState = UIState::Confirming;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle confirmation state
|
||||
if (uiState == UIState::Confirming) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
uiState = UIState::ActionMenu;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
executeAction();
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal state handling
|
||||
const int itemCount = getCurrentItemCount();
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
@ -196,6 +313,13 @@ void MyLibraryActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Long press Confirm to open action menu (only for files, not directories)
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
||||
mappedInput.getHeldTime() >= ACTION_MENU_MS && isSelectedItemAFile()) {
|
||||
openActionMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
@ -203,8 +327,13 @@ void MyLibraryActivity::loop() {
|
||||
|
||||
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
||||
|
||||
// Confirm button - open selected item
|
||||
// Confirm button - open selected item (short press)
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Ignore if it was a long press that triggered the action menu
|
||||
if (mappedInput.getHeldTime() >= ACTION_MENU_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTab == Tab::Recent) {
|
||||
if (!bookPaths.empty() && selectorIndex < static_cast<int>(bookPaths.size())) {
|
||||
onSelectBook(bookPaths[selectorIndex], currentTab);
|
||||
@ -302,6 +431,20 @@ void MyLibraryActivity::displayTaskLoop() {
|
||||
void MyLibraryActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Handle different UI states
|
||||
if (uiState == UIState::ActionMenu) {
|
||||
renderActionMenu();
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (uiState == UIState::Confirming) {
|
||||
renderConfirmation();
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal state - draw library view
|
||||
// Draw tab bar
|
||||
std::vector<TabInfo> tabs = {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}};
|
||||
ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs);
|
||||
@ -376,3 +519,69 @@ void MyLibraryActivity::renderFilesTab() const {
|
||||
i != selectorIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void MyLibraryActivity::renderActionMenu() const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, "Book Actions", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Show filename
|
||||
const int filenameY = 70;
|
||||
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
|
||||
|
||||
// Menu options
|
||||
const int menuStartY = pageHeight / 2 - 30;
|
||||
constexpr int menuLineHeight = 40;
|
||||
constexpr int menuItemWidth = 120;
|
||||
const int menuX = (pageWidth - menuItemWidth) / 2;
|
||||
|
||||
// Archive option
|
||||
if (menuSelection == 0) {
|
||||
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0);
|
||||
|
||||
// Delete option
|
||||
if (menuSelection == 1) {
|
||||
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1);
|
||||
|
||||
// Draw side button hints (up/down navigation)
|
||||
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
||||
|
||||
// Draw bottom button hints
|
||||
const auto labels = mappedInput.mapLabels("« Cancel", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
void MyLibraryActivity::renderConfirmation() const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Title based on action
|
||||
const char* actionTitle = (selectedAction == ActionType::Archive) ? "Archive Book?" : "Delete Book?";
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, actionTitle, true, EpdFontFamily::BOLD);
|
||||
|
||||
// Show filename
|
||||
const int filenameY = pageHeight / 2 - 40;
|
||||
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
|
||||
|
||||
// Warning text
|
||||
const int warningY = pageHeight / 2;
|
||||
if (selectedAction == ActionType::Archive) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be moved to archive.");
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "Reading progress will be saved.");
|
||||
} else {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, warningY, "Book will be permanently deleted!", true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, warningY + 25, "This cannot be undone.");
|
||||
}
|
||||
|
||||
// Draw bottom button hints
|
||||
const auto labels = mappedInput.mapLabels("« Cancel", "Confirm", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
class MyLibraryActivity final : public Activity {
|
||||
public:
|
||||
enum class Tab { Recent, Files };
|
||||
enum class UIState { Normal, ActionMenu, Confirming };
|
||||
enum class ActionType { Archive, Delete };
|
||||
|
||||
private:
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
@ -21,6 +23,14 @@ class MyLibraryActivity final : public Activity {
|
||||
int selectorIndex = 0;
|
||||
bool updateRequired = false;
|
||||
|
||||
// Action menu state
|
||||
UIState uiState = UIState::Normal;
|
||||
ActionType selectedAction = ActionType::Archive;
|
||||
std::string actionTargetPath;
|
||||
std::string actionTargetName;
|
||||
int menuSelection = 0; // 0 = Archive, 1 = Delete
|
||||
bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu
|
||||
|
||||
// Recent tab state
|
||||
std::vector<std::string> bookTitles; // Display titles for each book
|
||||
std::vector<std::string> bookPaths; // Paths for each visible book (excludes missing)
|
||||
@ -50,6 +60,13 @@ class MyLibraryActivity final : public Activity {
|
||||
void render() const;
|
||||
void renderRecentTab() const;
|
||||
void renderFilesTab() const;
|
||||
void renderActionMenu() const;
|
||||
void renderConfirmation() const;
|
||||
|
||||
// Action handling
|
||||
void openActionMenu();
|
||||
void executeAction();
|
||||
bool isSelectedItemAFile() const;
|
||||
|
||||
public:
|
||||
explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include "BookManager.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
@ -119,6 +120,33 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle end-of-book prompt
|
||||
if (showingEndOfBookPrompt) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
||||
endOfBookSelection = (endOfBookSelection + 2) % 3;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||
endOfBookSelection = (endOfBookSelection + 1) % 3;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
handleEndOfBookAction();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
// Go back to last page instead
|
||||
currentSpineIndex = epub->getSpineItemsCount() - 1;
|
||||
nextPageNumber = UINT16_MAX;
|
||||
showingEndOfBookPrompt = false;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter chapter selection activity
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Don't start activity transition while rendering
|
||||
@ -260,11 +288,9 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// any botton press when at end of the book goes back to the last page
|
||||
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
|
||||
currentSpineIndex = epub->getSpineItemsCount() - 1;
|
||||
nextPageNumber = UINT16_MAX;
|
||||
updateRequired = true;
|
||||
// any button press when at end of the book - this is now handled by the prompt
|
||||
// Just ensure we don't go past the end
|
||||
if (currentSpineIndex >= epub->getSpineItemsCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -341,13 +367,13 @@ void EpubReaderActivity::renderScreen() {
|
||||
currentSpineIndex = epub->getSpineItemsCount();
|
||||
}
|
||||
|
||||
// Show end of book screen
|
||||
// Show end of book prompt
|
||||
if (currentSpineIndex == epub->getSpineItemsCount()) {
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
showingEndOfBookPrompt = true;
|
||||
renderEndOfBookPrompt();
|
||||
return;
|
||||
}
|
||||
showingEndOfBookPrompt = false;
|
||||
|
||||
// Apply screen viewable areas and additional padding
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
@ -586,3 +612,60 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
||||
title.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::renderEndOfBookPrompt() {
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Book title (truncated if needed)
|
||||
std::string bookTitle = epub->getTitle();
|
||||
if (bookTitle.length() > 30) {
|
||||
bookTitle = bookTitle.substr(0, 27) + "...";
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 120, bookTitle.c_str());
|
||||
|
||||
// Menu options
|
||||
const int menuStartY = pageHeight / 2 - 30;
|
||||
constexpr int menuLineHeight = 45;
|
||||
constexpr int menuItemWidth = 140;
|
||||
const int menuX = (pageWidth - menuItemWidth) / 2;
|
||||
|
||||
const char* options[] = {"Archive", "Delete", "Keep"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
const int optionY = menuStartY + i * menuLineHeight;
|
||||
if (endOfBookSelection == i) {
|
||||
renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5);
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void EpubReaderActivity::handleEndOfBookAction() {
|
||||
const std::string bookPath = epub->getPath();
|
||||
|
||||
switch (endOfBookSelection) {
|
||||
case 0: // Archive
|
||||
BookManager::archiveBook(bookPath);
|
||||
onGoHome();
|
||||
break;
|
||||
case 1: // Delete
|
||||
BookManager::deleteBook(bookPath);
|
||||
onGoHome();
|
||||
break;
|
||||
case 2: // Keep
|
||||
default:
|
||||
onGoHome();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,12 +19,18 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
// End-of-book prompt state
|
||||
bool showingEndOfBookPrompt = false;
|
||||
int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep (default to safe option)
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void renderScreen();
|
||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||
int orientedMarginBottom, int orientedMarginLeft);
|
||||
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
|
||||
void renderEndOfBookPrompt();
|
||||
void handleEndOfBookAction();
|
||||
|
||||
public:
|
||||
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
#include <Serialization.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include "BookManager.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
@ -95,6 +96,30 @@ void TxtReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle end-of-book prompt
|
||||
if (showingEndOfBookPrompt) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
||||
endOfBookSelection = (endOfBookSelection + 2) % 3;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||
endOfBookSelection = (endOfBookSelection + 1) % 3;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
handleEndOfBookAction();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
showingEndOfBookPrompt = false;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes directly to home
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
|
||||
onGoHome();
|
||||
@ -121,9 +146,15 @@ void TxtReaderActivity::loop() {
|
||||
if (prevReleased && currentPage > 0) {
|
||||
currentPage--;
|
||||
updateRequired = true;
|
||||
} else if (nextReleased && currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
updateRequired = true;
|
||||
} else if (nextReleased) {
|
||||
if (currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
updateRequired = true;
|
||||
} else {
|
||||
// At last page, show end-of-book prompt
|
||||
showingEndOfBookPrompt = true;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -381,6 +412,12 @@ void TxtReaderActivity::renderScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show end-of-book prompt if active
|
||||
if (showingEndOfBookPrompt) {
|
||||
renderEndOfBookPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize reader if not done
|
||||
if (!initialized) {
|
||||
renderer.clearScreen();
|
||||
@ -698,3 +735,64 @@ void TxtReaderActivity::savePageIndexCache() const {
|
||||
f.close();
|
||||
Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages);
|
||||
}
|
||||
|
||||
void TxtReaderActivity::renderEndOfBookPrompt() {
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Filename (truncated if needed)
|
||||
std::string filename = txt->getPath();
|
||||
const size_t lastSlash = filename.find_last_of('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
filename = filename.substr(lastSlash + 1);
|
||||
}
|
||||
if (filename.length() > 30) {
|
||||
filename = filename.substr(0, 27) + "...";
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 120, filename.c_str());
|
||||
|
||||
// Menu options
|
||||
const int menuStartY = pageHeight / 2 - 30;
|
||||
constexpr int menuLineHeight = 45;
|
||||
constexpr int menuItemWidth = 140;
|
||||
const int menuX = (pageWidth - menuItemWidth) / 2;
|
||||
|
||||
const char* options[] = {"Archive", "Delete", "Keep"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
const int optionY = menuStartY + i * menuLineHeight;
|
||||
if (endOfBookSelection == i) {
|
||||
renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5);
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void TxtReaderActivity::handleEndOfBookAction() {
|
||||
const std::string bookPath = txt->getPath();
|
||||
|
||||
switch (endOfBookSelection) {
|
||||
case 0: // Archive
|
||||
BookManager::archiveBook(bookPath);
|
||||
onGoHome();
|
||||
break;
|
||||
case 1: // Delete
|
||||
BookManager::deleteBook(bookPath);
|
||||
onGoHome();
|
||||
break;
|
||||
case 2: // Keep
|
||||
default:
|
||||
onGoHome();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,10 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
// End-of-book prompt state
|
||||
bool showingEndOfBookPrompt = false;
|
||||
int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep
|
||||
|
||||
// Streaming text reader - stores file offsets for each page
|
||||
std::vector<size_t> pageOffsets; // File offset for start of each page
|
||||
std::vector<std::string> currentPageLines;
|
||||
@ -38,6 +42,8 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
|
||||
void renderScreen();
|
||||
void renderPage();
|
||||
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
|
||||
void renderEndOfBookPrompt();
|
||||
void handleEndOfBookAction();
|
||||
|
||||
void initializeReader();
|
||||
bool loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset);
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include "BookManager.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
@ -79,6 +80,32 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle end-of-book prompt
|
||||
if (showingEndOfBookPrompt) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
||||
endOfBookSelection = (endOfBookSelection + 2) % 3;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||
endOfBookSelection = (endOfBookSelection + 1) % 3;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
handleEndOfBookAction();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
// Go back to last page
|
||||
currentPage = xtc->getPageCount() - 1;
|
||||
showingEndOfBookPrompt = false;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter chapter selection activity
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
@ -122,10 +149,8 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle end of book
|
||||
// If at end of book prompt position, handle differently
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
currentPage = xtc->getPageCount() - 1;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -142,7 +167,7 @@ void XtcReaderActivity::loop() {
|
||||
} else if (nextReleased) {
|
||||
currentPage += skipAmount;
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
currentPage = xtc->getPageCount(); // Allow showing "End of book"
|
||||
currentPage = xtc->getPageCount(); // Will trigger end-of-book prompt
|
||||
}
|
||||
updateRequired = true;
|
||||
}
|
||||
@ -165,14 +190,13 @@ void XtcReaderActivity::renderScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bounds check
|
||||
// Bounds check - show end-of-book prompt
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
// Show end of book screen
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
showingEndOfBookPrompt = true;
|
||||
renderEndOfBookPrompt();
|
||||
return;
|
||||
}
|
||||
showingEndOfBookPrompt = false;
|
||||
|
||||
renderPage();
|
||||
saveProgress();
|
||||
@ -389,3 +413,64 @@ void XtcReaderActivity::loadProgress() {
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderActivity::renderEndOfBookPrompt() {
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 80, "Finished!", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Filename (truncated if needed)
|
||||
std::string filename = xtc->getPath();
|
||||
const size_t lastSlash = filename.find_last_of('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
filename = filename.substr(lastSlash + 1);
|
||||
}
|
||||
if (filename.length() > 30) {
|
||||
filename = filename.substr(0, 27) + "...";
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 120, filename.c_str());
|
||||
|
||||
// Menu options
|
||||
const int menuStartY = pageHeight / 2 - 30;
|
||||
constexpr int menuLineHeight = 45;
|
||||
constexpr int menuItemWidth = 140;
|
||||
const int menuX = (pageWidth - menuItemWidth) / 2;
|
||||
|
||||
const char* options[] = {"Archive", "Delete", "Keep"};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
const int optionY = menuStartY + i * menuLineHeight;
|
||||
if (endOfBookSelection == i) {
|
||||
renderer.fillRect(menuX - 10, optionY - 5, menuItemWidth + 20, menuLineHeight - 5);
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, optionY, options[i], endOfBookSelection != i);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void XtcReaderActivity::handleEndOfBookAction() {
|
||||
const std::string bookPath = xtc->getPath();
|
||||
|
||||
switch (endOfBookSelection) {
|
||||
case 0: // Archive
|
||||
BookManager::archiveBook(bookPath);
|
||||
onGoHome();
|
||||
break;
|
||||
case 1: // Delete
|
||||
BookManager::deleteBook(bookPath);
|
||||
onGoHome();
|
||||
break;
|
||||
case 2: // Keep
|
||||
default:
|
||||
onGoHome();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,12 +24,18 @@ class XtcReaderActivity final : public ActivityWithSubactivity {
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
// End-of-book prompt state
|
||||
bool showingEndOfBookPrompt = false;
|
||||
int endOfBookSelection = 2; // 0=Archive, 1=Delete, 2=Keep
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void renderScreen();
|
||||
void renderPage();
|
||||
void saveProgress() const;
|
||||
void loadProgress();
|
||||
void renderEndOfBookPrompt();
|
||||
void handleEndOfBookAction();
|
||||
|
||||
public:
|
||||
explicit XtcReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Xtc> xtc,
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "BookManager.h"
|
||||
#include "html/FilesPageHtml.generated.h"
|
||||
#include "html/HomePageHtml.generated.h"
|
||||
#include "util/StringUtils.h"
|
||||
@ -106,6 +107,11 @@ void CrossPointWebServer::begin() {
|
||||
// Delete file/folder endpoint
|
||||
server->on("/delete", HTTP_POST, [this] { handleDelete(); });
|
||||
|
||||
// Archive/Unarchive endpoints
|
||||
server->on("/archive", HTTP_POST, [this] { handleArchive(); });
|
||||
server->on("/unarchive", HTTP_POST, [this] { handleUnarchive(); });
|
||||
server->on("/api/archived", HTTP_GET, [this] { handleArchivedList(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
@ -602,6 +608,7 @@ void CrossPointWebServer::handleDelete() const {
|
||||
|
||||
String itemPath = server->arg("path");
|
||||
const String itemType = server->hasArg("type") ? server->arg("type") : "file";
|
||||
const bool isArchived = server->hasArg("archived") && server->arg("archived") == "true";
|
||||
|
||||
// Validate path
|
||||
if (itemPath.isEmpty() || itemPath == "/") {
|
||||
@ -617,8 +624,8 @@ void CrossPointWebServer::handleDelete() const {
|
||||
// Security check: prevent deletion of protected items
|
||||
const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
|
||||
|
||||
// Check if item starts with a dot (hidden/system file)
|
||||
if (itemName.startsWith(".")) {
|
||||
// Check if item starts with a dot (hidden/system file) - but allow archived items
|
||||
if (itemName.startsWith(".") && !isArchived) {
|
||||
Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), itemPath.c_str());
|
||||
server->send(403, "text/plain", "Cannot delete system files");
|
||||
return;
|
||||
@ -633,18 +640,19 @@ void CrossPointWebServer::handleDelete() const {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if item exists
|
||||
if (!SdMan.exists(itemPath.c_str())) {
|
||||
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
|
||||
server->send(404, "text/plain", "Item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [WEB] Attempting to delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str());
|
||||
Serial.printf("[%lu] [WEB] Attempting to delete %s (archived=%d): %s\n", millis(), itemType.c_str(), isArchived,
|
||||
itemPath.c_str());
|
||||
|
||||
bool success = false;
|
||||
|
||||
if (itemType == "folder") {
|
||||
// Check if item exists
|
||||
if (!SdMan.exists(itemPath.c_str())) {
|
||||
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
|
||||
server->send(404, "text/plain", "Item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// For folders, try to remove (will fail if not empty)
|
||||
FsFile dir = SdMan.open(itemPath.c_str());
|
||||
if (dir && dir.isDirectory()) {
|
||||
@ -662,8 +670,13 @@ void CrossPointWebServer::handleDelete() const {
|
||||
}
|
||||
success = SdMan.rmdir(itemPath.c_str());
|
||||
} else {
|
||||
// For files, use remove
|
||||
success = SdMan.remove(itemPath.c_str());
|
||||
// For files, use BookManager to also clean up cache and recent books
|
||||
if (isArchived) {
|
||||
// For archived books, just pass the filename
|
||||
success = BookManager::deleteBook(itemName.c_str(), true);
|
||||
} else {
|
||||
success = BookManager::deleteBook(itemPath.c_str(), false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
@ -675,6 +688,90 @@ void CrossPointWebServer::handleDelete() const {
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleArchive() const {
|
||||
if (!server->hasArg("path")) {
|
||||
server->send(400, "text/plain", "Missing path");
|
||||
return;
|
||||
}
|
||||
|
||||
String bookPath = server->arg("path");
|
||||
|
||||
// Validate path
|
||||
if (bookPath.isEmpty() || bookPath == "/") {
|
||||
server->send(400, "text/plain", "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
if (!bookPath.startsWith("/")) {
|
||||
bookPath = "/" + bookPath;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [WEB] Archiving book: %s\n", millis(), bookPath.c_str());
|
||||
|
||||
if (BookManager::archiveBook(bookPath.c_str())) {
|
||||
server->send(200, "text/plain", "Book archived successfully");
|
||||
} else {
|
||||
server->send(500, "text/plain", "Failed to archive book");
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleUnarchive() const {
|
||||
if (!server->hasArg("filename")) {
|
||||
server->send(400, "text/plain", "Missing filename");
|
||||
return;
|
||||
}
|
||||
|
||||
const String filename = server->arg("filename");
|
||||
|
||||
if (filename.isEmpty()) {
|
||||
server->send(400, "text/plain", "Invalid filename");
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [WEB] Unarchiving book: %s\n", millis(), filename.c_str());
|
||||
|
||||
// Get the original path before unarchiving (for response)
|
||||
const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename.c_str());
|
||||
|
||||
if (BookManager::unarchiveBook(filename.c_str())) {
|
||||
// Return JSON with the original path
|
||||
String response = "{\"success\":true,\"originalPath\":\"";
|
||||
response += originalPath.c_str();
|
||||
response += "\"}";
|
||||
server->send(200, "application/json", response);
|
||||
} else {
|
||||
server->send(500, "text/plain", "Failed to unarchive book");
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleArchivedList() const {
|
||||
Serial.printf("[%lu] [WEB] Fetching archived books list\n", millis());
|
||||
|
||||
const auto archivedBooks = BookManager::listArchivedBooks();
|
||||
|
||||
// Build JSON response
|
||||
String response = "[";
|
||||
bool first = true;
|
||||
for (const auto& filename : archivedBooks) {
|
||||
if (!first) {
|
||||
response += ",";
|
||||
}
|
||||
first = false;
|
||||
|
||||
const std::string originalPath = BookManager::getArchivedBookOriginalPath(filename);
|
||||
|
||||
response += "{\"filename\":\"";
|
||||
response += filename.c_str();
|
||||
response += "\",\"originalPath\":\"";
|
||||
response += originalPath.c_str();
|
||||
response += "\"}";
|
||||
}
|
||||
response += "]";
|
||||
|
||||
server->send(200, "application/json", response);
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
|
||||
@ -60,4 +60,7 @@ class CrossPointWebServer {
|
||||
void handleUploadPost() const;
|
||||
void handleCreateFolder() const;
|
||||
void handleDelete() const;
|
||||
void handleArchive() const;
|
||||
void handleUnarchive() const;
|
||||
void handleArchivedList() const;
|
||||
};
|
||||
|
||||
@ -322,8 +322,8 @@
|
||||
.folder-btn:hover {
|
||||
background-color: #d68910;
|
||||
}
|
||||
/* Delete button styles */
|
||||
.delete-btn {
|
||||
/* Action button styles */
|
||||
.delete-btn, .archive-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@ -337,10 +337,75 @@
|
||||
background-color: #fee;
|
||||
color: #e74c3c;
|
||||
}
|
||||
.archive-btn:hover {
|
||||
background-color: #e8f4fd;
|
||||
color: #3498db;
|
||||
}
|
||||
.actions-col {
|
||||
width: 60px;
|
||||
width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Archived files button */
|
||||
.archived-action-btn {
|
||||
background-color: #9b59b6;
|
||||
}
|
||||
.archived-action-btn:hover {
|
||||
background-color: #8e44ad;
|
||||
}
|
||||
/* Archive modal styles */
|
||||
.archive-warning {
|
||||
color: #3498db;
|
||||
font-weight: 600;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.archive-btn-confirm {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
.archive-btn-confirm:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
/* Archived files list */
|
||||
.archived-file-row {
|
||||
background-color: #f3e5f5 !important;
|
||||
}
|
||||
.archived-file-row:hover {
|
||||
background-color: #e1bee7 !important;
|
||||
}
|
||||
.original-path {
|
||||
font-size: 0.8em;
|
||||
color: #7f8c8d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.restore-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #95a5a6;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.restore-btn:hover {
|
||||
background-color: #e8f6e9;
|
||||
color: #27ae60;
|
||||
}
|
||||
.archive-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
background-color: #9b59b6;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
margin-left: 10px;
|
||||
}
|
||||
/* Failed uploads banner */
|
||||
.failed-uploads-banner {
|
||||
background-color: #fff3cd;
|
||||
@ -586,6 +651,7 @@
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
|
||||
<button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
|
||||
<button class="action-btn archived-action-btn" onclick="openArchivedModal()">📦 Archived <span id="archivedCount"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -659,12 +725,58 @@
|
||||
<p class="delete-item-name" id="deleteItemName"></p>
|
||||
<input type="hidden" id="deleteItemPath">
|
||||
<input type="hidden" id="deleteItemType">
|
||||
<input type="hidden" id="deleteItemArchived" value="false">
|
||||
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
|
||||
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive Confirmation Modal -->
|
||||
<div class="modal-overlay" id="archiveModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeArchiveModal()">×</button>
|
||||
<h3>📦 Archive Book</h3>
|
||||
<div class="folder-form">
|
||||
<p class="archive-warning">📦 Book will be moved to archive</p>
|
||||
<p class="file-info">Reading progress will be saved. You can restore it later.</p>
|
||||
<p class="delete-item-name" id="archiveItemName"></p>
|
||||
<input type="hidden" id="archiveItemPath">
|
||||
<button class="archive-btn-confirm" onclick="confirmArchive()">Archive</button>
|
||||
<button class="delete-btn-cancel" onclick="closeArchiveModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archived Files Modal -->
|
||||
<div class="modal-overlay" id="archivedModal">
|
||||
<div class="modal" style="max-width: 600px;">
|
||||
<button class="modal-close" onclick="closeArchivedModal()">×</button>
|
||||
<h3>📦 Archived Books</h3>
|
||||
<div id="archivedFilesList">
|
||||
<div class="loader-container">
|
||||
<span class="loader"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Confirmation Modal -->
|
||||
<div class="modal-overlay" id="restoreModal">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeRestoreModal()">×</button>
|
||||
<h3>📤 Restore Book</h3>
|
||||
<div class="folder-form">
|
||||
<p class="file-info">Restore book to its original location:</p>
|
||||
<p class="delete-item-name" id="restoreItemName"></p>
|
||||
<p class="original-path" id="restoreOriginalPath"></p>
|
||||
<input type="hidden" id="restoreItemFilename">
|
||||
<button class="archive-btn-confirm" onclick="confirmRestore()">Restore</button>
|
||||
<button class="delete-btn-cancel" onclick="closeRestoreModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// get current path from query parameter
|
||||
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
|
||||
@ -750,6 +862,12 @@
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Check if file is a book format (can be archived)
|
||||
function isBookFormat(filename) {
|
||||
const ext = filename.toLowerCase();
|
||||
return ext.endsWith('.epub') || ext.endsWith('.txt') || ext.endsWith('.xtc') || ext.endsWith('.xtch');
|
||||
}
|
||||
|
||||
sortedFiles.forEach(file => {
|
||||
if (file.isDirectory) {
|
||||
let folderPath = currentPath;
|
||||
@ -773,7 +891,12 @@
|
||||
fileTableContent += '</td>';
|
||||
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
|
||||
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
|
||||
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button></td>`;
|
||||
fileTableContent += '<td class="actions-col">';
|
||||
if (isBookFormat(file.name)) {
|
||||
fileTableContent += `<button class="archive-btn" onclick="openArchiveModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}')" title="Archive book">📦</button>`;
|
||||
}
|
||||
fileTableContent += `<button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button>`;
|
||||
fileTableContent += '</td>';
|
||||
fileTableContent += '</tr>';
|
||||
}
|
||||
});
|
||||
@ -1176,10 +1299,11 @@ function retryAllFailedUploads() {
|
||||
}
|
||||
|
||||
// Delete functions
|
||||
function openDeleteModal(name, path, isFolder) {
|
||||
function openDeleteModal(name, path, isFolder, isArchived = false) {
|
||||
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
|
||||
document.getElementById('deleteItemPath').value = path;
|
||||
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
|
||||
document.getElementById('deleteItemArchived').value = isArchived ? 'true' : 'false';
|
||||
document.getElementById('deleteModal').classList.add('open');
|
||||
}
|
||||
|
||||
@ -1190,17 +1314,26 @@ function retryAllFailedUploads() {
|
||||
function confirmDelete() {
|
||||
const path = document.getElementById('deleteItemPath').value;
|
||||
const itemType = document.getElementById('deleteItemType').value;
|
||||
const isArchived = document.getElementById('deleteItemArchived').value === 'true';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', path);
|
||||
formData.append('type', itemType);
|
||||
if (isArchived) {
|
||||
formData.append('archived', 'true');
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/delete', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
if (isArchived) {
|
||||
closeDeleteModal();
|
||||
loadArchivedFiles();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
alert('Failed to delete: ' + xhr.responseText);
|
||||
closeDeleteModal();
|
||||
@ -1215,7 +1348,166 @@ function retryAllFailedUploads() {
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Archive functions
|
||||
function openArchiveModal(name, path) {
|
||||
document.getElementById('archiveItemName').textContent = '📄 ' + name;
|
||||
document.getElementById('archiveItemPath').value = path;
|
||||
document.getElementById('archiveModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeArchiveModal() {
|
||||
document.getElementById('archiveModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmArchive() {
|
||||
const path = document.getElementById('archiveItemPath').value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('path', path);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/archive', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to archive: ' + xhr.responseText);
|
||||
closeArchiveModal();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to archive - network error');
|
||||
closeArchiveModal();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Archived files modal
|
||||
function openArchivedModal() {
|
||||
document.getElementById('archivedModal').classList.add('open');
|
||||
loadArchivedFiles();
|
||||
}
|
||||
|
||||
function closeArchivedModal() {
|
||||
document.getElementById('archivedModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function loadArchivedFiles() {
|
||||
const container = document.getElementById('archivedFilesList');
|
||||
container.innerHTML = '<div class="loader-container"><span class="loader"></span></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/archived');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load archived files');
|
||||
}
|
||||
const archivedFiles = await response.json();
|
||||
|
||||
// Update the badge count
|
||||
const countSpan = document.getElementById('archivedCount');
|
||||
if (archivedFiles.length > 0) {
|
||||
countSpan.textContent = '(' + archivedFiles.length + ')';
|
||||
} else {
|
||||
countSpan.textContent = '';
|
||||
}
|
||||
|
||||
if (archivedFiles.length === 0) {
|
||||
container.innerHTML = '<div class="no-files">No archived books</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="file-table">';
|
||||
html += '<tr><th>Book</th><th class="actions-col">Actions</th></tr>';
|
||||
|
||||
archivedFiles.forEach(file => {
|
||||
html += '<tr class="archived-file-row">';
|
||||
html += '<td>';
|
||||
html += '<span class="file-icon">📄</span>' + escapeHtml(file.filename);
|
||||
html += '<div class="original-path">Original: ' + escapeHtml(file.originalPath) + '</div>';
|
||||
html += '</td>';
|
||||
html += '<td class="actions-col">';
|
||||
html += `<button class="restore-btn" onclick="openRestoreModal('${file.filename.replaceAll("'", "\\'")}', '${file.originalPath.replaceAll("'", "\\'")}')" title="Restore book">📤</button>`;
|
||||
html += `<button class="delete-btn" onclick="openDeleteModal('${file.filename.replaceAll("'", "\\'")}', '${file.filename.replaceAll("'", "\\'")}', false, true)" title="Delete permanently">🗑️</button>`;
|
||||
html += '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
container.innerHTML = '<div class="no-files">Failed to load archived files</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Restore functions
|
||||
function openRestoreModal(filename, originalPath) {
|
||||
document.getElementById('restoreItemName').textContent = '📄 ' + filename;
|
||||
document.getElementById('restoreOriginalPath').textContent = '→ ' + originalPath;
|
||||
document.getElementById('restoreItemFilename').value = filename;
|
||||
document.getElementById('restoreModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeRestoreModal() {
|
||||
document.getElementById('restoreModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function confirmRestore() {
|
||||
const filename = document.getElementById('restoreItemFilename').value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('filename', filename);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/unarchive', true);
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
alert('Book restored to: ' + result.originalPath);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
closeRestoreModal();
|
||||
loadArchivedFiles();
|
||||
// Also refresh the main file list in case we're viewing that folder
|
||||
hydrate();
|
||||
} else {
|
||||
alert('Failed to restore: ' + xhr.responseText);
|
||||
closeRestoreModal();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
alert('Failed to restore - network error');
|
||||
closeRestoreModal();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
// Load archived count on page load
|
||||
async function loadArchivedCount() {
|
||||
try {
|
||||
const response = await fetch('/api/archived');
|
||||
if (response.ok) {
|
||||
const archivedFiles = await response.json();
|
||||
const countSpan = document.getElementById('archivedCount');
|
||||
if (archivedFiles.length > 0) {
|
||||
countSpan.textContent = '(' + archivedFiles.length + ')';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
hydrate();
|
||||
loadArchivedCount();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user