13 Commits

Author SHA1 Message Date
cottongin
0e2440aea8 fix: resolve end-of-book deadlock, long-press guards, archive UX, and home screen refresh
- Fix device freeze at end-of-book by deferring EndOfBookMenuActivity
  creation from render() to loop() (avoids RenderLock deadlock) in
  EpubReaderActivity and XtcReaderActivity
- Add initialSkipRelease to BookManageMenuActivity to prevent stale
  Confirm release from triggering actions when opened via long-press
- Add initialSkipRelease to MyLibraryActivity for long-press Browse
  Files -> archive navigation
- Thread skip-release through HomeActivity callback and main.cpp
- Fix HomeActivity stale cover buffer after archive/delete by fully
  resetting render state (freeCoverBuffer, firstRenderDone, etc.)
- Swap short/long-press actions in .archive context: short-press opens
  manage menu, long-press unarchives and opens the book
- Add deferred open pattern (pendingOpenPath) to wait for Confirm
  release before navigating to reader after unarchive
- Add BookManager::cleanupEmptyArchiveDirs() to remove empty parent
  directories after unarchive/delete inside .archive
- Add optional unarchivedPath output parameter to BookManager::unarchiveBook
- Restyle EndOfBookMenuActivity to standard list layout with proper
  header, margins, and button hints matching other screens
- Change EndOfBookMenuActivity back button hint to "« Back"
- Add Table of Contents option to EndOfBookMenuActivity

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 07:37:36 -05:00
cottongin
39ef1e6d78 fix: remove invalid RenderLock::unlock() call in end-of-book handler
RenderLock is a pure RAII wrapper with no unlock() method. The lock
releases naturally when render() returns. Build now succeeds cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 03:16:24 -05:00
cottongin
3cc127d658 fix: ClearCacheActivity now clears txt_* caches and recents list
Add txt_* prefix to the directory check so TXT book caches are also
removed. After clearing all cache directories, call RECENT_BOOKS.clear()
to remove stale entries that would show missing covers on the home screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 03:05:19 -05:00
cottongin
98146f2545 feat: add EndOfBookMenuActivity replacing static end-of-book text
Interactive menu shown when reaching the end of a book with options:
Archive Book, Delete Book, Back to Beginning, Close Book, Close Menu.
Wired into EpubReaderActivity, XtcReaderActivity, and TxtReaderActivity
(TXT shows menu when user tries to advance past the last page).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 03:04:48 -05:00
cottongin
f5b708424d feat: replace Delete Book Cache with Manage Book in reader menu
EpubReaderMenuActivity now shows "Manage Book" instead of "Delete
Book Cache". Selecting it opens BookManageMenuActivity as a sub-activity
with Archive, Delete, Delete Cache, and Reindex options. New menu
actions (ARCHIVE_BOOK, DELETE_BOOK, REINDEX_BOOK, REINDEX_BOOK_FULL)
are forwarded to EpubReaderActivity and handled via BookManager.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 03:02:30 -05:00
cottongin
1c19899aa3 feat: add long-press on HomeActivity for book management and archive browsing
Long-press Confirm on a recent book opens the BookManageMenuActivity.
Long-press Confirm on Browse Files navigates directly to /.archive/.
Wires onMyLibraryOpenWithPath callback through main.cpp to HomeActivity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 02:59:35 -05:00
cottongin
390f10f30d feat: add long-press Confirm for book management in file browser and recents
Long-pressing Confirm on a book file in MyLibraryActivity or
RecentBooksActivity opens the BookManageMenuActivity popup with
Archive/Delete/Delete Cache/Reindex options. Actions are executed
via BookManager and the file list is refreshed afterward.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 02:57:19 -05:00
cottongin
49471e36f1 refactor: change browse activities to ActivityWithSubactivity
Change HomeActivity, MyLibraryActivity, and RecentBooksActivity base
class from Activity to ActivityWithSubactivity. Adds subActivity
guard at top of each loop(). No new behavior, just enabling sub-activity
hosting for the upcoming BookManageMenuActivity integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 02:55:29 -05:00
cottongin
c44ac0272a feat: add BookManageMenuActivity popup sub-activity
Contextual popup menu for book management with Archive/Unarchive,
Delete, Delete Cache Only, and Reindex options. Supports long-press
on Reindex to trigger full reindex including cover/thumbnail regen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 02:54:14 -05:00
cottongin
29954a3683 feat: add BookManager utility and RecentBooksStore::clear()
BookManager provides static functions for archive/unarchive/delete/
deleteCache/reindex operations on books, centralizing cache path
computation and file operations. Archive preserves directory structure
under /.archive/ and renames cache dirs to match new path hashes.

RecentBooksStore: :clear() added for bulk cache clearing use case.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 02:52:38 -05:00
cottongin
3eddb07a1a feat(i18n): add string keys for book management feature
Add STR_MANAGE_BOOK, STR_ARCHIVE_BOOK, STR_UNARCHIVE_BOOK,
STR_DELETE_BOOK, STR_DELETE_CACHE_ONLY, STR_REINDEX_BOOK,
STR_BROWSE_ARCHIVE, status messages, STR_BACK_TO_BEGINNING,
and STR_CLOSE_MENU for the manage books and end-of-book menus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 02:51:14 -05:00
cottongin
f443f5dde0 feat(hal): expose rename() on HalStorage
Forward SDCardManager::rename() through the HAL layer for
file/directory move operations needed by book archiving.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 02:50:27 -05:00
cottongin
3d51dfeeb7 feat: Add NTP clock sync to Clock settings
Adds a "Sync Clock" action in Settings > Clock that connects to WiFi
(auto-connecting to saved networks or prompting for selection) and
performs a blocking NTP time sync. Shows the synced time on success
with an auto-dismiss countdown, or an error on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 02:19:38 -05:00
40 changed files with 1405 additions and 39 deletions

View File

@@ -398,6 +398,23 @@ enum class StrId : uint16_t {
STR_INDEXING_POPUP,
STR_INDEXING_STATUS_TEXT,
STR_INDEXING_STATUS_ICON,
STR_SYNC_CLOCK,
STR_TIME_SYNCED,
STR_MANAGE_BOOK,
STR_ARCHIVE_BOOK,
STR_UNARCHIVE_BOOK,
STR_DELETE_BOOK,
STR_DELETE_CACHE_ONLY,
STR_REINDEX_BOOK,
STR_BROWSE_ARCHIVE,
STR_BOOK_ARCHIVED,
STR_BOOK_UNARCHIVED,
STR_BOOK_DELETED,
STR_CACHE_DELETED,
STR_BOOK_REINDEXED,
STR_ACTION_FAILED,
STR_BACK_TO_BEGINNING,
STR_CLOSE_MENU,
// Sentinel - must be last
_COUNT
};

View File

@@ -341,3 +341,5 @@ STR_INDEXING_DISPLAY: "Zobrazení indexování"
STR_INDEXING_POPUP: "Popup"
STR_INDEXING_STATUS_TEXT: "Text stavového řádku"
STR_INDEXING_STATUS_ICON: "Ikona stavového řádku"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"

View File

@@ -362,3 +362,20 @@ STR_INDEXING_DISPLAY: "Indexing Display"
STR_INDEXING_POPUP: "Popup"
STR_INDEXING_STATUS_TEXT: "Status Bar Text"
STR_INDEXING_STATUS_ICON: "Status Bar Icon"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"
STR_MANAGE_BOOK: "Manage Book"
STR_ARCHIVE_BOOK: "Archive Book"
STR_UNARCHIVE_BOOK: "Unarchive Book"
STR_DELETE_BOOK: "Delete Book"
STR_DELETE_CACHE_ONLY: "Delete Cache Only"
STR_REINDEX_BOOK: "Reindex Book"
STR_BROWSE_ARCHIVE: "Browse Archive"
STR_BOOK_ARCHIVED: "Book archived"
STR_BOOK_UNARCHIVED: "Book unarchived"
STR_BOOK_DELETED: "Book deleted"
STR_CACHE_DELETED: "Cache deleted"
STR_BOOK_REINDEXED: "Book reindexed"
STR_ACTION_FAILED: "Action failed"
STR_BACK_TO_BEGINNING: "Back to Beginning"
STR_CLOSE_MENU: "Close Menu"

View File

@@ -341,3 +341,5 @@ STR_INDEXING_DISPLAY: "Affichage indexation"
STR_INDEXING_POPUP: "Popup"
STR_INDEXING_STATUS_TEXT: "Texte barre d'état"
STR_INDEXING_STATUS_ICON: "Icône barre d'état"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"

View File

@@ -341,3 +341,5 @@ STR_INDEXING_DISPLAY: "Indexierungsanzeige"
STR_INDEXING_POPUP: "Popup"
STR_INDEXING_STATUS_TEXT: "Statusleistentext"
STR_INDEXING_STATUS_ICON: "Statusleistensymbol"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"

View File

@@ -341,3 +341,5 @@ STR_INDEXING_DISPLAY: "Exibição de indexação"
STR_INDEXING_POPUP: "Popup"
STR_INDEXING_STATUS_TEXT: "Texto da barra"
STR_INDEXING_STATUS_ICON: "Ícone da barra"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"

View File

@@ -316,3 +316,5 @@ STR_UPLOAD: "Încărcare"
STR_BOOK_S_STYLE: "Stilul cărţii"
STR_EMBEDDED_STYLE: "Stil încorporat"
STR_OPDS_SERVER_URL: "URL server OPDS"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"

View File

@@ -341,3 +341,5 @@ STR_INDEXING_DISPLAY: "Отображение индексации"
STR_INDEXING_POPUP: "Popup"
STR_INDEXING_STATUS_TEXT: "Текст в строке"
STR_INDEXING_STATUS_ICON: "Иконка в строке"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"

View File

@@ -341,3 +341,5 @@ STR_INDEXING_DISPLAY: "Mostrar indexación"
STR_INDEXING_POPUP: "Popup"
STR_INDEXING_STATUS_TEXT: "Texto barra estado"
STR_INDEXING_STATUS_ICON: "Icono barra estado"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"

View File

@@ -341,3 +341,5 @@ STR_INDEXING_DISPLAY: "Indexeringsvisning"
STR_INDEXING_POPUP: "Popup"
STR_INDEXING_STATUS_TEXT: "Statusfältstext"
STR_INDEXING_STATUS_ICON: "Statusfältsikon"
STR_SYNC_CLOCK: "Sync Clock"
STR_TIME_SYNCED: "Time synced!"

View File

@@ -38,6 +38,8 @@ bool HalStorage::remove(const char* path) { return SDCard.remove(path); }
bool HalStorage::rmdir(const char* path) { return SDCard.rmdir(path); }
bool HalStorage::rename(const char* path, const char* newPath) { return SDCard.rename(path, newPath); }
bool HalStorage::openFileForRead(const char* moduleName, const char* path, FsFile& file) {
return SDCard.openFileForRead(moduleName, path, file);
}

View File

@@ -29,6 +29,7 @@ class HalStorage {
bool exists(const char* path);
bool remove(const char* path);
bool rmdir(const char* path);
bool rename(const char* path, const char* newPath);
bool openFileForRead(const char* moduleName, const char* path, FsFile& file);
bool openFileForRead(const char* moduleName, const std::string& path, FsFile& file);

View File

@@ -47,6 +47,11 @@ void RecentBooksStore::removeBook(const std::string& path) {
}
}
void RecentBooksStore::clear() {
recentBooks.clear();
saveToFile();
}
void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
auto it =

View File

@@ -33,6 +33,9 @@ class RecentBooksStore {
// Remove a book from the recent list by path
void removeBook(const std::string& path);
// Clear all recent books
void clear();
// Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; }

View File

@@ -0,0 +1,115 @@
#include "BookManageMenuActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void BookManageMenuActivity::buildMenuItems() {
menuItems.clear();
if (archived) {
menuItems.push_back({Action::UNARCHIVE, StrId::STR_UNARCHIVE_BOOK});
} else {
menuItems.push_back({Action::ARCHIVE, StrId::STR_ARCHIVE_BOOK});
}
menuItems.push_back({Action::DELETE, StrId::STR_DELETE_BOOK});
menuItems.push_back({Action::DELETE_CACHE, StrId::STR_DELETE_CACHE_ONLY});
menuItems.push_back({Action::REINDEX, StrId::STR_REINDEX_BOOK});
}
void BookManageMenuActivity::onEnter() {
Activity::onEnter();
selectedIndex = 0;
requestUpdate();
}
void BookManageMenuActivity::onExit() { Activity::onExit(); }
void BookManageMenuActivity::loop() {
// Long-press detection: REINDEX_FULL when long-pressing on the Reindex item
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS) {
if (!ignoreNextConfirmRelease && selectedIndex < static_cast<int>(menuItems.size()) &&
menuItems[selectedIndex].action == Action::REINDEX) {
ignoreNextConfirmRelease = true;
auto cb = onAction;
cb(Action::REINDEX_FULL);
return;
}
}
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
if (selectedIndex < static_cast<int>(menuItems.size())) {
auto cb = onAction;
cb(menuItems[selectedIndex].action);
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
auto cb = onCancel;
cb();
return;
}
}
void BookManageMenuActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
constexpr int popupMargin = 20;
constexpr int lineHeight = 30;
constexpr int titleHeight = 40;
const int optionCount = static_cast<int>(menuItems.size());
const int popupH = titleHeight + popupMargin + lineHeight * optionCount + popupMargin;
const int popupW = pageWidth - 60;
const int popupX = (pageWidth - popupW) / 2;
const int popupY = (pageHeight - popupH) / 2;
// Popup border and background
renderer.fillRect(popupX - 2, popupY - 2, popupW + 4, popupH + 4, true);
renderer.fillRect(popupX, popupY, popupW, popupH, false);
// Title
renderer.drawText(UI_12_FONT_ID, popupX + popupMargin, popupY + 8, tr(STR_MANAGE_BOOK), true, EpdFontFamily::BOLD);
// Divider line
const int dividerY = popupY + titleHeight;
renderer.fillRect(popupX + 4, dividerY, popupW - 8, 1, true);
// Menu items
const int startY = dividerY + popupMargin / 2;
for (int i = 0; i < optionCount; ++i) {
const int itemY = startY + i * lineHeight;
const bool isSelected = (i == selectedIndex);
if (isSelected) {
renderer.fillRect(popupX + 2, itemY, popupW - 4, lineHeight, true);
}
renderer.drawText(UI_10_FONT_ID, popupX + popupMargin, itemY, I18N.get(menuItems[i].labelId), !isSelected);
}
// Button hints
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,61 @@
#pragma once
#include <I18n.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
class BookManageMenuActivity final : public Activity {
public:
enum class Action {
ARCHIVE,
UNARCHIVE,
DELETE,
DELETE_CACHE,
REINDEX,
REINDEX_FULL,
};
explicit BookManageMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& bookPath, bool isArchived,
const std::function<void(Action)>& onAction,
const std::function<void()>& onCancel,
bool initialSkipRelease = false)
: Activity("BookManageMenu", renderer, mappedInput),
bookPath(bookPath),
archived(isArchived),
ignoreNextConfirmRelease(initialSkipRelease),
onAction(onAction),
onCancel(onCancel) {
buildMenuItems();
}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
struct MenuItem {
Action action;
StrId labelId;
};
std::string bookPath;
bool archived;
std::vector<MenuItem> menuItems;
int selectedIndex = 0;
ButtonNavigator buttonNavigator;
bool ignoreNextConfirmRelease;
static constexpr unsigned long LONG_PRESS_MS = 700;
const std::function<void(Action)> onAction;
const std::function<void()> onCancel;
void buildMenuItems();
};

View File

@@ -13,12 +13,14 @@
#include <cstring>
#include <vector>
#include "BookManageMenuActivity.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
#include "util/StringUtils.h"
int HomeActivity::getMenuItemCount() const {
@@ -124,7 +126,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
}
void HomeActivity::onEnter() {
Activity::onEnter();
ActivityWithSubactivity::onEnter();
// Check if OPDS browser URL is configured
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
@@ -139,7 +141,7 @@ void HomeActivity::onEnter() {
}
void HomeActivity::onExit() {
Activity::onExit();
ActivityWithSubactivity::onExit();
// Free the stored cover buffer if any
freeCoverBuffer();
@@ -188,6 +190,11 @@ void HomeActivity::freeCoverBuffer() {
}
void HomeActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
const int menuCount = getMenuItemCount();
buttonNavigator.onNext([this, menuCount] {
@@ -200,7 +207,32 @@ void HomeActivity::loop() {
requestUpdate();
});
// Long-press Confirm: manage menu for recent books, or browse archive for Browse Files
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS &&
!ignoreNextConfirmRelease) {
if (selectorIndex < static_cast<int>(recentBooks.size())) {
// Long-press on a recent book → manage menu
ignoreNextConfirmRelease = true;
openManageMenu(recentBooks[selectorIndex].path);
return;
}
// Check if Browse Files is selected
const int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
if (menuSelectedIndex == 0) {
// Long-press on Browse Files → go to archive folder
ignoreNextConfirmRelease = true;
onMyLibraryOpenWithPath("/.archive", true);
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
// Calculate dynamic indices based on which options are available
int idx = 0;
int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
@@ -210,7 +242,7 @@ void HomeActivity::loop() {
const int fileTransferIdx = idx++;
const int settingsIdx = idx;
if (selectorIndex < recentBooks.size()) {
if (selectorIndex < static_cast<int>(recentBooks.size())) {
onSelectBook(recentBooks[selectorIndex].path);
} else if (menuSelectedIndex == myLibraryIdx) {
onMyLibraryOpen();
@@ -273,3 +305,53 @@ void HomeActivity::render(Activity::RenderLock&&) {
loadRecentCovers(metrics.homeCoverHeight);
}
}
void HomeActivity::openManageMenu(const std::string& bookPath) {
const bool isArchived = BookManager::isArchived(bookPath);
const std::string capturedPath = bookPath;
enterNewActivity(new BookManageMenuActivity(
renderer, mappedInput, capturedPath, isArchived,
[this, capturedPath](BookManageMenuActivity::Action action) {
exitActivity();
bool success = false;
switch (action) {
case BookManageMenuActivity::Action::ARCHIVE:
success = BookManager::archiveBook(capturedPath);
break;
case BookManageMenuActivity::Action::UNARCHIVE:
success = BookManager::unarchiveBook(capturedPath);
break;
case BookManageMenuActivity::Action::DELETE:
success = BookManager::deleteBook(capturedPath);
break;
case BookManageMenuActivity::Action::DELETE_CACHE:
success = BookManager::deleteBookCache(capturedPath);
break;
case BookManageMenuActivity::Action::REINDEX:
success = BookManager::reindexBook(capturedPath, false);
break;
case BookManageMenuActivity::Action::REINDEX_FULL:
success = BookManager::reindexBook(capturedPath, true);
break;
}
{
RenderLock lock(*this);
GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED));
}
requestUpdateAndWait();
// Fully reset recent books state so the home screen reloads cleanly
recentBooks.clear();
recentsLoaded = false;
recentsLoading = false;
coverRendered = false;
freeCoverBuffer();
selectorIndex = 0;
firstRenderDone = false;
requestUpdate();
},
[this] {
exitActivity();
requestUpdate();
},
true));
}

View File

@@ -2,14 +2,14 @@
#include <functional>
#include <vector>
#include "../Activity.h"
#include "../ActivityWithSubactivity.h"
#include "./MyLibraryActivity.h"
#include "util/ButtonNavigator.h"
struct RecentBook;
struct Rect;
class HomeActivity final : public Activity {
class HomeActivity final : public ActivityWithSubactivity {
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
bool recentsLoading = false;
@@ -20,8 +20,14 @@ class HomeActivity final : public Activity {
bool coverBufferStored = false; // Track if cover buffer is stored
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
std::vector<RecentBook> recentBooks;
// Long-press state
bool ignoreNextConfirmRelease = false;
static constexpr unsigned long LONG_PRESS_MS = 700;
const std::function<void(const std::string& path)> onSelectBook;
const std::function<void()> onMyLibraryOpen;
const std::function<void(const std::string& path, bool initialSkipRelease)> onMyLibraryOpenWithPath;
const std::function<void()> onRecentsOpen;
const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen;
@@ -33,16 +39,20 @@ class HomeActivity final : public Activity {
void freeCoverBuffer(); // Free the stored cover buffer
void loadRecentBooks(int maxBooks);
void loadRecentCovers(int coverHeight);
void openManageMenu(const std::string& bookPath);
public:
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(const std::string& path)>& onSelectBook,
const std::function<void()>& onMyLibraryOpen, const std::function<void()>& onRecentsOpen,
const std::function<void()>& onMyLibraryOpen,
const std::function<void(const std::string& path, bool initialSkipRelease)>& onMyLibraryOpenWithPath,
const std::function<void()>& onRecentsOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
const std::function<void()>& onOpdsBrowserOpen)
: Activity("Home", renderer, mappedInput),
: ActivityWithSubactivity("Home", renderer, mappedInput),
onSelectBook(onSelectBook),
onMyLibraryOpen(onMyLibraryOpen),
onMyLibraryOpenWithPath(onMyLibraryOpenWithPath),
onRecentsOpen(onRecentsOpen),
onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen),

View File

@@ -6,9 +6,11 @@
#include <algorithm>
#include "BookManageMenuActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
#include "util/StringUtils.h"
namespace {
@@ -103,7 +105,7 @@ void MyLibraryActivity::loadFiles() {
}
void MyLibraryActivity::onEnter() {
Activity::onEnter();
ActivityWithSubactivity::onEnter();
loadFiles();
selectorIndex = 0;
@@ -112,11 +114,26 @@ void MyLibraryActivity::onEnter() {
}
void MyLibraryActivity::onExit() {
Activity::onExit();
ActivityWithSubactivity::onExit();
files.clear();
}
void MyLibraryActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Deferred open: wait for Confirm release before navigating to avoid stale event in reader
if (!pendingOpenPath.empty()) {
if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
std::string path = std::move(pendingOpenPath);
pendingOpenPath.clear();
onSelectBook(path);
}
return;
}
// Long press BACK (1s+) goes to root folder
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS &&
basepath != "/") {
@@ -128,7 +145,28 @@ void MyLibraryActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
// In archive context: long-press = unarchive+open, short-press = manage menu
// Outside archive: long-press = manage menu, short-press = open
const bool inArchive = isInArchive();
const bool isBookFile = !files.empty() && selectorIndex < files.size() && files[selectorIndex].back() != '/';
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS &&
!ignoreNextConfirmRelease && isBookFile) {
ignoreNextConfirmRelease = true;
const std::string fullPath = (basepath.back() == '/' ? basepath : basepath + "/") + files[selectorIndex];
if (inArchive) {
unarchiveAndOpen(fullPath);
} else {
openManageMenu(fullPath);
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
if (files.empty()) {
return;
}
@@ -139,6 +177,9 @@ void MyLibraryActivity::loop() {
loadFiles();
selectorIndex = 0;
requestUpdate();
} else if (inArchive) {
const std::string fullPath = basepath + files[selectorIndex];
openManageMenu(fullPath);
} else {
onSelectBook(basepath + files[selectorIndex]);
return;
@@ -235,6 +276,94 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
renderer.displayBuffer();
}
void MyLibraryActivity::openManageMenu(const std::string& bookPath) {
const bool isArchived = BookManager::isArchived(bookPath);
const bool fromLongPress = !isInArchive();
const std::string capturedPath = bookPath;
enterNewActivity(new BookManageMenuActivity(
renderer, mappedInput, capturedPath, isArchived,
[this, capturedPath](BookManageMenuActivity::Action action) {
exitActivity();
bool success = false;
switch (action) {
case BookManageMenuActivity::Action::ARCHIVE:
success = BookManager::archiveBook(capturedPath);
break;
case BookManageMenuActivity::Action::UNARCHIVE:
success = BookManager::unarchiveBook(capturedPath);
break;
case BookManageMenuActivity::Action::DELETE:
success = BookManager::deleteBook(capturedPath);
break;
case BookManageMenuActivity::Action::DELETE_CACHE:
success = BookManager::deleteBookCache(capturedPath);
break;
case BookManageMenuActivity::Action::REINDEX:
success = BookManager::reindexBook(capturedPath, false);
break;
case BookManageMenuActivity::Action::REINDEX_FULL:
success = BookManager::reindexBook(capturedPath, true);
break;
}
if (success && BookManager::isArchived(capturedPath) &&
(action == BookManageMenuActivity::Action::UNARCHIVE ||
action == BookManageMenuActivity::Action::DELETE)) {
BookManager::cleanupEmptyArchiveDirs(capturedPath);
}
{
RenderLock lock(*this);
GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED));
}
requestUpdateAndWait();
loadFiles();
if (files.empty() && isInArchive() && basepath != "/.archive") {
// Current directory was removed; navigate up to nearest existing ancestor
while (basepath.length() > std::string("/.archive").length()) {
auto slash = basepath.find_last_of('/');
if (slash == std::string::npos || slash == 0) break;
basepath = basepath.substr(0, slash);
loadFiles();
if (!files.empty() || basepath == "/.archive") break;
}
selectorIndex = 0;
} else if (selectorIndex >= files.size() && !files.empty()) {
selectorIndex = files.size() - 1;
}
requestUpdate();
},
[this] {
exitActivity();
requestUpdate();
},
fromLongPress));
}
bool MyLibraryActivity::isInArchive() const { return basepath.rfind("/.archive", 0) == 0; }
void MyLibraryActivity::unarchiveAndOpen(const std::string& bookPath) {
std::string unarchivedPath;
if (BookManager::unarchiveBook(bookPath, &unarchivedPath)) {
BookManager::cleanupEmptyArchiveDirs(bookPath);
{
RenderLock lock(*this);
GUI.drawPopup(renderer, tr(STR_BOOK_UNARCHIVED));
}
requestUpdateAndWait();
pendingOpenPath = unarchivedPath;
} else {
{
RenderLock lock(*this);
GUI.drawPopup(renderer, tr(STR_ACTION_FAILED));
}
requestUpdateAndWait();
loadFiles();
if (selectorIndex >= files.size() && !files.empty()) {
selectorIndex = files.size() - 1;
}
requestUpdate();
}
}
size_t MyLibraryActivity::findEntry(const std::string& name) const {
for (size_t i = 0; i < files.size(); i++)
if (files[i] == name) return i;

View File

@@ -3,11 +3,11 @@
#include <string>
#include <vector>
#include "../Activity.h"
#include "../ActivityWithSubactivity.h"
#include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class MyLibraryActivity final : public Activity {
class MyLibraryActivity final : public ActivityWithSubactivity {
private:
ButtonNavigator buttonNavigator;
@@ -17,6 +17,13 @@ class MyLibraryActivity final : public Activity {
std::string basepath = "/";
std::vector<std::string> files;
// Long-press state
bool ignoreNextConfirmRelease = false;
static constexpr unsigned long LONG_PRESS_MS = 700;
// Deferred open: wait for Confirm release before navigating to book
std::string pendingOpenPath;
// Callbacks
const std::function<void(const std::string& path)> onSelectBook;
const std::function<void()> onGoHome;
@@ -24,14 +31,19 @@ class MyLibraryActivity final : public Activity {
// Data loading
void loadFiles();
size_t findEntry(const std::string& name) const;
bool isInArchive() const;
void openManageMenu(const std::string& bookPath);
void unarchiveAndOpen(const std::string& bookPath);
public:
explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome,
const std::function<void(const std::string& path)>& onSelectBook,
std::string initialPath = "/")
: Activity("MyLibrary", renderer, mappedInput),
std::string initialPath = "/", bool initialSkipRelease = false)
: ActivityWithSubactivity("MyLibrary", renderer, mappedInput),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
ignoreNextConfirmRelease(initialSkipRelease),
onSelectBook(onSelectBook),
onGoHome(onGoHome) {}
void onEnter() override;

View File

@@ -6,10 +6,12 @@
#include <algorithm>
#include "BookManageMenuActivity.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
#include "util/StringUtils.h"
namespace {
@@ -31,7 +33,7 @@ void RecentBooksActivity::loadRecentBooks() {
}
void RecentBooksActivity::onEnter() {
Activity::onEnter();
ActivityWithSubactivity::onEnter();
// Load data
loadRecentBooks();
@@ -41,14 +43,33 @@ void RecentBooksActivity::onEnter() {
}
void RecentBooksActivity::onExit() {
Activity::onExit();
ActivityWithSubactivity::onExit();
recentBooks.clear();
}
void RecentBooksActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
// Long-press Confirm: open manage menu
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS &&
!ignoreNextConfirmRelease) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
ignoreNextConfirmRelease = true;
openManageMenu(recentBooks[selectorIndex].path);
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path);
@@ -111,3 +132,49 @@ void RecentBooksActivity::render(Activity::RenderLock&&) {
renderer.displayBuffer();
}
void RecentBooksActivity::openManageMenu(const std::string& bookPath) {
const bool isArchived = BookManager::isArchived(bookPath);
const std::string capturedPath = bookPath;
enterNewActivity(new BookManageMenuActivity(
renderer, mappedInput, capturedPath, isArchived,
[this, capturedPath](BookManageMenuActivity::Action action) {
exitActivity();
bool success = false;
switch (action) {
case BookManageMenuActivity::Action::ARCHIVE:
success = BookManager::archiveBook(capturedPath);
break;
case BookManageMenuActivity::Action::UNARCHIVE:
success = BookManager::unarchiveBook(capturedPath);
break;
case BookManageMenuActivity::Action::DELETE:
success = BookManager::deleteBook(capturedPath);
break;
case BookManageMenuActivity::Action::DELETE_CACHE:
success = BookManager::deleteBookCache(capturedPath);
break;
case BookManageMenuActivity::Action::REINDEX:
success = BookManager::reindexBook(capturedPath, false);
break;
case BookManageMenuActivity::Action::REINDEX_FULL:
success = BookManager::reindexBook(capturedPath, true);
break;
}
{
RenderLock lock(*this);
GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED));
}
requestUpdateAndWait();
loadRecentBooks();
if (selectorIndex >= static_cast<int>(recentBooks.size()) && !recentBooks.empty()) {
selectorIndex = recentBooks.size() - 1;
}
requestUpdate();
},
[this] {
exitActivity();
requestUpdate();
},
true));
}

View File

@@ -5,11 +5,11 @@
#include <string>
#include <vector>
#include "../Activity.h"
#include "../ActivityWithSubactivity.h"
#include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class RecentBooksActivity final : public Activity {
class RecentBooksActivity final : public ActivityWithSubactivity {
private:
ButtonNavigator buttonNavigator;
@@ -18,18 +18,23 @@ class RecentBooksActivity final : public Activity {
// Recent tab state
std::vector<RecentBook> recentBooks;
// Long-press state
bool ignoreNextConfirmRelease = false;
static constexpr unsigned long LONG_PRESS_MS = 700;
// Callbacks
const std::function<void(const std::string& path)> onSelectBook;
const std::function<void()> onGoHome;
// Data loading
void loadRecentBooks();
void openManageMenu(const std::string& bookPath);
public:
explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome,
const std::function<void(const std::string& path)>& onSelectBook)
: Activity("RecentBooks", renderer, mappedInput), onSelectBook(onSelectBook), onGoHome(onGoHome) {}
: ActivityWithSubactivity("RecentBooks", renderer, mappedInput), onSelectBook(onSelectBook), onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -0,0 +1,74 @@
#include "EndOfBookMenuActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void EndOfBookMenuActivity::buildMenuItems() {
menuItems.clear();
menuItems.push_back({Action::ARCHIVE, StrId::STR_ARCHIVE_BOOK});
menuItems.push_back({Action::DELETE, StrId::STR_DELETE_BOOK});
menuItems.push_back({Action::TABLE_OF_CONTENTS, StrId::STR_TABLE_OF_CONTENTS});
menuItems.push_back({Action::BACK_TO_BEGINNING, StrId::STR_BACK_TO_BEGINNING});
menuItems.push_back({Action::CLOSE_BOOK, StrId::STR_CLOSE_BOOK});
menuItems.push_back({Action::CLOSE_MENU, StrId::STR_CLOSE_MENU});
}
void EndOfBookMenuActivity::onEnter() {
Activity::onEnter();
selectedIndex = 0;
requestUpdate();
}
void EndOfBookMenuActivity::onExit() { Activity::onExit(); }
void EndOfBookMenuActivity::loop() {
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectedIndex < static_cast<int>(menuItems.size())) {
auto cb = onAction;
cb(menuItems[selectedIndex].action);
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
auto cb = onAction;
cb(Action::CLOSE_MENU);
return;
}
}
void EndOfBookMenuActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
auto metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_END_OF_BOOK));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(menuItems.size()), selectedIndex,
[this](int index) { return std::string(I18N.get(menuItems[index].labelId)); });
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <I18n.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
class EndOfBookMenuActivity final : public Activity {
public:
enum class Action {
ARCHIVE,
DELETE,
TABLE_OF_CONTENTS,
BACK_TO_BEGINNING,
CLOSE_BOOK,
CLOSE_MENU,
};
explicit EndOfBookMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& bookPath,
const std::function<void(Action)>& onAction)
: Activity("EndOfBookMenu", renderer, mappedInput), bookPath(bookPath), onAction(onAction) {
buildMenuItems();
}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
struct MenuItem {
Action action;
StrId labelId;
};
std::string bookPath;
std::vector<MenuItem> menuItems;
int selectedIndex = 0;
ButtonNavigator buttonNavigator;
const std::function<void(Action)> onAction;
void buildMenuItems();
};

View File

@@ -13,12 +13,14 @@
#include "EpubReaderBookmarkSelectionActivity.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "EpubReaderPercentSelectionActivity.h"
#include "EndOfBookMenuActivity.h"
#include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
#include "util/BookmarkStore.h"
#include "util/Dictionary.h"
@@ -220,6 +222,52 @@ void EpubReaderActivity::loop() {
return; // Don't access 'this' after callback
}
// Deferred end-of-book menu (set in render() to avoid deadlock)
if (pendingEndOfBookMenu) {
pendingEndOfBookMenu = false;
endOfBookMenuOpened = true;
const std::string path = epub->getPath();
enterNewActivity(new EndOfBookMenuActivity(
renderer, mappedInput, path, [this](EndOfBookMenuActivity::Action action) {
exitActivity();
switch (action) {
case EndOfBookMenuActivity::Action::ARCHIVE:
if (epub) BookManager::archiveBook(epub->getPath());
pendingGoHome = true;
break;
case EndOfBookMenuActivity::Action::DELETE:
if (epub) BookManager::deleteBook(epub->getPath());
pendingGoHome = true;
break;
case EndOfBookMenuActivity::Action::TABLE_OF_CONTENTS:
endOfBookMenuOpened = false;
currentSpineIndex = epub->getSpineItemsCount() - 1;
nextPageNumber = UINT16_MAX;
section.reset();
openChapterSelection();
break;
case EndOfBookMenuActivity::Action::BACK_TO_BEGINNING:
currentSpineIndex = 0;
nextPageNumber = 0;
section.reset();
endOfBookMenuOpened = false;
requestUpdate();
break;
case EndOfBookMenuActivity::Action::CLOSE_BOOK:
pendingGoHome = true;
break;
case EndOfBookMenuActivity::Action::CLOSE_MENU:
currentSpineIndex = epub->getSpineItemsCount() - 1;
nextPageNumber = UINT16_MAX;
section.reset();
endOfBookMenuOpened = false;
requestUpdate();
break;
}
}));
return;
}
// Skip button processing after returning from subactivity
// This prevents stale button release events from triggering actions
// We wait until: (1) all relevant buttons are released, AND (2) wasReleased events have been cleared
@@ -264,7 +312,7 @@ void EpubReaderActivity::loop() {
exitActivity();
enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, SETTINGS.fontSize, hasDictionary, isBookmarked, epub->getCachePath(),
SETTINGS.orientation, SETTINGS.fontSize, hasDictionary, isBookmarked, epub->getCachePath(), epub->getPath(),
[this](const uint8_t orientation, const uint8_t fontSize) { onReaderMenuBack(orientation, fontSize); },
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
}
@@ -299,10 +347,11 @@ void EpubReaderActivity::loop() {
return;
}
// any botton press when at end of the book goes back to the last page
// any button 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;
endOfBookMenuOpened = false;
requestUpdate();
return;
}
@@ -712,6 +761,36 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
pendingGoHome = true;
break;
}
case EpubReaderMenuActivity::MenuAction::ARCHIVE_BOOK: {
if (epub) {
BookManager::archiveBook(epub->getPath());
}
pendingGoHome = true;
break;
}
case EpubReaderMenuActivity::MenuAction::DELETE_BOOK: {
if (epub) {
BookManager::deleteBook(epub->getPath());
}
pendingGoHome = true;
break;
}
case EpubReaderMenuActivity::MenuAction::MANAGE_BOOK:
break;
case EpubReaderMenuActivity::MenuAction::REINDEX_BOOK: {
if (epub) {
BookManager::reindexBook(epub->getPath(), false);
}
pendingGoHome = true;
break;
}
case EpubReaderMenuActivity::MenuAction::REINDEX_BOOK_FULL: {
if (epub) {
BookManager::reindexBook(epub->getPath(), true);
}
pendingGoHome = true;
break;
}
case EpubReaderMenuActivity::MenuAction::SYNC: {
if (KOREADER_STORE.hasCredentials()) {
const int currentPage = section ? section->currentPage : 0;
@@ -807,11 +886,11 @@ void EpubReaderActivity::render(Activity::RenderLock&& lock) {
currentSpineIndex = epub->getSpineItemsCount();
}
// Show end of book screen
// End of book — defer menu creation to loop() to avoid deadlock (render holds the lock)
if (currentSpineIndex == epub->getSpineItemsCount()) {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
renderer.displayBuffer();
if (!endOfBookMenuOpened) {
pendingEndOfBookMenu = true;
}
return;
}

View File

@@ -27,6 +27,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
volatile bool loadingSection = false; // True during the entire !section block (read from main loop)
bool silentIndexingActive = false; // True while silently pre-indexing the next chapter
int preIndexedNextSpine = -1; // Spine index already pre-indexed (prevents re-render loop)
bool endOfBookMenuOpened = false; // Guard to prevent repeated opening of EndOfBookMenuActivity
bool pendingEndOfBookMenu = false; // Deferred: open EndOfBookMenuActivity from loop(), not render()
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;

View File

@@ -3,10 +3,12 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include "../home/BookManageMenuActivity.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
void EpubReaderMenuActivity::onEnter() {
ActivityWithSubactivity::onEnter();
@@ -116,6 +118,42 @@ void EpubReaderMenuActivity::loop() {
return;
}
if (selectedAction == MenuAction::MANAGE_BOOK) {
const bool isArchived = BookManager::isArchived(bookFilePath);
enterNewActivity(new BookManageMenuActivity(
renderer, mappedInput, bookFilePath, isArchived,
[this](BookManageMenuActivity::Action action) {
exitActivity();
auto cb = onAction;
switch (action) {
case BookManageMenuActivity::Action::ARCHIVE:
cb(MenuAction::ARCHIVE_BOOK);
break;
case BookManageMenuActivity::Action::DELETE:
cb(MenuAction::DELETE_BOOK);
break;
case BookManageMenuActivity::Action::DELETE_CACHE:
cb(MenuAction::DELETE_CACHE);
break;
case BookManageMenuActivity::Action::REINDEX:
cb(MenuAction::REINDEX_BOOK);
break;
case BookManageMenuActivity::Action::REINDEX_FULL:
cb(MenuAction::REINDEX_BOOK_FULL);
break;
case BookManageMenuActivity::Action::UNARCHIVE:
// Unarchive from within reader is unusual but handle gracefully
cb(MenuAction::GO_HOME);
break;
}
},
[this] {
exitActivity();
requestUpdate();
}));
return;
}
// 1. Capture the callback and action locally
auto actionCallback = onAction;

View File

@@ -28,12 +28,18 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
GO_HOME,
SYNC,
DELETE_CACHE,
MANAGE_BOOK,
ARCHIVE_BOOK,
DELETE_BOOK,
REINDEX_BOOK,
REINDEX_BOOK_FULL,
};
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const uint8_t currentFontSize,
const bool hasDictionary, const bool isBookmarked, const std::string& bookCachePath,
const std::string& bookFilePath,
const std::function<void(uint8_t, uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
@@ -42,6 +48,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
pendingOrientation(currentOrientation),
pendingFontSize(currentFontSize),
bookCachePath(bookCachePath),
bookFilePath(bookFilePath),
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent),
@@ -75,6 +82,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
StrId::STR_LANDSCAPE_CCW};
const std::vector<StrId> fontSizeLabels = {StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE, StrId::STR_X_LARGE};
std::string bookCachePath;
std::string bookFilePath;
// Letterbox fill override: 0xFF = Default (use global), 0 = Dithered, 1 = Solid, 2 = None
uint8_t pendingLetterboxFill = BookSettings::USE_GLOBAL;
static constexpr int LETTERBOX_FILL_OPTION_COUNT = 4; // Default + 3 modes
@@ -132,7 +140,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
items.push_back({MenuAction::GO_HOME, StrId::STR_CLOSE_BOOK});
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
items.push_back({MenuAction::MANAGE_BOOK, StrId::STR_MANAGE_BOOK});
return items;
}
};

View File

@@ -9,10 +9,12 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EndOfBookMenuActivity.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
namespace {
constexpr unsigned long goHomeMs = 1000;
@@ -153,10 +155,45 @@ void TxtReaderActivity::loop() {
if (prevTriggered && currentPage > 0) {
currentPage--;
endOfBookMenuOpened = false;
requestUpdate();
} else if (nextTriggered && currentPage < totalPages - 1) {
currentPage++;
requestUpdate();
} else if (nextTriggered && currentPage == totalPages - 1 && !endOfBookMenuOpened) {
// At last page and trying to advance → show end of book menu
endOfBookMenuOpened = true;
const std::string path = txt->getPath();
enterNewActivity(new EndOfBookMenuActivity(
renderer, mappedInput, path, [this](EndOfBookMenuActivity::Action action) {
exitActivity();
switch (action) {
case EndOfBookMenuActivity::Action::ARCHIVE:
if (txt) BookManager::archiveBook(txt->getPath());
if (onGoHome) onGoHome();
return;
case EndOfBookMenuActivity::Action::DELETE:
if (txt) BookManager::deleteBook(txt->getPath());
if (onGoHome) onGoHome();
return;
case EndOfBookMenuActivity::Action::TABLE_OF_CONTENTS:
endOfBookMenuOpened = false;
requestUpdate();
break;
case EndOfBookMenuActivity::Action::BACK_TO_BEGINNING:
currentPage = 0;
endOfBookMenuOpened = false;
requestUpdate();
break;
case EndOfBookMenuActivity::Action::CLOSE_BOOK:
if (onGoHome) onGoHome();
return;
case EndOfBookMenuActivity::Action::CLOSE_MENU:
endOfBookMenuOpened = false;
requestUpdate();
break;
}
}));
}
}

View File

@@ -14,6 +14,7 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
int totalPages = 1;
int pagesUntilFullRefresh = 0;
bool endOfBookMenuOpened = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;

View File

@@ -15,11 +15,13 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EndOfBookMenuActivity.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "XtcReaderChapterSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
namespace {
constexpr unsigned long skipPageMs = 700;
@@ -104,6 +106,60 @@ void XtcReaderActivity::loop() {
return;
}
// Deferred end-of-book menu (set in render() to avoid deadlock)
if (pendingEndOfBookMenu) {
pendingEndOfBookMenu = false;
endOfBookMenuOpened = true;
const std::string path = xtc->getPath();
enterNewActivity(new EndOfBookMenuActivity(
renderer, mappedInput, path, [this](EndOfBookMenuActivity::Action action) {
exitActivity();
switch (action) {
case EndOfBookMenuActivity::Action::ARCHIVE:
if (xtc) BookManager::archiveBook(xtc->getPath());
if (onGoHome) onGoHome();
break;
case EndOfBookMenuActivity::Action::DELETE:
if (xtc) BookManager::deleteBook(xtc->getPath());
if (onGoHome) onGoHome();
break;
case EndOfBookMenuActivity::Action::TABLE_OF_CONTENTS:
endOfBookMenuOpened = false;
currentPage = xtc->getPageCount() - 1;
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
enterNewActivity(new XtcReaderChapterSelectionActivity(
renderer, mappedInput, xtc, currentPage,
[this] {
exitActivity();
requestUpdate();
},
[this](const uint32_t newPage) {
currentPage = newPage;
exitActivity();
requestUpdate();
}));
} else {
requestUpdate();
}
break;
case EndOfBookMenuActivity::Action::BACK_TO_BEGINNING:
currentPage = 0;
endOfBookMenuOpened = false;
requestUpdate();
break;
case EndOfBookMenuActivity::Action::CLOSE_BOOK:
if (onGoHome) onGoHome();
break;
case EndOfBookMenuActivity::Action::CLOSE_MENU:
currentPage = xtc->getPageCount() - 1;
endOfBookMenuOpened = false;
requestUpdate();
break;
}
}));
return;
}
// Enter chapter selection activity
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
@@ -155,6 +211,7 @@ void XtcReaderActivity::loop() {
// Handle end of book
if (currentPage >= xtc->getPageCount()) {
currentPage = xtc->getPageCount() - 1;
endOfBookMenuOpened = false;
requestUpdate();
return;
}
@@ -183,12 +240,11 @@ void XtcReaderActivity::render(Activity::RenderLock&&) {
return;
}
// Bounds check
// End of book — defer menu creation to loop() to avoid deadlock (render holds the lock)
if (currentPage >= xtc->getPageCount()) {
// Show end of book screen
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
renderer.displayBuffer();
if (!endOfBookMenuOpened) {
pendingEndOfBookMenu = true;
}
return;
}

View File

@@ -17,6 +17,8 @@ class XtcReaderActivity final : public ActivityWithSubactivity {
uint32_t currentPage = 0;
int pagesUntilFullRefresh = 0;
bool endOfBookMenuOpened = false;
bool pendingEndOfBookMenu = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;

View File

@@ -6,6 +6,7 @@
#include <Logging.h>
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -94,8 +95,8 @@ void ClearCacheActivity::clearCache() {
file.getName(name, sizeof(name));
String itemName(name);
// Only delete directories starting with epub_ or xtc_
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) {
if (file.isDirectory() &&
(itemName.startsWith("epub_") || itemName.startsWith("xtc_") || itemName.startsWith("txt_"))) {
String fullPath = "/.crosspoint/" + itemName;
LOG_DBG("CLEAR_CACHE", "Removing cache: %s", fullPath.c_str());
@@ -113,6 +114,9 @@ void ClearCacheActivity::clearCache() {
}
root.close();
// Clear recents since all cached data (covers, progress) is gone
RECENT_BOOKS.clear();
LOG_DBG("CLEAR_CACHE", "Cache cleared: %d removed, %d failed", clearedCount, failedCount);
state = SUCCESS;

View File

@@ -0,0 +1,150 @@
#include "NtpSyncActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
#include <WiFi.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/TimeSync.h"
static constexpr unsigned long AUTO_DISMISS_MS = 5000;
void NtpSyncActivity::onWifiSelectionComplete(const bool success) {
exitActivity();
if (!success) {
LOG_ERR("NTP", "WiFi connection failed, exiting");
goBack();
return;
}
LOG_DBG("NTP", "WiFi connected, starting NTP sync");
{
RenderLock lock(*this);
state = SYNCING;
}
requestUpdateAndWait();
const bool synced = TimeSync::waitForNtpSync(8000);
{
RenderLock lock(*this);
state = synced ? SUCCESS : FAILED;
if (synced) {
successTimestamp = millis();
}
}
requestUpdate();
if (synced) {
LOG_DBG("NTP", "Time synced successfully");
} else {
LOG_ERR("NTP", "NTP sync timed out");
}
}
void NtpSyncActivity::onEnter() {
ActivityWithSubactivity::onEnter();
LOG_DBG("NTP", "Turning on WiFi...");
WiFi.mode(WIFI_STA);
LOG_DBG("NTP", "Launching WifiSelectionActivity...");
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
}
void NtpSyncActivity::onExit() {
ActivityWithSubactivity::onExit();
TimeSync::stopNtpSync();
WiFi.disconnect(false);
delay(100);
WiFi.mode(WIFI_OFF);
delay(100);
}
void NtpSyncActivity::render(Activity::RenderLock&&) {
if (subActivity) {
return;
}
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}, tr(STR_SYNC_CLOCK));
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const auto centerY = (pageHeight - lineHeight) / 2;
if (state == SYNCING) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_SYNCING_TIME));
} else if (state == SUCCESS) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_TIME_SYNCED), true, EpdFontFamily::BOLD);
time_t now = time(nullptr);
struct tm* t = localtime(&now);
if (t != nullptr && t->tm_year > 100) {
char timeBuf[32];
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
} else {
int hour12 = t->tm_hour % 12;
if (hour12 == 0) hour12 = 12;
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
}
renderer.drawCenteredText(UI_10_FONT_ID, centerY + lineHeight + metrics.verticalSpacing, timeBuf);
}
const unsigned long elapsed = millis() - successTimestamp;
const int remaining = static_cast<int>((AUTO_DISMISS_MS - elapsed + 999) / 1000);
char backLabel[32];
snprintf(backLabel, sizeof(backLabel), "%s (%d)", tr(STR_BACK), remaining > 0 ? remaining : 1);
const auto labels = mappedInput.mapLabels(backLabel, "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else if (state == FAILED) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}
void NtpSyncActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (state == SUCCESS) {
const unsigned long elapsed = millis() - successTimestamp;
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || elapsed >= AUTO_DISMISS_MS) {
goBack();
return;
}
const int currentSecond = static_cast<int>(elapsed / 1000);
if (currentSecond != lastCountdownSecond) {
lastCountdownSecond = currentSecond;
requestUpdate();
}
return;
}
if (state == FAILED) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
goBack();
}
return;
}
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include "activities/ActivityWithSubactivity.h"
class NtpSyncActivity : public ActivityWithSubactivity {
enum State { WIFI_SELECTION, SYNCING, SUCCESS, FAILED };
const std::function<void()> goBack;
State state = WIFI_SELECTION;
unsigned long successTimestamp = 0;
int lastCountdownSecond = -1;
void onWifiSelectionComplete(bool success);
public:
explicit NtpSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& goBack)
: ActivityWithSubactivity("NtpSync", renderer, mappedInput), goBack(goBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
bool preventAutoSleep() override { return state == SYNCING; }
};

View File

@@ -13,6 +13,7 @@
#include "KOReaderSettingsActivity.h"
#include "LanguageSelectActivity.h"
#include "MappedInputManager.h"
#include "NtpSyncActivity.h"
#include "OtaUpdateActivity.h"
#include "SetTimeActivity.h"
#include "SetTimezoneOffsetActivity.h"
@@ -221,6 +222,9 @@ void SettingsActivity::toggleCurrentSetting() {
case SettingAction::SetTimezoneOffset:
enterSubActivity(new SetTimezoneOffsetActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::SyncClock:
enterSubActivity(new NtpSyncActivity(renderer, mappedInput, onComplete));
break;
case SettingAction::None:
// Do nothing
break;
@@ -245,7 +249,8 @@ void SettingsActivity::rebuildClockActions() {
[](const SettingInfo& s) { return s.type == SettingType::ACTION; }),
clockSettings.end());
// Always add Set Time
// Always add Sync Clock and Set Time
clockSettings.push_back(SettingInfo::Action(StrId::STR_SYNC_CLOCK, SettingAction::SyncClock));
clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_TIME, SettingAction::SetTime));
// Only add Set UTC Offset when timezone is set to Custom

View File

@@ -23,6 +23,7 @@ enum class SettingAction {
Language,
SetTime,
SetTimezoneOffset,
SyncClock,
};
struct SettingInfo {

View File

@@ -224,12 +224,13 @@ void enterDeepSleep() {
}
void onGoHome();
void onGoToMyLibraryWithPath(const std::string& path);
void onGoToMyLibraryWithPath(const std::string& path, bool initialSkipRelease = false);
void onGoToRecentBooks();
void onGoToReader(const std::string& initialEpubPath) {
const std::string bookPath = initialEpubPath; // Copy before exitActivity() invalidates the reference
exitActivity();
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, onGoHome, onGoToMyLibraryWithPath));
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, onGoHome,
[](const std::string& p) { onGoToMyLibraryWithPath(p); }));
}
void onGoToFileTransfer() {
@@ -252,9 +253,9 @@ void onGoToRecentBooks() {
enterNewActivity(new RecentBooksActivity(renderer, mappedInputManager, onGoHome, onGoToReader));
}
void onGoToMyLibraryWithPath(const std::string& path) {
void onGoToMyLibraryWithPath(const std::string& path, bool initialSkipRelease) {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, path));
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, path, initialSkipRelease));
}
void onGoToBrowser() {
@@ -264,8 +265,9 @@ void onGoToBrowser() {
void onGoHome() {
exitActivity();
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary, onGoToRecentBooks,
onGoToSettings, onGoToFileTransfer, onGoToBrowser));
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary,
onGoToMyLibraryWithPath, onGoToRecentBooks, onGoToSettings, onGoToFileTransfer,
onGoToBrowser));
}
void setupDisplayAndFonts() {

255
src/util/BookManager.cpp Normal file
View File

@@ -0,0 +1,255 @@
#include "BookManager.h"
#include <HalStorage.h>
#include <Logging.h>
#include <functional>
#include "RecentBooksStore.h"
#include "StringUtils.h"
namespace {
constexpr char ARCHIVE_ROOT[] = "/.archive";
constexpr char CACHE_ROOT[] = "/.crosspoint";
std::string getCachePrefix(const std::string& bookPath) {
if (StringUtils::checkFileExtension(bookPath, ".epub")) return "epub_";
if (StringUtils::checkFileExtension(bookPath, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch"))
return "xtc_";
if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".md"))
return "txt_";
return "epub_";
}
std::string computeCachePath(const std::string& bookPath) {
const auto prefix = getCachePrefix(bookPath);
return std::string(CACHE_ROOT) + "/" + prefix + std::to_string(std::hash<std::string>{}(bookPath));
}
// Ensure all parent directories of a path exist.
void ensureParentDirs(const std::string& path) {
for (size_t i = 1; i < path.length(); i++) {
if (path[i] == '/') {
Storage.mkdir(path.substr(0, i).c_str());
}
}
}
// Delete cover and thumbnail BMP files from a cache directory.
void deleteCoverFiles(const std::string& cachePath) {
auto dir = Storage.open(cachePath.c_str());
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
return;
}
dir.rewindDirectory();
char name[256];
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
file.getName(name, sizeof(name));
const std::string fname(name);
file.close();
const bool isCover = (fname == "cover.bmp" || fname == "cover_crop.bmp");
const bool isThumb = (fname.rfind("thumb_", 0) == 0 && StringUtils::checkFileExtension(fname, ".bmp"));
if (isCover || isThumb) {
const std::string fullPath = cachePath + "/" + fname;
Storage.remove(fullPath.c_str());
}
}
dir.close();
}
} // namespace
namespace BookManager {
std::string getBookCachePath(const std::string& bookPath) { return computeCachePath(bookPath); }
bool isArchived(const std::string& bookPath) {
return bookPath.rfind(std::string(ARCHIVE_ROOT) + "/", 0) == 0;
}
bool archiveBook(const std::string& bookPath) {
if (isArchived(bookPath)) {
LOG_ERR("BKMGR", "Book is already archived: %s", bookPath.c_str());
return false;
}
const std::string destPath = std::string(ARCHIVE_ROOT) + bookPath;
ensureParentDirs(destPath);
if (!Storage.rename(bookPath.c_str(), destPath.c_str())) {
LOG_ERR("BKMGR", "Failed to move book to archive: %s -> %s", bookPath.c_str(), destPath.c_str());
return false;
}
// Rename cache directory to match the new book path hash
const std::string oldCache = computeCachePath(bookPath);
const std::string newCache = computeCachePath(destPath);
if (oldCache != newCache && Storage.exists(oldCache.c_str())) {
if (!Storage.rename(oldCache.c_str(), newCache.c_str())) {
LOG_ERR("BKMGR", "Failed to rename cache dir: %s -> %s", oldCache.c_str(), newCache.c_str());
}
}
RECENT_BOOKS.removeBook(bookPath);
LOG_DBG("BKMGR", "Archived: %s -> %s", bookPath.c_str(), destPath.c_str());
return true;
}
bool unarchiveBook(const std::string& archivePath, std::string* unarchivedPath) {
if (!isArchived(archivePath)) {
LOG_ERR("BKMGR", "Book is not in archive: %s", archivePath.c_str());
return false;
}
// Strip "/.archive" prefix to get original path
std::string destPath = archivePath.substr(strlen(ARCHIVE_ROOT));
if (destPath.empty() || destPath[0] != '/') {
destPath = "/" + destPath;
}
// Check if original parent directory exists, fall back to root
const auto lastSlash = destPath.find_last_of('/');
std::string parentDir = (lastSlash != std::string::npos && lastSlash > 0) ? destPath.substr(0, lastSlash) : "/";
if (!Storage.exists(parentDir.c_str())) {
const auto filename = destPath.substr(lastSlash + 1);
destPath = "/" + filename;
LOG_DBG("BKMGR", "Original dir gone, unarchiving to root: %s", destPath.c_str());
}
ensureParentDirs(destPath);
if (!Storage.rename(archivePath.c_str(), destPath.c_str())) {
LOG_ERR("BKMGR", "Failed to move book from archive: %s -> %s", archivePath.c_str(), destPath.c_str());
return false;
}
// Rename cache directory
const std::string oldCache = computeCachePath(archivePath);
const std::string newCache = computeCachePath(destPath);
if (oldCache != newCache && Storage.exists(oldCache.c_str())) {
if (!Storage.rename(oldCache.c_str(), newCache.c_str())) {
LOG_ERR("BKMGR", "Failed to rename cache dir: %s -> %s", oldCache.c_str(), newCache.c_str());
}
}
RECENT_BOOKS.removeBook(archivePath);
LOG_DBG("BKMGR", "Unarchived: %s -> %s", archivePath.c_str(), destPath.c_str());
if (unarchivedPath) *unarchivedPath = destPath;
return true;
}
bool deleteBook(const std::string& bookPath) {
// Delete the book file
if (Storage.exists(bookPath.c_str())) {
if (!Storage.remove(bookPath.c_str())) {
LOG_ERR("BKMGR", "Failed to delete book file: %s", bookPath.c_str());
return false;
}
}
// Delete cache directory
const std::string cachePath = computeCachePath(bookPath);
if (Storage.exists(cachePath.c_str())) {
Storage.removeDir(cachePath.c_str());
}
RECENT_BOOKS.removeBook(bookPath);
LOG_DBG("BKMGR", "Deleted book: %s", bookPath.c_str());
return true;
}
bool deleteBookCache(const std::string& bookPath) {
const std::string cachePath = computeCachePath(bookPath);
if (Storage.exists(cachePath.c_str())) {
if (!Storage.removeDir(cachePath.c_str())) {
LOG_ERR("BKMGR", "Failed to delete cache: %s", cachePath.c_str());
return false;
}
}
RECENT_BOOKS.removeBook(bookPath);
LOG_DBG("BKMGR", "Deleted cache for: %s", bookPath.c_str());
return true;
}
bool reindexBook(const std::string& bookPath, bool alsoRegenerateCovers) {
const std::string cachePath = computeCachePath(bookPath);
if (!Storage.exists(cachePath.c_str())) {
LOG_DBG("BKMGR", "No cache to reindex for: %s", bookPath.c_str());
RECENT_BOOKS.removeBook(bookPath);
return true;
}
const auto prefix = getCachePrefix(bookPath);
if (prefix == "epub_") {
// Delete sections directory
const std::string sectionsPath = cachePath + "/sections";
if (Storage.exists(sectionsPath.c_str())) {
Storage.removeDir(sectionsPath.c_str());
}
// Delete book.bin (spine/TOC metadata)
const std::string bookBin = cachePath + "/book.bin";
if (Storage.exists(bookBin.c_str())) {
Storage.remove(bookBin.c_str());
}
// Delete CSS cache
const std::string cssCache = cachePath + "/css_rules.cache";
if (Storage.exists(cssCache.c_str())) {
Storage.remove(cssCache.c_str());
}
} else if (prefix == "txt_") {
// Delete page index
const std::string indexBin = cachePath + "/index.bin";
if (Storage.exists(indexBin.c_str())) {
Storage.remove(indexBin.c_str());
}
} else if (prefix == "xtc_") {
// XTC is pre-indexed; only covers/thumbs are cached
// Nothing to delete for sections
}
if (alsoRegenerateCovers) {
deleteCoverFiles(cachePath);
}
RECENT_BOOKS.removeBook(bookPath);
LOG_DBG("BKMGR", "Reindexed (covers=%d): %s", alsoRegenerateCovers, bookPath.c_str());
return true;
}
void cleanupEmptyArchiveDirs(const std::string& bookPath) {
if (!isArchived(bookPath)) return;
// Walk up from the book's parent directory, removing empty dirs
std::string dir = bookPath.substr(0, bookPath.find_last_of('/'));
const std::string archiveRoot(ARCHIVE_ROOT);
while (dir.length() > archiveRoot.length()) {
auto d = Storage.open(dir.c_str());
if (!d || !d.isDirectory()) {
if (d) d.close();
break;
}
auto child = d.openNextFile();
const bool empty = !child;
if (child) child.close();
d.close();
if (!empty) break;
Storage.rmdir(dir.c_str());
LOG_DBG("BKMGR", "Removed empty archive dir: %s", dir.c_str());
auto slash = dir.find_last_of('/');
if (slash == std::string::npos || slash == 0) break;
dir = dir.substr(0, slash);
}
}
} // namespace BookManager

39
src/util/BookManager.h Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include <string>
namespace BookManager {
// Compute the cache directory path for a book (e.g. "/.crosspoint/epub_12345")
std::string getBookCachePath(const std::string& bookPath);
// Move a book to /.archive/ preserving directory structure.
// Renames the cache dir to match the new path hash. Removes from recents.
// Returns true on success.
bool archiveBook(const std::string& bookPath);
// Move a book from /.archive/ back to its original location.
// Falls back to "/" if the original directory no longer exists.
// Renames the cache dir to match the restored path hash. Returns true on success.
// If unarchivedPath is non-null, stores the destination path on success.
bool unarchiveBook(const std::string& archivePath, std::string* unarchivedPath = nullptr);
// Delete a book file, its cache directory, and remove from recents.
bool deleteBook(const std::string& bookPath);
// Delete only the cache directory for a book and remove from recents.
bool deleteBookCache(const std::string& bookPath);
// Clear indexed data from cache, preserving progress.
// If alsoRegenerateCovers is true, also deletes cover/thumbnail BMPs.
// Removes from recents.
bool reindexBook(const std::string& bookPath, bool alsoRegenerateCovers);
// Returns true if the book path is inside the /.archive/ folder.
bool isArchived(const std::string& bookPath);
// Remove empty directories under /.archive/ walking up from the book's parent.
// Stops at /.archive itself (never removes it).
void cleanupEmptyArchiveDirs(const std::string& bookPath);
} // namespace BookManager