Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Version counting is based on semantic versioning (Major.Feature.Patch)
* Fix rating context menu in the grid view.
* Add reset rating to the comic context menu.
* Add support for renaming folders inside the app. This preserves the folder and subfolders state (completed, read, dates, etc.) rather than creating a new folder like updating the library does if you rename the folder directly on the file system.
* Add organizing fuctionalities for renaming files and create folder structures based on metadata. Highly experimental.

### WebUI
* Add per-library search.
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ add_subdirectory(YACReaderLibrary/server)

if(NOT BUILD_SERVER_STANDALONE)
add_subdirectory(YACReaderLibrary/comic_vine)
add_subdirectory(YACReaderLibrary/organize_files)
endif()

# Always add YACReaderLibrary: defines library_common and db_helper (shared with server)
Expand Down
9 changes: 3 additions & 6 deletions YACReaderLibrary/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,6 @@ qt_add_executable(YACReaderLibrary WIN32
add_library_dialog.cpp
rename_library_dialog.h
rename_library_dialog.cpp
organize_files_dialog.h
organize_files_dialog.cpp
organize_files_coordinator.h
organize_files_coordinator.cpp
organize_files_preview_dialog.h
organize_files_preview_dialog.cpp
properties_dialog.h
properties_dialog.cpp
options_dialog.h
Expand Down Expand Up @@ -252,6 +246,7 @@ set(yacreaderlibrary_image_files
${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/getInfo.svg
${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/hideComicFlow.svg
${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/openInYACReader.svg
${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/organize.svg
${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/selectAll.svg
${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/setReadButton.svg
${PROJECT_SOURCE_DIR}/images/comics_view_toolbar/setUnread.svg
Expand Down Expand Up @@ -515,6 +510,7 @@ qt_add_translations(YACReaderLibrary
custom_widgets_library
shortcuts_library
comic_vine
organize_files
# Keep extraction scoped to targets used by this app and add the QML files
# directly so qsTr() strings in QML are collected too.
TS_FILES
Expand Down Expand Up @@ -558,6 +554,7 @@ target_link_libraries(YACReaderLibrary PRIVATE
shortcuts_library
server
comic_vine
organize_files
cbx_backend
concurrent_queue
worker
Expand Down
147 changes: 147 additions & 0 deletions YACReaderLibrary/db_helper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include <QTextStream>

#include <algorithm>
#include <utility>

using namespace YACReader;

Expand Down Expand Up @@ -1458,6 +1459,152 @@ bool DBHelper::renameFolder(qulonglong id, const QString &name, const QString &o
return execute(updateComics);
}

bool DBHelper::moveComic(qulonglong comicId, qulonglong newParentId, const QString &newFileName, const QString &newRelativePath, QSqlDatabase &db)
{
QSqlQuery query(db);
query.prepare("UPDATE comic SET parentId = :parentId, fileName = :fileName, path = :path WHERE id = :id");
query.bindValue(":parentId", newParentId);
query.bindValue(":fileName", newFileName);
query.bindValue(":path", newRelativePath);
query.bindValue(":id", comicId);

return query.exec() && query.numRowsAffected() == 1;
}

qulonglong DBHelper::ensureFolderPath(const QString &relativePath, QSqlDatabase &db, QList<qulonglong> *createdFolderIds)
{
const auto segments = relativePath.split('/', Qt::SkipEmptyParts);

qulonglong parentId = 1;
auto inheritedType = DBHelper::loadFolder(parentId, db).type;
QString currentPath;

for (const auto &segment : segments) {
currentPath += '/' + segment;

const auto existing = DBHelper::loadFolder(segment, parentId, db);
if (existing.knownId) {
parentId = existing.id;
inheritedType = existing.type;
continue;
}

Folder folder(segment, currentPath);
folder.setFather(parentId);
folder.type = inheritedType;

parentId = DBHelper::insert(&folder, db);
if (createdFolderIds != nullptr)
createdFolderIds->append(parentId);
}

return parentId;
}

void DBHelper::syncFolderAddedFromContents(const QList<qulonglong> &folderIds, QSqlDatabase &db)
{
QSqlQuery query(db);
query.prepare("UPDATE folder SET added = COALESCE("
"(SELECT MIN(ci.added) FROM comic c INNER JOIN comic_info ci ON c.comicInfoId = ci.id WHERE c.parentId = folder.id), added) "
"WHERE id = :id");

for (const auto id : folderIds) {
query.bindValue(":id", id);
if (!query.exec())
QLOG_ERROR() << "syncFolderAddedFromContents: update failed for folder" << id << query.lastError().text();
}
}

void DBHelper::removeEmptyFolderPaths(const QStringList &relativePaths, QSqlDatabase &db, QList<QVariantMap> *removedRows)
{
QSqlQuery select(db);
select.prepare("SELECT * FROM folder WHERE path = :path AND id <> 1"
" AND NOT EXISTS (SELECT 1 FROM comic WHERE comic.parentId = folder.id)"
" AND NOT EXISTS (SELECT 1 FROM folder AS child WHERE child.parentId = folder.id)");

QSqlQuery remove(db);
remove.prepare("DELETE FROM folder WHERE id = :id");

for (const auto &path : relativePaths) {
select.bindValue(":path", path);
if (!select.exec()) {
QLOG_ERROR() << "removeEmptyFolderPaths: select failed for" << path << select.lastError().text();
continue;
}

if (!select.next())
continue;

const auto record = select.record();

QVariantMap row;
for (int i = 0; i < record.count(); ++i)
row.insert(record.fieldName(i), record.value(i));

remove.bindValue(":id", row.value(QStringLiteral("id")));
if (!remove.exec()) {
QLOG_ERROR() << "removeEmptyFolderPaths: delete failed for" << path << remove.lastError().text();
continue;
}

if (removedRows != nullptr)
removedRows->append(row);
}
}

void DBHelper::removeEmptyFolderRows(const QList<qulonglong> &folderIds, QSqlDatabase &db)
{
QSqlQuery remove(db);
remove.prepare("DELETE FROM folder WHERE id = :id AND id <> 1"
" AND NOT EXISTS (SELECT 1 FROM comic WHERE comic.parentId = folder.id)"
" AND NOT EXISTS (SELECT 1 FROM folder AS child WHERE child.parentId = folder.id)");

for (const auto id : folderIds) {
remove.bindValue(":id", id);
if (!remove.exec())
QLOG_ERROR() << "removeEmptyFolderRows: delete failed for folder" << id << remove.lastError().text();
}
}

bool DBHelper::restoreFolderRows(const QList<QVariantMap> &rows, QSqlDatabase &db)
{
// A child cannot be inserted before its parent, because parentId is a foreign
// key into the same table.
auto ordered = rows;
std::sort(ordered.begin(), ordered.end(), [](const QVariantMap &a, const QVariantMap &b) {
return a.value(QStringLiteral("path")).toString().count(QLatin1Char('/')) < b.value(QStringLiteral("path")).toString().count(QLatin1Char('/'));
});

bool success = true;

for (const auto &row : std::as_const(ordered)) {
if (row.value(QStringLiteral("id")).toULongLong() == 0)
continue;

QStringList columns;
QStringList placeholders;
for (auto it = row.constBegin(); it != row.constEnd(); ++it) {
columns << it.key();
placeholders << QLatin1Char(':') + it.key();
}

QSqlQuery insert(db);
insert.prepare(QStringLiteral("INSERT OR IGNORE INTO folder (%1) VALUES (%2)")
.arg(columns.join(QStringLiteral(", ")), placeholders.join(QStringLiteral(", "))));

for (auto it = row.constBegin(); it != row.constEnd(); ++it)
insert.bindValue(QLatin1Char(':') + it.key(), it.value());

if (!insert.exec()) {
QLOG_ERROR() << "restoreFolderRows: insert failed for"
<< row.value(QStringLiteral("path")).toString() << insert.lastError().text();
success = false;
}
}

return success;
}

// inserts
qulonglong DBHelper::insert(Folder *folder, QSqlDatabase &db)
{
Expand Down
6 changes: 6 additions & 0 deletions YACReaderLibrary/db_helper.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ class DBHelper
static void renameLabel(qulonglong id, const QString &name, QSqlDatabase &db);
static void renameList(qulonglong id, const QString &name, QSqlDatabase &db);
static bool renameFolder(qulonglong id, const QString &name, const QString &oldPath, const QString &newPath, QSqlDatabase &db, QString *error = nullptr);
static bool moveComic(qulonglong comicId, qulonglong newParentId, const QString &newFileName, const QString &newRelativePath, QSqlDatabase &db);
static qulonglong ensureFolderPath(const QString &relativePath, QSqlDatabase &db, QList<qulonglong> *createdFolderIds = nullptr);
static void syncFolderAddedFromContents(const QList<qulonglong> &folderIds, QSqlDatabase &db);
static void removeEmptyFolderPaths(const QStringList &relativePaths, QSqlDatabase &db, QList<QVariantMap> *removedRows = nullptr);
static bool restoreFolderRows(const QList<QVariantMap> &rows, QSqlDatabase &db);
static void removeEmptyFolderRows(const QList<qulonglong> &folderIds, QSqlDatabase &db);
static void reasignOrderToSublists(QList<qulonglong> ids, QSqlDatabase &db);
static void reasignOrderToComicsInFavorites(QList<qulonglong> comicIds, QSqlDatabase &db);
static void reasignOrderToComicsInLabel(qulonglong labelId, QList<qulonglong> comicIds, QSqlDatabase &db);
Expand Down
2 changes: 1 addition & 1 deletion YACReaderLibrary/feature_flags.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace YACReader::FeatureFlags {

// The file organization workflow is still experimental. Keep its actions out
// of menus and shortcut management until the feature is ready for production.
inline constexpr bool organizeFiles = false;
inline constexpr bool organizeFiles = true;

} // namespace YACReader::FeatureFlags

Expand Down
59 changes: 48 additions & 11 deletions YACReaderLibrary/library_window.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include "edit_shortcuts_dialog.h"
#include "export_comics_info_dialog.h"
#include "export_library_dialog.h"
#include "feature_flags.h"
#include "folder_item.h"
#include "folder_management_coordinator.h"
#include "folder_model.h"
Expand Down Expand Up @@ -267,6 +268,12 @@ void LibraryWindow::setupUI()
void LibraryWindow::applyTheme(const Theme &theme)
{
editInfoToolBar->setStyleSheet(theme.comicsViewToolbar.toolbarQSS);
// Both menu buttons carry their own icon, because neither has a default action
// to take one from. See createMenuToolButton().
if (organizeToolButton != nullptr)
organizeToolButton->setIcon(theme.comicsViewToolbar.organizeIcon);
if (setTypeToolButton != nullptr)
setTypeToolButton->setIcon(theme.comicsViewToolbar.setAsNormalIcon);
mainSplitter->setStyleSheet(theme.contentSplitter.horizontalSplitterQSS);

// Update main toolbar and comics view toolbar icons
Expand Down Expand Up @@ -425,12 +432,19 @@ void LibraryWindow::setupCoordinators()
comicsModel,
foldersModel,
[this] { return getSelectedComics(); },
[this] { return getCurrentFolderIndex(); },
[this] {
// A search shows comics from the whole library while the folder
// tree keeps its old selection. That folder says nothing about the
// results, so the base is the library root, forced.
if (librarySearchCoordinator != nullptr && librarySearchCoordinator->isSearching())
return QModelIndex();
return getCurrentFolderIndex();
},
[this] {
const auto libraryName = selectedLibrary->currentText();
return OrganizeFilesCoordinator::LibraryContext { static_cast<qulonglong>(libraries.getId(libraryName)), libraries.getPath(libraryName) };
});
connect(organizeFilesCoordinator, &OrganizeFilesCoordinator::currentSourceReloadRequested, this, &LibraryWindow::reloadCurrentFolderComicsContent);
connect(organizeFilesCoordinator, &OrganizeFilesCoordinator::libraryContentChanged, this, &LibraryWindow::reloadCurrentLibrary);
comicManagementCoordinator = new ComicManagementCoordinator(
this,
settings,
Expand Down Expand Up @@ -548,7 +562,6 @@ void LibraryWindow::setupCoordinators()
connect(noLibrariesWidget, &NoLibrariesWidget::createNewLibrary, libraryManagementCoordinator, &LibraryManagementCoordinator::showCreateLibraryDialog);
connect(noLibrariesWidget, &NoLibrariesWidget::addExistingLibrary, libraryManagementCoordinator, &LibraryManagementCoordinator::showAddLibraryDialog);
connect(libraryDatabaseMaintenanceCoordinator, &LibraryDatabaseMaintenanceCoordinator::libraryReloadRequested, libraryManagementCoordinator, &LibraryManagementCoordinator::loadLibrary);
connect(organizeFilesCoordinator, &OrganizeFilesCoordinator::folderRefreshRequested, libraryManagementCoordinator, &LibraryManagementCoordinator::updateFolder);
connect(comicManagementCoordinator, &ComicManagementCoordinator::importRequested, libraryManagementCoordinator, [this](qulonglong folderId) {
libraryManagementCoordinator->updateFolder(foldersModel->getIndexFromFolderId(folderId));
});
Expand Down Expand Up @@ -634,6 +647,29 @@ bool LibraryWindow::hasLoadedLibraryModels() const
listsModelProxy->sourceModel() == listsModel;
}

namespace {

QToolButton *createMenuToolButton(const QList<QAction *> &entries, const QString &toolTip)
{
Q_ASSERT(!entries.isEmpty());

auto button = new QToolButton();
for (auto *entry : entries)
button->addAction(entry);

button->setPopupMode(QToolButton::InstantPopup);
button->setToolTip(toolTip);

auto *first = entries.first();
const auto followFirstEntry = [button, first] { button->setEnabled(first->isEnabled()); };
QObject::connect(first, &QAction::changed, button, followFirstEntry);
followFirstEntry();

return button;
}

}

void LibraryWindow::createToolBars()
{

Expand Down Expand Up @@ -690,6 +726,11 @@ void LibraryWindow::createToolBars()
editInfoToolBar->addAction(actions.openComicAction);
editInfoToolBar->addSeparator();
editInfoToolBar->addAction(actions.editSelectedComicsAction);
if (YACReader::FeatureFlags::organizeFiles) {
organizeToolButton = createMenuToolButton({ actions.renameComicsFilesAction, actions.organizeComicsFilesAction },
tr("Rename or organize files"));
editInfoToolBar->addWidget(organizeToolButton);
}
editInfoToolBar->addAction(actions.getInfoAction);
editInfoToolBar->addAction(actions.asignOrderAction);

Expand All @@ -706,14 +747,10 @@ void LibraryWindow::createToolBars()

editInfoToolBar->addSeparator();

auto setTypeToolButton = new QToolButton();
setTypeToolButton->addAction(actions.setNormalAction);
setTypeToolButton->addAction(actions.setMangaAction);
setTypeToolButton->addAction(actions.setWesternMangaAction);
setTypeToolButton->addAction(actions.setWebComicAction);
setTypeToolButton->addAction(actions.setYonkomaAction);
setTypeToolButton->setPopupMode(QToolButton::InstantPopup);
setTypeToolButton->setDefaultAction(actions.setNormalAction);
setTypeToolButton = createMenuToolButton({ actions.setNormalAction, actions.setMangaAction,
actions.setWesternMangaAction, actions.setWebComicAction,
actions.setYonkomaAction },
tr("Set the type of the selected comics"));
editInfoToolBar->addWidget(setTypeToolButton);

editInfoToolBar->addSeparator();
Expand Down
3 changes: 3 additions & 0 deletions YACReaderLibrary/library_window.h
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class EmptySpecialListWidget;
class EmptyReadingListWidget;
class RecentVisibilityCoordinator;
class OrganizeFilesCoordinator;
class QToolButton;
class ComicManagementCoordinator;
class ReadingListManagementCoordinator;
class FolderManagementCoordinator;
Expand Down Expand Up @@ -153,6 +154,8 @@ class LibraryWindow : public QMainWindow, protected Themable
QToolBar *treeActions;
QToolBar *comicsToolBar;
QToolBar *editInfoToolBar;
QToolButton *organizeToolButton = nullptr;
QToolButton *setTypeToolButton = nullptr;
QList<QAction *> comicToolbarEntries;
QAction *comicToolbarEndAnchor = nullptr;

Expand Down
Loading
Loading