From 3f48d85b77755ca2808b7d6bf5ce4b9c61e8437b Mon Sep 17 00:00:00 2001 From: Andrey Raspopov Date: Mon, 15 Jun 2026 16:12:06 +0200 Subject: [PATCH 1/4] First pass on file organization --- YACReaderLibrary/CMakeLists.txt | 2 + YACReaderLibrary/library_window.cpp | 147 ++++++++++++++++++++ YACReaderLibrary/library_window.h | 1 + YACReaderLibrary/library_window_actions.cpp | 7 + YACReaderLibrary/library_window_actions.h | 1 + YACReaderLibrary/organize_files_dialog.cpp | 136 ++++++++++++++++++ YACReaderLibrary/organize_files_dialog.h | 51 +++++++ 7 files changed, 345 insertions(+) create mode 100644 YACReaderLibrary/organize_files_dialog.cpp create mode 100644 YACReaderLibrary/organize_files_dialog.h diff --git a/YACReaderLibrary/CMakeLists.txt b/YACReaderLibrary/CMakeLists.txt index 8db25b77d..3f642c5cd 100644 --- a/YACReaderLibrary/CMakeLists.txt +++ b/YACReaderLibrary/CMakeLists.txt @@ -88,6 +88,8 @@ 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 properties_dialog.h properties_dialog.cpp options_dialog.h diff --git a/YACReaderLibrary/library_window.cpp b/YACReaderLibrary/library_window.cpp index 57e6f5f7d..9c2d232a8 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -6,8 +6,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -61,6 +63,7 @@ #include "library_creator.h" #include "no_libraries_widget.h" #include "options_dialog.h" +#include "organize_files_dialog.h" #include "package_manager.h" #include "properties_dialog.h" #include "reading_list_item.h" @@ -2224,6 +2227,149 @@ void LibraryWindow::openContainingFolder() QDesktopServices::openUrl(QUrl("file:///" + path, QUrl::TolerantMode)); } +static void collectComicsRecursively(qulonglong libraryId, qulonglong folderId, QList &out) +{ + const auto comics = DBHelper::getFolderComicsFromLibrary(libraryId, folderId); + for (auto *item : comics) { + if (auto *comic = static_cast(item)) + out.append(*comic); + } + qDeleteAll(comics); + + const auto subfolders = DBHelper::getFolderSubfoldersFromLibrary(libraryId, folderId); + for (auto *item : subfolders) { + collectComicsRecursively(libraryId, item->id, out); + } + qDeleteAll(subfolders); +} + +// Removes empty directories under basePath (but never basePath itself). +static void removeEmptyDirs(const QString &basePath) +{ + QDir base(basePath); + const auto entries = base.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &entry : entries) { + const QString childPath = base.absoluteFilePath(entry); + removeEmptyDirs(childPath); + QDir().rmdir(childPath); // only succeeds if empty + } +} + +void LibraryWindow::organizeFiles() +{ + const QModelIndex sourceIndex = getCurrentFolderIndex(); + if (!sourceIndex.isValid()) + return; + + const auto libraryId = libraries.getId(selectedLibrary->currentText()); + const auto folder = foldersModel->getFolder(sourceIndex); + const QString libraryRoot = QDir::cleanPath(currentPath()); + const QString folderAbsolutePath = QDir::cleanPath(currentPath() + foldersModel->getFolderPath(sourceIndex)); + + OrganizeFilesDialog dialog(this); + if (dialog.exec() != QDialog::Accepted) + return; + + const QString pattern = dialog.formatPattern(); + if (pattern.trimmed().isEmpty()) + return; + + QList comics; + collectComicsRecursively(libraryId, folder.id, comics); + + if (comics.isEmpty()) { + QMessageBox::information(this, tr("Organize files"), tr("This folder does not contain any comics to organize.")); + return; + } + + // Compute the moves. The destination is rooted at the selected folder. + struct Move { + QString source; + QString destination; + }; + QList moves; + const QDir destinationRoot(folderAbsolutePath); + + for (const ComicDB &comic : comics) { + const QString source = QDir::cleanPath(libraryRoot + comic.path); + const QFileInfo sourceInfo(source); + if (!sourceInfo.exists()) + continue; + + const QString extension = sourceInfo.suffix().isEmpty() ? QString() : QStringLiteral(".") + sourceInfo.suffix(); + + const QString relative = OrganizeFilesDialog::buildRelativePath(pattern, + comic.info.publisher.toString(), + comic.info.series.toString(), + comic.info.number.toString(), + comic.info.title.toString(), + comic.info.volume.toString(), + comic.info.year.toString(), + extension); + + QString destination = QDir::cleanPath(destinationRoot.absoluteFilePath(relative)); + if (destination == QDir::cleanPath(source)) + continue; // already in place + + // Avoid clobbering an existing destination by appending a counter. + if (QFileInfo::exists(destination)) { + const QFileInfo destInfo(destination); + const QString dir = destInfo.absolutePath(); + const QString base = destInfo.completeBaseName(); + const QString suffix = destInfo.suffix().isEmpty() ? QString() : QStringLiteral(".") + destInfo.suffix(); + int counter = 1; + QString candidate; + do { + candidate = QDir::cleanPath(dir + QStringLiteral("/") + base + QStringLiteral(" (") + QString::number(counter++) + QStringLiteral(")") + suffix); + } while (QFileInfo::exists(candidate)); + destination = candidate; + } + + moves.append({ source, destination }); + } + + if (moves.isEmpty()) { + QMessageBox::information(this, tr("Organize files"), tr("All files are already organized according to this format.")); + return; + } + + const auto answer = QMessageBox::question(this, tr("Organize files"), + tr("%1 file(s) will be moved inside \"%2\" according to the chosen format. Continue?") + .arg(moves.size()) + .arg(folder.name), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) + return; + + int moved = 0; + QStringList failures; + for (const Move &move : moves) { + const QString targetDir = QFileInfo(move.destination).absolutePath(); + if (!QDir().mkpath(targetDir)) { + failures << move.source; + continue; + } + if (QFile::rename(move.source, move.destination)) + moved++; + else + failures << move.source; + } + + // Clean up directories that became empty after moving files out of them. + removeEmptyDirs(folderAbsolutePath); + + if (!failures.isEmpty()) { + QMessageBox::warning(this, tr("Organize files"), + tr("%1 of %2 file(s) were moved. %3 file(s) could not be moved.") + .arg(moved) + .arg(moves.size()) + .arg(failures.size())); + } + + // Rescan the folder so the database reflects the new on-disk layout. + updateFolder(sourceIndex); +} + void LibraryWindow::setFolderAsNotCompleted() { // foldersModel->updateFolderCompletedStatus(foldersView->selectionModel()->selectedRows(),false); @@ -2577,6 +2723,7 @@ void LibraryWindow::showFoldersContextMenu(const QPoint &point) QMenu menu; menu.addAction(actions.openContainingFolderAction); + menu.addAction(actions.organizeFilesAction); menu.addAction(actions.updateFolderAction); menu.addSeparator(); //------------------------------- menu.addAction(actions.rescanXMLFromCurrentFolderAction); diff --git a/YACReaderLibrary/library_window.h b/YACReaderLibrary/library_window.h index 116567859..a1fadc458 100644 --- a/YACReaderLibrary/library_window.h +++ b/YACReaderLibrary/library_window.h @@ -238,6 +238,7 @@ public slots: void updateLibrary(); // void deleteLibrary(); void openContainingFolder(); + void organizeFiles(); void setFolderAsNotCompleted(); void setFolderAsCompleted(); void setFolderAsRead(); diff --git a/YACReaderLibrary/library_window_actions.cpp b/YACReaderLibrary/library_window_actions.cpp index 76957dcbd..4814cb4bc 100644 --- a/YACReaderLibrary/library_window_actions.cpp +++ b/YACReaderLibrary/library_window_actions.cpp @@ -208,6 +208,9 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti openContainingFolderAction->setData(OPEN_CONTAINING_FOLDER_ACTION_YL); openContainingFolderAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(OPEN_CONTAINING_FOLDER_ACTION_YL)); + organizeFilesAction = new QAction(window); + organizeFilesAction->setText(tr("Organize files")); + setFolderAsNotCompletedAction = new QAction(window); setFolderAsNotCompletedAction->setText(tr("Set as uncompleted")); setFolderAsNotCompletedAction->setData(SET_FOLDER_AS_NOT_COMPLETED_ACTION_YL); @@ -381,6 +384,7 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti // actions not asigned to any widget window->addAction(saveCoversToAction); window->addAction(openContainingFolderAction); + window->addAction(organizeFilesAction); window->addAction(updateCurrentFolderAction); window->addAction(resetComicRatingAction); window->addAction(setFolderAsCompletedAction); @@ -461,6 +465,7 @@ void LibraryWindowActions::createConnections( QObject::connect(setFolderAsReadAction, &QAction::triggered, window, &LibraryWindow::setFolderAsRead); QObject::connect(setFolderAsUnreadAction, &QAction::triggered, window, &LibraryWindow::setFolderAsUnread); QObject::connect(openContainingFolderAction, &QAction::triggered, window, &LibraryWindow::openContainingFolder); + QObject::connect(organizeFilesAction, &QAction::triggered, window, &LibraryWindow::organizeFiles); QObject::connect(setFolderCoverAction, &QAction::triggered, window, &LibraryWindow::setFolderCover); QObject::connect(deleteCustomFolderCoverAction, &QAction::triggered, window, &LibraryWindow::deleteCustomFolderCover); @@ -586,6 +591,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << expandAllNodesAction << colapseAllNodesAction << openContainingFolderAction + << organizeFilesAction << setFolderAsNotCompletedAction << setFolderAsCompletedAction << setFolderAsReadAction @@ -709,6 +715,7 @@ void LibraryWindowActions::disableFoldersActions(bool disabled) colapseAllNodesAction->setDisabled(disabled); openContainingFolderAction->setDisabled(disabled); + organizeFilesAction->setDisabled(disabled); updateFolderAction->setDisabled(disabled); rescanXMLFromCurrentFolderAction->setDisabled(disabled); diff --git a/YACReaderLibrary/library_window_actions.h b/YACReaderLibrary/library_window_actions.h index 0cffabdbb..885d54268 100644 --- a/YACReaderLibrary/library_window_actions.h +++ b/YACReaderLibrary/library_window_actions.h @@ -59,6 +59,7 @@ class LibraryWindowActions QAction *colapseAllNodesAction; QAction *openContainingFolderAction; + QAction *organizeFilesAction; QAction *saveCoversToAction; //-- QAction *setFolderAsNotCompletedAction; diff --git a/YACReaderLibrary/organize_files_dialog.cpp b/YACReaderLibrary/organize_files_dialog.cpp new file mode 100644 index 000000000..3c58b9fd2 --- /dev/null +++ b/YACReaderLibrary/organize_files_dialog.cpp @@ -0,0 +1,136 @@ +#include "organize_files_dialog.h" + +#include +#include +#include +#include +#include + +OrganizeFilesDialog::OrganizeFilesDialog(QWidget *parent) + : QDialog(parent) +{ + setupUI(); +} + +QString OrganizeFilesDialog::defaultPattern() +{ + return QStringLiteral("{publisher}/{series}/#{number} {title}"); +} + +void OrganizeFilesDialog::setupUI() +{ + auto description = new QLabel(tr("Files will be moved into subfolders following the format below. " + "Each part separated by \"/\" becomes a folder, except the last one which becomes the file name.")); + description->setWordWrap(true); + + auto tokensLabel = new QLabel(tr("Available tokens: %1") + .arg(QStringLiteral("{publisher} {series} {number} {title} {volume} {year}"))); + tokensLabel->setWordWrap(true); + + auto hintLabel = new QLabel(tr("{title} falls back to the series name when the comic has no title.")); + hintLabel->setWordWrap(true); + + patternEdit = new QLineEdit(defaultPattern()); + connect(patternEdit, &QLineEdit::textChanged, this, &OrganizeFilesDialog::updatePreview); + + previewLabel = new QLabel; + previewLabel->setWordWrap(true); + previewLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto mainLayout = new QVBoxLayout; + mainLayout->addWidget(description); + mainLayout->addWidget(new QLabel(tr("Format:"))); + mainLayout->addWidget(patternEdit); + mainLayout->addWidget(tokensLabel); + mainLayout->addWidget(hintLabel); + mainLayout->addSpacing(8); + mainLayout->addWidget(previewLabel); + mainLayout->addStretch(); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); + setModal(true); + setWindowTitle(tr("Organize files")); + resize(480, sizeHint().height()); + + updatePreview(); +} + +QString OrganizeFilesDialog::formatPattern() const +{ + return patternEdit->text(); +} + +void OrganizeFilesDialog::updatePreview() +{ + // Example metadata so the user can see the resulting layout live. + const QString example = buildRelativePath(patternEdit->text(), + QStringLiteral("Marvel"), + QStringLiteral("The Amazing Spider-Man"), + QStringLiteral("42"), + QStringLiteral("The Sinister Six"), + QStringLiteral("1"), + QStringLiteral("2018"), + QStringLiteral(".cbz")); + previewLabel->setText(tr("Example: %1").arg(example)); +} + +static QString sanitizeSegment(QString segment) +{ + // Replace characters that are invalid in file/folder names on common + // filesystems, then collapse whitespace and trim. + static const QString invalid = QStringLiteral("<>:\"/\\|?*"); + for (QChar &c : segment) { + if (invalid.contains(c) || c < QChar(0x20)) + c = QLatin1Char('_'); + } + segment = segment.simplified(); + // Windows does not allow trailing dots or spaces in names. + while (segment.endsWith(QLatin1Char('.')) || segment.endsWith(QLatin1Char(' '))) + segment.chop(1); + return segment; +} + +QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, + const QString &publisher, + const QString &series, + const QString &number, + const QString &title, + const QString &volume, + const QString &year, + const QString &extension) +{ + const QString safeSeries = series.trimmed().isEmpty() ? tr("Unknown Series") : series.trimmed(); + const QString safePublisher = publisher.trimmed().isEmpty() ? tr("Unknown Publisher") : publisher.trimmed(); + // {title} falls back to the series name, as requested. + const QString effectiveTitle = title.trimmed().isEmpty() ? safeSeries : title.trimmed(); + + QString result = pattern; + result.replace(QStringLiteral("{publisher}"), safePublisher); + result.replace(QStringLiteral("{series}"), safeSeries); + result.replace(QStringLiteral("{number}"), number.trimmed()); + result.replace(QStringLiteral("{title}"), effectiveTitle); + result.replace(QStringLiteral("{volume}"), volume.trimmed()); + result.replace(QStringLiteral("{year}"), year.trimmed()); + + // Split into segments, sanitize each, drop empty ones. + const QStringList rawSegments = result.split(QLatin1Char('/'), Qt::SkipEmptyParts); + QStringList segments; + for (const QString &raw : rawSegments) { + const QString clean = sanitizeSegment(raw); + if (!clean.isEmpty()) + segments << clean; + } + + if (segments.isEmpty()) + segments << sanitizeSegment(effectiveTitle); + + QString relativePath = segments.join(QLatin1Char('/')); + if (!extension.isEmpty()) + relativePath += extension; + return relativePath; +} diff --git a/YACReaderLibrary/organize_files_dialog.h b/YACReaderLibrary/organize_files_dialog.h new file mode 100644 index 000000000..dcf688869 --- /dev/null +++ b/YACReaderLibrary/organize_files_dialog.h @@ -0,0 +1,51 @@ +#ifndef ORGANIZE_FILES_DIALOG_H +#define ORGANIZE_FILES_DIALOG_H + +#include + +class QLineEdit; +class QLabel; + +// Dialog that lets the user define the path/name format used to organize comic +// files on disk. The format is a path template where each path segment becomes a +// directory, except the last one which becomes the file name (the original +// extension is kept). +// +// Supported tokens: {publisher} {series} {number} {title} {volume} {year} +// {title} falls back to {series} when the comic has no title. +class OrganizeFilesDialog : public QDialog +{ + Q_OBJECT +public: + explicit OrganizeFilesDialog(QWidget *parent = nullptr); + + // Returns the format pattern entered by the user. + QString formatPattern() const; + + // Default format used when none has been configured yet. + static QString defaultPattern(); + + // Builds the relative destination path (directories + file name, including + // the given extension) for a comic, applying token substitution and + // sanitizing every path segment. The extension should include the leading + // dot (e.g. ".cbz"); pass an empty string for none. + static QString buildRelativePath(const QString &pattern, + const QString &publisher, + const QString &series, + const QString &number, + const QString &title, + const QString &volume, + const QString &year, + const QString &extension); + +private slots: + void updatePreview(); + +private: + QLineEdit *patternEdit; + QLabel *previewLabel; + + void setupUI(); +}; + +#endif // ORGANIZE_FILES_DIALOG_H From 9564bb57e1f32dec88f864c701334fb62aab5638 Mon Sep 17 00:00:00 2001 From: Anthony Harmitage Date: Mon, 20 Jul 2026 14:37:23 +0200 Subject: [PATCH 2/4] Organize files relative to the library root --- YACReaderLibrary/library_window.cpp | 13 ++---- YACReaderLibrary/organize_files_dialog.cpp | 54 +++++++++++++++------- YACReaderLibrary/organize_files_dialog.h | 19 +++++++- common/yacreader_global.h | 1 + 4 files changed, 61 insertions(+), 26 deletions(-) diff --git a/YACReaderLibrary/library_window.cpp b/YACReaderLibrary/library_window.cpp index da9dbf7ab..8f7b401fc 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -2648,7 +2648,6 @@ static void collectComicsRecursively(qulonglong libraryId, qulonglong folderId, qDeleteAll(subfolders); } -// Removes empty directories under basePath (but never basePath itself). static void removeEmptyDirs(const QString &basePath) { QDir base(basePath); @@ -2656,7 +2655,7 @@ static void removeEmptyDirs(const QString &basePath) for (const QString &entry : entries) { const QString childPath = base.absoluteFilePath(entry); removeEmptyDirs(childPath); - QDir().rmdir(childPath); // only succeeds if empty + QDir().rmdir(childPath); } } @@ -2671,7 +2670,7 @@ void LibraryWindow::organizeFiles() const QString libraryRoot = QDir::cleanPath(currentPath()); const QString folderAbsolutePath = QDir::cleanPath(currentPath() + foldersModel->getFolderPath(sourceIndex)); - OrganizeFilesDialog dialog(this); + OrganizeFilesDialog dialog(libraryRoot, folderAbsolutePath, settings, this); if (dialog.exec() != QDialog::Accepted) return; @@ -2687,13 +2686,12 @@ void LibraryWindow::organizeFiles() return; } - // Compute the moves. The destination is rooted at the selected folder. struct Move { QString source; QString destination; }; QList moves; - const QDir destinationRoot(folderAbsolutePath); + const QDir destinationRoot(dialog.relativeToRoot() ? libraryRoot : folderAbsolutePath); for (const ComicDB &comic : comics) { const QString source = QDir::cleanPath(libraryRoot + comic.path); @@ -2714,9 +2712,8 @@ void LibraryWindow::organizeFiles() QString destination = QDir::cleanPath(destinationRoot.absoluteFilePath(relative)); if (destination == QDir::cleanPath(source)) - continue; // already in place + continue; - // Avoid clobbering an existing destination by appending a counter. if (QFileInfo::exists(destination)) { const QFileInfo destInfo(destination); const QString dir = destInfo.absolutePath(); @@ -2760,7 +2757,6 @@ void LibraryWindow::organizeFiles() failures << move.source; } - // Clean up directories that became empty after moving files out of them. removeEmptyDirs(folderAbsolutePath); if (!failures.isEmpty()) { @@ -2771,7 +2767,6 @@ void LibraryWindow::organizeFiles() .arg(failures.size())); } - // Rescan the folder so the database reflects the new on-disk layout. updateFolder(sourceIndex); } diff --git a/YACReaderLibrary/organize_files_dialog.cpp b/YACReaderLibrary/organize_files_dialog.cpp index 3c58b9fd2..5c76b4aae 100644 --- a/YACReaderLibrary/organize_files_dialog.cpp +++ b/YACReaderLibrary/organize_files_dialog.cpp @@ -1,13 +1,21 @@ #include "organize_files_dialog.h" +#include "yacreader_global.h" + +#include #include +#include #include #include #include +#include #include -OrganizeFilesDialog::OrganizeFilesDialog(QWidget *parent) - : QDialog(parent) +OrganizeFilesDialog::OrganizeFilesDialog(const QString &libraryRoot, + const QString &selectedFolderPath, + QSettings *settings, + QWidget *parent) + : QDialog(parent), libraryRoot(libraryRoot), selectedFolderPath(selectedFolderPath), settings(settings) { setupUI(); } @@ -33,6 +41,17 @@ void OrganizeFilesDialog::setupUI() patternEdit = new QLineEdit(defaultPattern()); connect(patternEdit, &QLineEdit::textChanged, this, &OrganizeFilesDialog::updatePreview); + relativeToRootCheck = new QCheckBox(tr("Place folders relative to the library root")); + relativeToRootCheck->setToolTip(tr("When enabled, the format is applied from the library root instead of the " + "selected folder, so it is not nested inside the folder being organized.")); + const bool relativeToRoot = settings ? settings->value(ORGANIZE_FILES_RELATIVE_TO_ROOT, true).toBool() : true; + relativeToRootCheck->setChecked(relativeToRoot); + connect(relativeToRootCheck, &QCheckBox::toggled, this, [this](bool checked) { + if (settings) + settings->setValue(ORGANIZE_FILES_RELATIVE_TO_ROOT, checked); + updatePreview(); + }); + previewLabel = new QLabel; previewLabel->setWordWrap(true); previewLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); @@ -45,6 +64,7 @@ void OrganizeFilesDialog::setupUI() mainLayout->addWidget(description); mainLayout->addWidget(new QLabel(tr("Format:"))); mainLayout->addWidget(patternEdit); + mainLayout->addWidget(relativeToRootCheck); mainLayout->addWidget(tokensLabel); mainLayout->addWidget(hintLabel); mainLayout->addSpacing(8); @@ -65,31 +85,35 @@ QString OrganizeFilesDialog::formatPattern() const return patternEdit->text(); } +bool OrganizeFilesDialog::relativeToRoot() const +{ + return relativeToRootCheck->isChecked(); +} + void OrganizeFilesDialog::updatePreview() { - // Example metadata so the user can see the resulting layout live. - const QString example = buildRelativePath(patternEdit->text(), - QStringLiteral("Marvel"), - QStringLiteral("The Amazing Spider-Man"), - QStringLiteral("42"), - QStringLiteral("The Sinister Six"), - QStringLiteral("1"), - QStringLiteral("2018"), - QStringLiteral(".cbz")); + const QString relative = buildRelativePath(patternEdit->text(), + QStringLiteral("Marvel"), + QStringLiteral("The Amazing Spider-Man"), + QStringLiteral("42"), + QStringLiteral("The Sinister Six"), + QStringLiteral("1"), + QStringLiteral("2018"), + QStringLiteral(".cbz")); + + const QString base = relativeToRootCheck->isChecked() ? libraryRoot : selectedFolderPath; + const QString example = base.isEmpty() ? relative : QDir::cleanPath(base + QLatin1Char('/') + relative); previewLabel->setText(tr("Example: %1").arg(example)); } static QString sanitizeSegment(QString segment) { - // Replace characters that are invalid in file/folder names on common - // filesystems, then collapse whitespace and trim. static const QString invalid = QStringLiteral("<>:\"/\\|?*"); for (QChar &c : segment) { if (invalid.contains(c) || c < QChar(0x20)) c = QLatin1Char('_'); } segment = segment.simplified(); - // Windows does not allow trailing dots or spaces in names. while (segment.endsWith(QLatin1Char('.')) || segment.endsWith(QLatin1Char(' '))) segment.chop(1); return segment; @@ -106,7 +130,6 @@ QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, { const QString safeSeries = series.trimmed().isEmpty() ? tr("Unknown Series") : series.trimmed(); const QString safePublisher = publisher.trimmed().isEmpty() ? tr("Unknown Publisher") : publisher.trimmed(); - // {title} falls back to the series name, as requested. const QString effectiveTitle = title.trimmed().isEmpty() ? safeSeries : title.trimmed(); QString result = pattern; @@ -117,7 +140,6 @@ QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, result.replace(QStringLiteral("{volume}"), volume.trimmed()); result.replace(QStringLiteral("{year}"), year.trimmed()); - // Split into segments, sanitize each, drop empty ones. const QStringList rawSegments = result.split(QLatin1Char('/'), Qt::SkipEmptyParts); QStringList segments; for (const QString &raw : rawSegments) { diff --git a/YACReaderLibrary/organize_files_dialog.h b/YACReaderLibrary/organize_files_dialog.h index dcf688869..391a32009 100644 --- a/YACReaderLibrary/organize_files_dialog.h +++ b/YACReaderLibrary/organize_files_dialog.h @@ -5,6 +5,8 @@ class QLineEdit; class QLabel; +class QCheckBox; +class QSettings; // Dialog that lets the user define the path/name format used to organize comic // files on disk. The format is a path template where each path segment becomes a @@ -17,11 +19,21 @@ class OrganizeFilesDialog : public QDialog { Q_OBJECT public: - explicit OrganizeFilesDialog(QWidget *parent = nullptr); + // libraryRoot and selectedFolderPath are absolute paths used to render a + // realistic preview and to reflect the "relative to library root" toggle. + // settings persists that toggle across runs (may be null). + explicit OrganizeFilesDialog(const QString &libraryRoot, + const QString &selectedFolderPath, + QSettings *settings = nullptr, + QWidget *parent = nullptr); // Returns the format pattern entered by the user. QString formatPattern() const; + // Whether the destination should be rooted at the library root (true) or at + // the currently selected folder (false). + bool relativeToRoot() const; + // Default format used when none has been configured yet. static QString defaultPattern(); @@ -44,6 +56,11 @@ private slots: private: QLineEdit *patternEdit; QLabel *previewLabel; + QCheckBox *relativeToRootCheck; + + QString libraryRoot; + QString selectedFolderPath; + QSettings *settings; void setupUI(); }; diff --git a/common/yacreader_global.h b/common/yacreader_global.h index 810878628..184ce59f1 100644 --- a/common/yacreader_global.h +++ b/common/yacreader_global.h @@ -19,6 +19,7 @@ class QLibrary; #define DB_VERSION "9.16.0" #define IMPORT_COMIC_INFO_XML_METADATA "IMPORT_COMIC_INFO_XML_METADATA" +#define ORGANIZE_FILES_RELATIVE_TO_ROOT "ORGANIZE_FILES_RELATIVE_TO_ROOT" #define COMPARE_MODIFIED_DATE_ON_LIBRARY_UPDATES "COMPARE_MODIFIED_DATE_ON_LIBRARY_UPDATES" #define UPDATE_LIBRARIES_AT_STARTUP "UPDATE_LIBRARIES_AT_STARTUP" #define DETECT_CHANGES_IN_LIBRARIES_AUTOMATICALLY "DETECT_CHANGES_IN_LIBRARIES_AUTOMATICALLY" From 2bfc7eb48b395c4e35a256f7802cefed808abb5e Mon Sep 17 00:00:00 2001 From: Anthony Harmitage Date: Mon, 20 Jul 2026 14:37:23 +0200 Subject: [PATCH 3/4] Organize files relative to the library root --- YACReaderLibrary/library_window.cpp | 13 ++---- YACReaderLibrary/organize_files_dialog.cpp | 54 +++++++++++++++------- YACReaderLibrary/organize_files_dialog.h | 27 ++++++----- common/yacreader_global.h | 1 + 4 files changed, 56 insertions(+), 39 deletions(-) diff --git a/YACReaderLibrary/library_window.cpp b/YACReaderLibrary/library_window.cpp index da9dbf7ab..8f7b401fc 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -2648,7 +2648,6 @@ static void collectComicsRecursively(qulonglong libraryId, qulonglong folderId, qDeleteAll(subfolders); } -// Removes empty directories under basePath (but never basePath itself). static void removeEmptyDirs(const QString &basePath) { QDir base(basePath); @@ -2656,7 +2655,7 @@ static void removeEmptyDirs(const QString &basePath) for (const QString &entry : entries) { const QString childPath = base.absoluteFilePath(entry); removeEmptyDirs(childPath); - QDir().rmdir(childPath); // only succeeds if empty + QDir().rmdir(childPath); } } @@ -2671,7 +2670,7 @@ void LibraryWindow::organizeFiles() const QString libraryRoot = QDir::cleanPath(currentPath()); const QString folderAbsolutePath = QDir::cleanPath(currentPath() + foldersModel->getFolderPath(sourceIndex)); - OrganizeFilesDialog dialog(this); + OrganizeFilesDialog dialog(libraryRoot, folderAbsolutePath, settings, this); if (dialog.exec() != QDialog::Accepted) return; @@ -2687,13 +2686,12 @@ void LibraryWindow::organizeFiles() return; } - // Compute the moves. The destination is rooted at the selected folder. struct Move { QString source; QString destination; }; QList moves; - const QDir destinationRoot(folderAbsolutePath); + const QDir destinationRoot(dialog.relativeToRoot() ? libraryRoot : folderAbsolutePath); for (const ComicDB &comic : comics) { const QString source = QDir::cleanPath(libraryRoot + comic.path); @@ -2714,9 +2712,8 @@ void LibraryWindow::organizeFiles() QString destination = QDir::cleanPath(destinationRoot.absoluteFilePath(relative)); if (destination == QDir::cleanPath(source)) - continue; // already in place + continue; - // Avoid clobbering an existing destination by appending a counter. if (QFileInfo::exists(destination)) { const QFileInfo destInfo(destination); const QString dir = destInfo.absolutePath(); @@ -2760,7 +2757,6 @@ void LibraryWindow::organizeFiles() failures << move.source; } - // Clean up directories that became empty after moving files out of them. removeEmptyDirs(folderAbsolutePath); if (!failures.isEmpty()) { @@ -2771,7 +2767,6 @@ void LibraryWindow::organizeFiles() .arg(failures.size())); } - // Rescan the folder so the database reflects the new on-disk layout. updateFolder(sourceIndex); } diff --git a/YACReaderLibrary/organize_files_dialog.cpp b/YACReaderLibrary/organize_files_dialog.cpp index 3c58b9fd2..5c76b4aae 100644 --- a/YACReaderLibrary/organize_files_dialog.cpp +++ b/YACReaderLibrary/organize_files_dialog.cpp @@ -1,13 +1,21 @@ #include "organize_files_dialog.h" +#include "yacreader_global.h" + +#include #include +#include #include #include #include +#include #include -OrganizeFilesDialog::OrganizeFilesDialog(QWidget *parent) - : QDialog(parent) +OrganizeFilesDialog::OrganizeFilesDialog(const QString &libraryRoot, + const QString &selectedFolderPath, + QSettings *settings, + QWidget *parent) + : QDialog(parent), libraryRoot(libraryRoot), selectedFolderPath(selectedFolderPath), settings(settings) { setupUI(); } @@ -33,6 +41,17 @@ void OrganizeFilesDialog::setupUI() patternEdit = new QLineEdit(defaultPattern()); connect(patternEdit, &QLineEdit::textChanged, this, &OrganizeFilesDialog::updatePreview); + relativeToRootCheck = new QCheckBox(tr("Place folders relative to the library root")); + relativeToRootCheck->setToolTip(tr("When enabled, the format is applied from the library root instead of the " + "selected folder, so it is not nested inside the folder being organized.")); + const bool relativeToRoot = settings ? settings->value(ORGANIZE_FILES_RELATIVE_TO_ROOT, true).toBool() : true; + relativeToRootCheck->setChecked(relativeToRoot); + connect(relativeToRootCheck, &QCheckBox::toggled, this, [this](bool checked) { + if (settings) + settings->setValue(ORGANIZE_FILES_RELATIVE_TO_ROOT, checked); + updatePreview(); + }); + previewLabel = new QLabel; previewLabel->setWordWrap(true); previewLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); @@ -45,6 +64,7 @@ void OrganizeFilesDialog::setupUI() mainLayout->addWidget(description); mainLayout->addWidget(new QLabel(tr("Format:"))); mainLayout->addWidget(patternEdit); + mainLayout->addWidget(relativeToRootCheck); mainLayout->addWidget(tokensLabel); mainLayout->addWidget(hintLabel); mainLayout->addSpacing(8); @@ -65,31 +85,35 @@ QString OrganizeFilesDialog::formatPattern() const return patternEdit->text(); } +bool OrganizeFilesDialog::relativeToRoot() const +{ + return relativeToRootCheck->isChecked(); +} + void OrganizeFilesDialog::updatePreview() { - // Example metadata so the user can see the resulting layout live. - const QString example = buildRelativePath(patternEdit->text(), - QStringLiteral("Marvel"), - QStringLiteral("The Amazing Spider-Man"), - QStringLiteral("42"), - QStringLiteral("The Sinister Six"), - QStringLiteral("1"), - QStringLiteral("2018"), - QStringLiteral(".cbz")); + const QString relative = buildRelativePath(patternEdit->text(), + QStringLiteral("Marvel"), + QStringLiteral("The Amazing Spider-Man"), + QStringLiteral("42"), + QStringLiteral("The Sinister Six"), + QStringLiteral("1"), + QStringLiteral("2018"), + QStringLiteral(".cbz")); + + const QString base = relativeToRootCheck->isChecked() ? libraryRoot : selectedFolderPath; + const QString example = base.isEmpty() ? relative : QDir::cleanPath(base + QLatin1Char('/') + relative); previewLabel->setText(tr("Example: %1").arg(example)); } static QString sanitizeSegment(QString segment) { - // Replace characters that are invalid in file/folder names on common - // filesystems, then collapse whitespace and trim. static const QString invalid = QStringLiteral("<>:\"/\\|?*"); for (QChar &c : segment) { if (invalid.contains(c) || c < QChar(0x20)) c = QLatin1Char('_'); } segment = segment.simplified(); - // Windows does not allow trailing dots or spaces in names. while (segment.endsWith(QLatin1Char('.')) || segment.endsWith(QLatin1Char(' '))) segment.chop(1); return segment; @@ -106,7 +130,6 @@ QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, { const QString safeSeries = series.trimmed().isEmpty() ? tr("Unknown Series") : series.trimmed(); const QString safePublisher = publisher.trimmed().isEmpty() ? tr("Unknown Publisher") : publisher.trimmed(); - // {title} falls back to the series name, as requested. const QString effectiveTitle = title.trimmed().isEmpty() ? safeSeries : title.trimmed(); QString result = pattern; @@ -117,7 +140,6 @@ QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, result.replace(QStringLiteral("{volume}"), volume.trimmed()); result.replace(QStringLiteral("{year}"), year.trimmed()); - // Split into segments, sanitize each, drop empty ones. const QStringList rawSegments = result.split(QLatin1Char('/'), Qt::SkipEmptyParts); QStringList segments; for (const QString &raw : rawSegments) { diff --git a/YACReaderLibrary/organize_files_dialog.h b/YACReaderLibrary/organize_files_dialog.h index dcf688869..3b980d0dd 100644 --- a/YACReaderLibrary/organize_files_dialog.h +++ b/YACReaderLibrary/organize_files_dialog.h @@ -5,30 +5,24 @@ class QLineEdit; class QLabel; +class QCheckBox; +class QSettings; -// Dialog that lets the user define the path/name format used to organize comic -// files on disk. The format is a path template where each path segment becomes a -// directory, except the last one which becomes the file name (the original -// extension is kept). -// -// Supported tokens: {publisher} {series} {number} {title} {volume} {year} -// {title} falls back to {series} when the comic has no title. class OrganizeFilesDialog : public QDialog { Q_OBJECT public: - explicit OrganizeFilesDialog(QWidget *parent = nullptr); + explicit OrganizeFilesDialog(const QString &libraryRoot, + const QString &selectedFolderPath, + QSettings *settings = nullptr, + QWidget *parent = nullptr); - // Returns the format pattern entered by the user. QString formatPattern() const; - // Default format used when none has been configured yet. + bool relativeToRoot() const; + static QString defaultPattern(); - // Builds the relative destination path (directories + file name, including - // the given extension) for a comic, applying token substitution and - // sanitizing every path segment. The extension should include the leading - // dot (e.g. ".cbz"); pass an empty string for none. static QString buildRelativePath(const QString &pattern, const QString &publisher, const QString &series, @@ -44,6 +38,11 @@ private slots: private: QLineEdit *patternEdit; QLabel *previewLabel; + QCheckBox *relativeToRootCheck; + + QString libraryRoot; + QString selectedFolderPath; + QSettings *settings; void setupUI(); }; diff --git a/common/yacreader_global.h b/common/yacreader_global.h index 810878628..184ce59f1 100644 --- a/common/yacreader_global.h +++ b/common/yacreader_global.h @@ -19,6 +19,7 @@ class QLibrary; #define DB_VERSION "9.16.0" #define IMPORT_COMIC_INFO_XML_METADATA "IMPORT_COMIC_INFO_XML_METADATA" +#define ORGANIZE_FILES_RELATIVE_TO_ROOT "ORGANIZE_FILES_RELATIVE_TO_ROOT" #define COMPARE_MODIFIED_DATE_ON_LIBRARY_UPDATES "COMPARE_MODIFIED_DATE_ON_LIBRARY_UPDATES" #define UPDATE_LIBRARIES_AT_STARTUP "UPDATE_LIBRARIES_AT_STARTUP" #define DETECT_CHANGES_IN_LIBRARIES_AUTOMATICALLY "DETECT_CHANGES_IN_LIBRARIES_AUTOMATICALLY" From c838e294c7c801f27152f1078daf8a2979037656 Mon Sep 17 00:00:00 2001 From: Anthony Harmitage Date: Mon, 27 Jul 2026 18:48:43 +0200 Subject: [PATCH 4/4] Add preview dialog and padded enumeration --- YACReaderLibrary/CMakeLists.txt | 2 + YACReaderLibrary/library_window.cpp | 141 +++++++--- YACReaderLibrary/library_window.h | 2 + YACReaderLibrary/library_window_actions.cpp | 7 + YACReaderLibrary/library_window_actions.h | 1 + YACReaderLibrary/organize_files_dialog.cpp | 27 +- YACReaderLibrary/organize_files_dialog.h | 7 +- .../organize_files_preview_dialog.cpp | 253 ++++++++++++++++++ .../organize_files_preview_dialog.h | 50 ++++ 9 files changed, 447 insertions(+), 43 deletions(-) create mode 100644 YACReaderLibrary/organize_files_preview_dialog.cpp create mode 100644 YACReaderLibrary/organize_files_preview_dialog.h diff --git a/YACReaderLibrary/CMakeLists.txt b/YACReaderLibrary/CMakeLists.txt index 4275aef3f..30de497c4 100644 --- a/YACReaderLibrary/CMakeLists.txt +++ b/YACReaderLibrary/CMakeLists.txt @@ -92,6 +92,8 @@ qt_add_executable(YACReaderLibrary WIN32 rename_library_dialog.cpp organize_files_dialog.h organize_files_dialog.cpp + organize_files_preview_dialog.h + organize_files_preview_dialog.cpp properties_dialog.h properties_dialog.cpp options_dialog.h diff --git a/YACReaderLibrary/library_window.cpp b/YACReaderLibrary/library_window.cpp index 8f7b401fc..4a2da984b 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -67,6 +69,7 @@ #include "no_libraries_widget.h" #include "options_dialog.h" #include "organize_files_dialog.h" +#include "organize_files_preview_dialog.h" #include "package_manager.h" #include "properties_dialog.h" #include "reading_list_item.h" @@ -1508,6 +1511,7 @@ void LibraryWindow::showComicsContextMenu(const QPoint &point, bool showFullScre menu.addAction(actions.saveCoversToAction); menu.addSeparator(); menu.addAction(actions.openContainingFolderComicAction); + menu.addAction(actions.organizeComicsFilesAction); menu.addAction(actions.updateCurrentFolderAction); menu.addSeparator(); menu.addAction(actions.resetComicRatingAction); @@ -2659,6 +2663,23 @@ static void removeEmptyDirs(const QString &basePath) } } +static QString uniqueDestination(const QString &destination, const QSet &taken) +{ + if (!QFileInfo::exists(destination) && !taken.contains(destination)) + return destination; + + const QFileInfo destInfo(destination); + const QString dir = destInfo.absolutePath(); + const QString base = destInfo.completeBaseName(); + const QString suffix = destInfo.suffix().isEmpty() ? QString() : QStringLiteral(".") + destInfo.suffix(); + int counter = 1; + QString candidate; + do { + candidate = QDir::cleanPath(dir + QStringLiteral("/") + base + QStringLiteral(" (") + QString::number(counter++) + QStringLiteral(")") + suffix); + } while (QFileInfo::exists(candidate) || taken.contains(candidate)); + return candidate; +} + void LibraryWindow::organizeFiles() { const QModelIndex sourceIndex = getCurrentFolderIndex(); @@ -2667,17 +2688,8 @@ void LibraryWindow::organizeFiles() const auto libraryId = libraries.getId(selectedLibrary->currentText()); const auto folder = foldersModel->getFolder(sourceIndex); - const QString libraryRoot = QDir::cleanPath(currentPath()); const QString folderAbsolutePath = QDir::cleanPath(currentPath() + foldersModel->getFolderPath(sourceIndex)); - OrganizeFilesDialog dialog(libraryRoot, folderAbsolutePath, settings, this); - if (dialog.exec() != QDialog::Accepted) - return; - - const QString pattern = dialog.formatPattern(); - if (pattern.trimmed().isEmpty()) - return; - QList comics; collectComicsRecursively(libraryId, folder.id, comics); @@ -2686,12 +2698,61 @@ void LibraryWindow::organizeFiles() return; } - struct Move { - QString source; - QString destination; - }; + if (runOrganizeFilesFlow(comics, folderAbsolutePath)) + updateFolder(sourceIndex); +} + +void LibraryWindow::organizeComicsFiles() +{ + const QModelIndexList indexList = getSelectedComics(); + if (indexList.isEmpty()) + return; + + const QList comics = comicsModel->getComics(indexList); + if (comics.isEmpty()) + return; + + const QModelIndex folderIndex = getCurrentFolderIndex(); + const QString folderAbsolutePath = folderIndex.isValid() + ? QDir::cleanPath(currentPath() + foldersModel->getFolderPath(folderIndex)) + : QDir::cleanPath(currentPath()); + + if (runOrganizeFilesFlow(comics, folderAbsolutePath)) { + if (folderIndex.isValid()) + updateFolder(folderIndex); + else + reloadCurrentFolderComicsContent(); + } +} + +bool LibraryWindow::runOrganizeFilesFlow(const QList &comics, const QString &cleanupPath) +{ + const QString libraryRoot = QDir::cleanPath(currentPath()); + + OrganizeFilesDialog dialog(libraryRoot, cleanupPath, settings, this); + if (dialog.exec() != QDialog::Accepted) + return false; + + const QString pattern = dialog.formatPattern(); + if (pattern.trimmed().isEmpty()) + return false; + + using Move = OrganizeFilesPreviewDialog::Move; QList moves; - const QDir destinationRoot(dialog.relativeToRoot() ? libraryRoot : folderAbsolutePath); + QSet takenDestinations; + const QDir destinationRoot(dialog.relativeToRoot() ? libraryRoot : cleanupPath); + + QHash seriesNumberWidth; + for (const ComicDB &comic : comics) { + const QString series = comic.info.series.toString().trimmed(); + bool ok = false; + const int value = comic.info.number.toString().trimmed().toInt(&ok); + if (!ok) + continue; + const int width = QString::number(value).size(); + int ¤t = seriesNumberWidth[series]; + current = std::max(current, width); + } for (const ComicDB &comic : comics) { const QString source = QDir::cleanPath(libraryRoot + comic.path); @@ -2701,6 +2762,8 @@ void LibraryWindow::organizeFiles() const QString extension = sourceInfo.suffix().isEmpty() ? QString() : QStringLiteral(".") + sourceInfo.suffix(); + const int numberPadding = seriesNumberWidth.value(comic.info.series.toString().trimmed(), 0); + const QString relative = OrganizeFilesDialog::buildRelativePath(pattern, comic.info.publisher.toString(), comic.info.series.toString(), @@ -2708,44 +2771,44 @@ void LibraryWindow::organizeFiles() comic.info.title.toString(), comic.info.volume.toString(), comic.info.year.toString(), - extension); + extension, + numberPadding); QString destination = QDir::cleanPath(destinationRoot.absoluteFilePath(relative)); if (destination == QDir::cleanPath(source)) continue; - if (QFileInfo::exists(destination)) { - const QFileInfo destInfo(destination); - const QString dir = destInfo.absolutePath(); - const QString base = destInfo.completeBaseName(); - const QString suffix = destInfo.suffix().isEmpty() ? QString() : QStringLiteral(".") + destInfo.suffix(); - int counter = 1; - QString candidate; - do { - candidate = QDir::cleanPath(dir + QStringLiteral("/") + base + QStringLiteral(" (") + QString::number(counter++) + QStringLiteral(")") + suffix); - } while (QFileInfo::exists(candidate)); - destination = candidate; - } + destination = uniqueDestination(destination, takenDestinations); + takenDestinations.insert(destination); moves.append({ source, destination }); } if (moves.isEmpty()) { QMessageBox::information(this, tr("Organize files"), tr("All files are already organized according to this format.")); - return; + return false; } - const auto answer = QMessageBox::question(this, tr("Organize files"), - tr("%1 file(s) will be moved inside \"%2\" according to the chosen format. Continue?") - .arg(moves.size()) - .arg(folder.name), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (answer != QMessageBox::Yes) - return; + OrganizeFilesPreviewDialog preview(destinationRoot.absolutePath(), libraryRoot, moves, this); + if (preview.exec() != QDialog::Accepted) + return false; + + QList finalMoves; + QSet finalTaken; + for (const Move &move : preview.moves()) { + if (QDir::cleanPath(move.destination) == QDir::cleanPath(move.source)) + continue; + const QString destination = uniqueDestination(move.destination, finalTaken); + finalTaken.insert(destination); + finalMoves.append({ move.source, destination }); + } + + if (finalMoves.isEmpty()) + return false; int moved = 0; QStringList failures; - for (const Move &move : moves) { + for (const Move &move : finalMoves) { const QString targetDir = QFileInfo(move.destination).absolutePath(); if (!QDir().mkpath(targetDir)) { failures << move.source; @@ -2757,17 +2820,17 @@ void LibraryWindow::organizeFiles() failures << move.source; } - removeEmptyDirs(folderAbsolutePath); + removeEmptyDirs(cleanupPath); if (!failures.isEmpty()) { QMessageBox::warning(this, tr("Organize files"), tr("%1 of %2 file(s) were moved. %3 file(s) could not be moved.") .arg(moved) - .arg(moves.size()) + .arg(finalMoves.size()) .arg(failures.size())); } - updateFolder(sourceIndex); + return moved > 0; } void LibraryWindow::setFolderAsNotCompleted() diff --git a/YACReaderLibrary/library_window.h b/YACReaderLibrary/library_window.h index e568aff8b..be70e0a24 100644 --- a/YACReaderLibrary/library_window.h +++ b/YACReaderLibrary/library_window.h @@ -251,6 +251,7 @@ public slots: // void deleteLibrary(); void openContainingFolder(); void organizeFiles(); + void organizeComicsFiles(); void setFolderAsNotCompleted(); void setFolderAsCompleted(); void setFolderAsRead(); @@ -330,6 +331,7 @@ public slots: void reloadCurrentFolderComicsContent(); void reloadAfterCopyMove(const QModelIndex &mi); QModelIndex getCurrentFolderIndex(); + bool runOrganizeFilesFlow(const QList &comics, const QString &cleanupPath); void enableNeededActions(); void disableComicsActions(bool disabled); void addFolderToCurrentIndex(); diff --git a/YACReaderLibrary/library_window_actions.cpp b/YACReaderLibrary/library_window_actions.cpp index c16c19570..05f7bb014 100644 --- a/YACReaderLibrary/library_window_actions.cpp +++ b/YACReaderLibrary/library_window_actions.cpp @@ -293,6 +293,9 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti openContainingFolderComicAction->setData(OPEN_CONTAINING_FOLDER_COMIC_ACTION_YL); openContainingFolderComicAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(OPEN_CONTAINING_FOLDER_COMIC_ACTION_YL)); + organizeComicsFilesAction = new QAction(window); + organizeComicsFilesAction->setText(tr("Organize files")); + resetComicRatingAction = new QAction(window); resetComicRatingAction->setText(tr("Reset comic rating")); resetComicRatingAction->setData(RESET_COMIC_RATING_ACTION_YL); @@ -421,6 +424,7 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti window->addAction(deleteMetadataAction); window->addAction(rescanXMLFromCurrentFolderAction); window->addAction(openContainingFolderComicAction); + window->addAction(organizeComicsFilesAction); #ifndef Q_OS_MACOS window->addAction(toggleFullScreenAction); #endif @@ -480,6 +484,7 @@ void LibraryWindowActions::createConnections( // ContextMenus QObject::connect(openContainingFolderComicAction, &QAction::triggered, window, &LibraryWindow::openContainingFolderComic); + QObject::connect(organizeComicsFilesAction, &QAction::triggered, window, &LibraryWindow::organizeComicsFiles); QObject::connect(setFolderAsNotCompletedAction, &QAction::triggered, window, &LibraryWindow::setFolderAsNotCompleted); QObject::connect(setFolderAsCompletedAction, &QAction::triggered, window, &LibraryWindow::setFolderAsCompleted); QObject::connect(setFolderAsReadAction, &QAction::triggered, window, &LibraryWindow::setFolderAsRead); @@ -597,6 +602,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << setMangaAction << setNormalAction << openContainingFolderComicAction + << organizeComicsFilesAction << resetComicRatingAction << selectAllComicsAction << editSelectedComicsAction @@ -709,6 +715,7 @@ void LibraryWindowActions::disableComicsActions(bool disabled) deleteComicsAction->setDisabled(disabled); // context menu openContainingFolderComicAction->setDisabled(disabled); + organizeComicsFilesAction->setDisabled(disabled); resetComicRatingAction->setDisabled(disabled); getInfoAction->setDisabled(disabled); diff --git a/YACReaderLibrary/library_window_actions.h b/YACReaderLibrary/library_window_actions.h index c0f23faac..af0b72d31 100644 --- a/YACReaderLibrary/library_window_actions.h +++ b/YACReaderLibrary/library_window_actions.h @@ -82,6 +82,7 @@ class LibraryWindowActions QAction *deleteCustomFolderCoverAction; QAction *openContainingFolderComicAction; + QAction *organizeComicsFilesAction; QAction *setAsReadAction; QAction *setAsNonReadAction; diff --git a/YACReaderLibrary/organize_files_dialog.cpp b/YACReaderLibrary/organize_files_dialog.cpp index 5c76b4aae..388b3f2fc 100644 --- a/YACReaderLibrary/organize_files_dialog.cpp +++ b/YACReaderLibrary/organize_files_dialog.cpp @@ -106,7 +106,7 @@ void OrganizeFilesDialog::updatePreview() previewLabel->setText(tr("Example: %1").arg(example)); } -static QString sanitizeSegment(QString segment) +QString OrganizeFilesDialog::sanitizeSegment(QString segment) { static const QString invalid = QStringLiteral("<>:\"/\\|?*"); for (QChar &c : segment) { @@ -119,6 +119,26 @@ static QString sanitizeSegment(QString segment) return segment; } +QString OrganizeFilesDialog::padNumber(const QString &number, int width) +{ + const QString trimmed = number.trimmed(); + if (width <= 0 || trimmed.isEmpty()) + return trimmed; + + int digits = 0; + while (digits < trimmed.size() && trimmed.at(digits).isDigit()) + ++digits; + + if (digits == 0) + return trimmed; + + QString leading = trimmed.left(digits); + while (leading.size() < width) + leading.prepend(QLatin1Char('0')); + + return leading + trimmed.mid(digits); +} + QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, const QString &publisher, const QString &series, @@ -126,7 +146,8 @@ QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, const QString &title, const QString &volume, const QString &year, - const QString &extension) + const QString &extension, + int numberPadding) { const QString safeSeries = series.trimmed().isEmpty() ? tr("Unknown Series") : series.trimmed(); const QString safePublisher = publisher.trimmed().isEmpty() ? tr("Unknown Publisher") : publisher.trimmed(); @@ -135,7 +156,7 @@ QString OrganizeFilesDialog::buildRelativePath(const QString &pattern, QString result = pattern; result.replace(QStringLiteral("{publisher}"), safePublisher); result.replace(QStringLiteral("{series}"), safeSeries); - result.replace(QStringLiteral("{number}"), number.trimmed()); + result.replace(QStringLiteral("{number}"), padNumber(number, numberPadding)); result.replace(QStringLiteral("{title}"), effectiveTitle); result.replace(QStringLiteral("{volume}"), volume.trimmed()); result.replace(QStringLiteral("{year}"), year.trimmed()); diff --git a/YACReaderLibrary/organize_files_dialog.h b/YACReaderLibrary/organize_files_dialog.h index 3b980d0dd..b4cd7c7e7 100644 --- a/YACReaderLibrary/organize_files_dialog.h +++ b/YACReaderLibrary/organize_files_dialog.h @@ -30,7 +30,12 @@ class OrganizeFilesDialog : public QDialog const QString &title, const QString &volume, const QString &year, - const QString &extension); + const QString &extension, + int numberPadding = 0); + + static QString sanitizeSegment(QString segment); + + static QString padNumber(const QString &number, int width); private slots: void updatePreview(); diff --git a/YACReaderLibrary/organize_files_preview_dialog.cpp b/YACReaderLibrary/organize_files_preview_dialog.cpp new file mode 100644 index 000000000..a94900616 --- /dev/null +++ b/YACReaderLibrary/organize_files_preview_dialog.cpp @@ -0,0 +1,253 @@ +#include "organize_files_preview_dialog.h" + +#include "organize_files_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +// Only the "New location" column (0) may be edited; the source column is +// informational and must stay read-only. +class FirstColumnEditableDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + if (index.column() != 0) + return nullptr; + return QStyledItemDelegate::createEditor(parent, option, index); + } +}; +} + +OrganizeFilesPreviewDialog::OrganizeFilesPreviewDialog(const QString &baseRoot, + const QString &libraryRoot, + const QList &moves, + QWidget *parent) + : QDialog(parent), baseRoot(QDir::cleanPath(baseRoot)), libraryRoot(QDir::cleanPath(libraryRoot)) +{ + setupUI(moves); +} + +void OrganizeFilesPreviewDialog::setupUI(const QList &moves) +{ + auto description = new QLabel(tr("%n file(s) will be moved as shown below. Double-click an item in the " + "\"New location\" column to rename a folder or file, or remove items to leave " + "them where they are, before applying the changes.", + "", moves.size())); + description->setWordWrap(true); + + tree = new QTreeWidget; + tree->setColumnCount(2); + tree->setHeaderLabels({ tr("New location"), tr("Current location") }); + tree->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::SelectedClicked | QAbstractItemView::EditKeyPressed); + tree->setItemDelegate(new FirstColumnEditableDelegate(tree)); + tree->setUniformRowHeights(true); + tree->setAlternatingRowColors(true); + tree->setSelectionMode(QAbstractItemView::ExtendedSelection); + + removeAction = new QAction(tr("Remove from list"), this); + removeAction->setShortcut(QKeySequence::Delete); + removeAction->setShortcutContext(Qt::WidgetShortcut); + connect(removeAction, &QAction::triggered, this, &OrganizeFilesPreviewDialog::removeSelectedItems); + tree->addAction(removeAction); + tree->setContextMenuPolicy(Qt::ActionsContextMenu); + connect(tree, &QTreeWidget::itemSelectionChanged, this, &OrganizeFilesPreviewDialog::updateActionsState); + + buildTree(moves); + + tree->expandAll(); + tree->resizeColumnToContents(0); + tree->header()->setStretchLastSection(true); + + auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + okButton = buttonBox->button(QDialogButtonBox::Ok); + okButton->setText(tr("Move files")); + removeButton = buttonBox->addButton(tr("Remove selected"), QDialogButtonBox::ActionRole); + connect(removeButton, &QPushButton::clicked, this, &OrganizeFilesPreviewDialog::removeSelectedItems); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto mainLayout = new QVBoxLayout; + mainLayout->addWidget(description); + mainLayout->addWidget(tree); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); + setModal(true); + setWindowTitle(tr("Organize files")); + resize(680, 520); + + updateActionsState(); +} + +void OrganizeFilesPreviewDialog::buildTree(const QList &moves) +{ + const QDir base(baseRoot); + const QIcon folderIcon = qApp->style()->standardIcon(QStyle::SP_DirIcon); + const QIcon fileIcon = qApp->style()->standardIcon(QStyle::SP_FileIcon); + + // Sort moves by destination so the tree is built in a stable, readable order. + QList sortedMoves = moves; + std::sort(sortedMoves.begin(), sortedMoves.end(), [&base](const Move &a, const Move &b) { + return base.relativeFilePath(a.destination).compare(base.relativeFilePath(b.destination), Qt::CaseInsensitive) < 0; + }); + + // Maps a cumulative relative directory path to its folder item. + QHash folders; + + for (const Move &move : sortedMoves) { + const QString relative = base.relativeFilePath(move.destination); + const QStringList segments = relative.split(QLatin1Char('/'), Qt::SkipEmptyParts); + if (segments.isEmpty()) + continue; + + QTreeWidgetItem *parent = nullptr; + QString cumulative; + // Build/reuse the folder nodes for every segment except the last (the file). + for (int i = 0; i < segments.size() - 1; ++i) { + cumulative += (cumulative.isEmpty() ? QString() : QStringLiteral("/")) + segments.at(i); + QTreeWidgetItem *&folderItem = folders[cumulative]; + if (folderItem == nullptr) { + folderItem = parent ? new QTreeWidgetItem(parent) : new QTreeWidgetItem(tree); + folderItem->setText(0, segments.at(i)); + folderItem->setIcon(0, folderIcon); + folderItem->setFlags(folderItem->flags() | Qt::ItemIsEditable); + } + parent = folderItem; + } + + QTreeWidgetItem *fileItem = parent ? new QTreeWidgetItem(parent) : new QTreeWidgetItem(tree); + fileItem->setText(0, segments.last()); + fileItem->setIcon(0, fileIcon); + fileItem->setFlags(fileItem->flags() | Qt::ItemIsEditable); + fileItem->setData(0, SourceRole, move.source); + + const QString sourceRelative = libraryRoot.isEmpty() ? move.source : QDir(libraryRoot).relativeFilePath(move.source); + fileItem->setText(1, sourceRelative); + fileItem->setToolTip(1, move.source); + } +} + +bool OrganizeFilesPreviewDialog::isFileItem(QTreeWidgetItem *item) const +{ + return item != nullptr && item->data(0, SourceRole).isValid(); +} + +void OrganizeFilesPreviewDialog::pruneEmptyAncestors(QTreeWidgetItem *item) +{ + // Delete folder nodes that no longer hold any files, walking up the tree. + while (item != nullptr && item->childCount() == 0 && !isFileItem(item)) { + QTreeWidgetItem *parent = item->parent(); + delete item; + item = parent; + } +} + +void OrganizeFilesPreviewDialog::removeSelectedItems() +{ + const QList selected = tree->selectedItems(); + if (selected.isEmpty()) + return; + + const QSet selectedSet(selected.begin(), selected.end()); + + // Only delete the top-most selected items; children of an already-selected + // item would be deleted along with their parent. + QList toDelete; + QList parents; + for (QTreeWidgetItem *item : selected) { + bool ancestorSelected = false; + for (QTreeWidgetItem *ancestor = item->parent(); ancestor != nullptr; ancestor = ancestor->parent()) { + if (selectedSet.contains(ancestor)) { + ancestorSelected = true; + break; + } + } + if (!ancestorSelected) { + toDelete.append(item); + parents.append(item->parent()); + } + } + + for (QTreeWidgetItem *item : toDelete) + delete item; + + for (QTreeWidgetItem *parent : parents) + pruneEmptyAncestors(parent); + + updateActionsState(); +} + +void OrganizeFilesPreviewDialog::updateActionsState() +{ + const bool hasSelection = !tree->selectedItems().isEmpty(); + removeAction->setEnabled(hasSelection); + if (removeButton != nullptr) + removeButton->setEnabled(hasSelection); + + bool hasFiles = false; + QTreeWidgetItemIterator it(tree); + while (*it) { + if (isFileItem(*it)) { + hasFiles = true; + break; + } + ++it; + } + if (okButton != nullptr) + okButton->setEnabled(hasFiles); +} + +QString OrganizeFilesPreviewDialog::relativePathForItem(QTreeWidgetItem *item) const +{ + QStringList segments; + for (QTreeWidgetItem *node = item; node != nullptr; node = node->parent()) { + const QString clean = OrganizeFilesDialog::sanitizeSegment(node->text(0)); + if (!clean.isEmpty()) + segments.prepend(clean); + } + return segments.join(QLatin1Char('/')); +} + +QList OrganizeFilesPreviewDialog::moves() const +{ + QList result; + + QTreeWidgetItemIterator it(tree); + while (*it) { + QTreeWidgetItem *item = *it; + ++it; + + // Leaves (files) carry the source path. + if (item->childCount() != 0) + continue; + const QVariant sourceData = item->data(0, SourceRole); + if (!sourceData.isValid()) + continue; + + const QString relative = relativePathForItem(item); + if (relative.isEmpty()) + continue; + + Move move; + move.source = sourceData.toString(); + move.destination = QDir::cleanPath(baseRoot + QLatin1Char('/') + relative); + result.append(move); + } + + return result; +} diff --git a/YACReaderLibrary/organize_files_preview_dialog.h b/YACReaderLibrary/organize_files_preview_dialog.h new file mode 100644 index 000000000..c35bf3bc4 --- /dev/null +++ b/YACReaderLibrary/organize_files_preview_dialog.h @@ -0,0 +1,50 @@ +#ifndef ORGANIZE_FILES_PREVIEW_DIALOG_H +#define ORGANIZE_FILES_PREVIEW_DIALOG_H + +#include +#include +#include + +class QAction; +class QPushButton; +class QTreeWidget; +class QTreeWidgetItem; + +class OrganizeFilesPreviewDialog : public QDialog +{ + Q_OBJECT +public: + struct Move { + QString source; + QString destination; + }; + + OrganizeFilesPreviewDialog(const QString &baseRoot, + const QString &libraryRoot, + const QList &moves, + QWidget *parent = nullptr); + + QList moves() const; + +private slots: + void removeSelectedItems(); + void updateActionsState(); + +private: + QString baseRoot; + QString libraryRoot; + QTreeWidget *tree; + QAction *removeAction; + QPushButton *removeButton; + QPushButton *okButton; + + void setupUI(const QList &moves); + void buildTree(const QList &moves); + QString relativePathForItem(QTreeWidgetItem *item) const; + bool isFileItem(QTreeWidgetItem *item) const; + void pruneEmptyAncestors(QTreeWidgetItem *item); + + static constexpr int SourceRole = Qt::UserRole + 1; +}; + +#endif // ORGANIZE_FILES_PREVIEW_DIALOG_H