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>
This commit is contained in:
cottongin
2026-02-21 07:37:36 -05:00
parent 39ef1e6d78
commit 0e2440aea8
16 changed files with 255 additions and 119 deletions

View File

@@ -23,10 +23,12 @@ class BookManageMenuActivity final : public Activity {
explicit BookManageMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& bookPath, bool isArchived,
const std::function<void(Action)>& onAction,
const std::function<void()>& onCancel)
const std::function<void()>& onCancel,
bool initialSkipRelease = false)
: Activity("BookManageMenu", renderer, mappedInput),
bookPath(bookPath),
archived(isArchived),
ignoreNextConfirmRelease(initialSkipRelease),
onAction(onAction),
onCancel(onCancel) {
buildMenuItems();
@@ -49,7 +51,7 @@ class BookManageMenuActivity final : public Activity {
int selectedIndex = 0;
ButtonNavigator buttonNavigator;
bool ignoreNextConfirmRelease = false;
bool ignoreNextConfirmRelease;
static constexpr unsigned long LONG_PRESS_MS = 700;
const std::function<void(Action)> onAction;

View File

@@ -222,7 +222,7 @@ void HomeActivity::loop() {
if (menuSelectedIndex == 0) {
// Long-press on Browse Files → go to archive folder
ignoreNextConfirmRelease = true;
onMyLibraryOpenWithPath("/.archive");
onMyLibraryOpenWithPath("/.archive", true);
return;
}
}
@@ -339,14 +339,19 @@ void HomeActivity::openManageMenu(const std::string& bookPath) {
GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED));
}
requestUpdateAndWait();
// Reload recents since the book may have been removed/archived
// 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

@@ -27,7 +27,7 @@ class HomeActivity final : public ActivityWithSubactivity {
const std::function<void(const std::string& path)> onSelectBook;
const std::function<void()> onMyLibraryOpen;
const std::function<void(const std::string& path)> onMyLibraryOpenWithPath;
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;
@@ -45,7 +45,7 @@ class HomeActivity final : public ActivityWithSubactivity {
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(const std::string& path)>& onSelectBook,
const std::function<void()>& onMyLibraryOpen,
const std::function<void(const std::string& path)>& onMyLibraryOpenWithPath,
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)

View File

@@ -124,6 +124,16 @@ void MyLibraryActivity::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 != "/") {
@@ -135,15 +145,21 @@ void MyLibraryActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
// Long-press Confirm: open manage menu for book files
// 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) {
if (!files.empty() && selectorIndex < files.size() && files[selectorIndex].back() != '/') {
ignoreNextConfirmRelease = true;
const std::string fullPath = (basepath.back() == '/' ? basepath : basepath + "/") + files[selectorIndex];
!ignoreNextConfirmRelease && isBookFile) {
ignoreNextConfirmRelease = true;
const std::string fullPath = (basepath.back() == '/' ? basepath : basepath + "/") + files[selectorIndex];
if (inArchive) {
unarchiveAndOpen(fullPath);
} else {
openManageMenu(fullPath);
return;
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -161,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;
@@ -259,6 +278,7 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
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,
@@ -285,13 +305,28 @@ void MyLibraryActivity::openManageMenu(const std::string& bookPath) {
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 (selectorIndex >= files.size() && !files.empty()) {
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();
@@ -299,7 +334,34 @@ void MyLibraryActivity::openManageMenu(const std::string& bookPath) {
[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 {

View File

@@ -21,6 +21,9 @@ class MyLibraryActivity final : public ActivityWithSubactivity {
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;
@@ -28,16 +31,19 @@ class MyLibraryActivity final : public ActivityWithSubactivity {
// 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 = "/")
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

@@ -175,5 +175,6 @@ void RecentBooksActivity::openManageMenu(const std::string& bookPath) {
[this] {
exitActivity();
requestUpdate();
}));
},
true));
}

View File

@@ -11,6 +11,7 @@ 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});
@@ -55,42 +56,18 @@ void EndOfBookMenuActivity::render(Activity::RenderLock&&) {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
auto metrics = UITheme::getInstance().getMetrics();
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;
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_END_OF_BOOK));
// Popup border and background
renderer.fillRect(popupX - 2, popupY - 2, popupW + 4, popupH + 4, true);
renderer.fillRect(popupX, popupY, popupW, popupH, false);
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
// Title
renderer.drawText(UI_12_FONT_ID, popupX + popupMargin, popupY + 8, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
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)); });
// 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_CLOSE_MENU), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
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

@@ -14,6 +14,7 @@ class EndOfBookMenuActivity final : public Activity {
enum class Action {
ARCHIVE,
DELETE,
TABLE_OF_CONTENTS,
BACK_TO_BEGINNING,
CLOSE_BOOK,
CLOSE_MENU,

View File

@@ -222,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
@@ -840,42 +886,10 @@ 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()) {
if (!endOfBookMenuOpened) {
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::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;
}
}));
pendingEndOfBookMenu = true;
}
return;
}

View File

@@ -28,6 +28,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
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

@@ -176,6 +176,10 @@ void TxtReaderActivity::loop() {
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;

View File

@@ -106,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()) {
@@ -186,38 +240,10 @@ void XtcReaderActivity::render(Activity::RenderLock&&) {
return;
}
// Bounds check - end of book
// End of book — defer menu creation to loop() to avoid deadlock (render holds the lock)
if (currentPage >= xtc->getPageCount()) {
if (!endOfBookMenuOpened) {
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::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;
}
}));
pendingEndOfBookMenu = true;
}
return;
}

View File

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

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() {

View File

@@ -100,7 +100,7 @@ bool archiveBook(const std::string& bookPath) {
return true;
}
bool unarchiveBook(const std::string& archivePath) {
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;
@@ -139,6 +139,7 @@ bool unarchiveBook(const std::string& archivePath) {
RECENT_BOOKS.removeBook(archivePath);
LOG_DBG("BKMGR", "Unarchived: %s -> %s", archivePath.c_str(), destPath.c_str());
if (unarchivedPath) *unarchivedPath = destPath;
return true;
}
@@ -222,4 +223,33 @@ bool reindexBook(const std::string& bookPath, bool alsoRegenerateCovers) {
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

View File

@@ -15,7 +15,8 @@ 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.
bool unarchiveBook(const std::string& archivePath);
// 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);
@@ -31,4 +32,8 @@ 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