From 935f0be187a255e07cfc3fc36c487d660e25d7fa Mon Sep 17 00:00:00 2001 From: Andrey Raspopov Date: Mon, 15 Jun 2026 16:12:06 +0200 Subject: [PATCH 1/5] 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 fac73e197..b2b3761a5 100644 --- a/YACReaderLibrary/CMakeLists.txt +++ b/YACReaderLibrary/CMakeLists.txt @@ -92,6 +92,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 5184109be..5b1dbbb29 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 @@ -64,6 +66,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" @@ -2797,6 +2800,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); @@ -3152,6 +3298,7 @@ void LibraryWindow::showFoldersContextMenu(const QPoint &point) menu.addAction(actions.openContainingFolderAction); menu.addAction(actions.renameFolderAction); + 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 f838e3e66..354ecf770 100644 --- a/YACReaderLibrary/library_window.h +++ b/YACReaderLibrary/library_window.h @@ -259,6 +259,7 @@ public slots: void startLibraryRepair(bool removeStaleLock); // 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 7bf3c5ad2..a7e4c6bfd 100644 --- a/YACReaderLibrary/library_window_actions.cpp +++ b/YACReaderLibrary/library_window_actions.cpp @@ -232,6 +232,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); @@ -405,6 +408,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); @@ -484,6 +488,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); @@ -615,6 +620,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << expandAllNodesAction << colapseAllNodesAction << openContainingFolderAction + << organizeFilesAction << setFolderAsNotCompletedAction << setFolderAsCompletedAction << setFolderAsReadAction @@ -753,6 +759,7 @@ void LibraryWindowActions::disableFoldersActions(bool disabled) colapseAllNodesAction->setDisabled(disabled); openContainingFolderAction->setDisabled(disabled); + organizeFilesAction->setDisabled(disabled); renameFolderAction->setDisabled(disabled); updateFolderAction->setDisabled(disabled); diff --git a/YACReaderLibrary/library_window_actions.h b/YACReaderLibrary/library_window_actions.h index d47b7431a..8750e7b49 100644 --- a/YACReaderLibrary/library_window_actions.h +++ b/YACReaderLibrary/library_window_actions.h @@ -65,6 +65,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 7d6f7ff1aa087f19773e19c5b9802bcd744aef98 Mon Sep 17 00:00:00 2001 From: Anthony Harmitage Date: Mon, 20 Jul 2026 14:37:23 +0200 Subject: [PATCH 2/5] 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 5b1dbbb29..ab9b9a36e 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -2816,7 +2816,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); @@ -2824,7 +2823,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); } } @@ -2839,7 +2838,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; @@ -2855,13 +2854,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); @@ -2882,9 +2880,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(); @@ -2928,7 +2925,6 @@ void LibraryWindow::organizeFiles() failures << move.source; } - // Clean up directories that became empty after moving files out of them. removeEmptyDirs(folderAbsolutePath); if (!failures.isEmpty()) { @@ -2939,7 +2935,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 917787641ddb68052da9e7de37ff7a021f5739d0 Mon Sep 17 00:00:00 2001 From: Anthony Harmitage Date: Mon, 27 Jul 2026 18:48:43 +0200 Subject: [PATCH 3/5] 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 b2b3761a5..4e9c8724d 100644 --- a/YACReaderLibrary/CMakeLists.txt +++ b/YACReaderLibrary/CMakeLists.txt @@ -94,6 +94,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 ab9b9a36e..969fd6649 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" @@ -1689,6 +1692,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.editSelectedComicsAction); @@ -2827,6 +2831,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(); @@ -2835,17 +2856,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); @@ -2854,12 +2866,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); @@ -2869,6 +2930,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(), @@ -2876,44 +2939,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; @@ -2925,17 +2988,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 354ecf770..ba215ad85 100644 --- a/YACReaderLibrary/library_window.h +++ b/YACReaderLibrary/library_window.h @@ -260,6 +260,7 @@ public slots: // void deleteLibrary(); void openContainingFolder(); void organizeFiles(); + void organizeComicsFiles(); void setFolderAsNotCompleted(); void setFolderAsCompleted(); void setFolderAsRead(); @@ -339,6 +340,7 @@ public slots: void reloadCurrentFolderComicsContent(); void reloadAfterCopyMove(const QModelIndex &mi); QModelIndex getCurrentFolderIndex(); + bool runOrganizeFilesFlow(const QList &comics, const QString &cleanupPath); void enableNeededActions(); void setComicActionsDisabled(bool disabled); void setComicToolbarEntriesVisible(bool visible); diff --git a/YACReaderLibrary/library_window_actions.cpp b/YACReaderLibrary/library_window_actions.cpp index a7e4c6bfd..5cf770d93 100644 --- a/YACReaderLibrary/library_window_actions.cpp +++ b/YACReaderLibrary/library_window_actions.cpp @@ -297,6 +297,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 rating")); resetComicRatingAction->setData(RESET_COMIC_RATING_ACTION_YL); @@ -425,6 +428,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 @@ -483,6 +487,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); @@ -601,6 +606,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << setMangaAction << setNormalAction << openContainingFolderComicAction + << organizeComicsFilesAction << resetComicRatingAction << selectAllComicsAction << editSelectedComicsAction @@ -720,6 +726,7 @@ void LibraryWindowActions::setComicSelectionActionsEnabled(bool enabled) deleteMetadataAction->setEnabled(enabled); deleteComicsAction->setEnabled(enabled); openContainingFolderComicAction->setEnabled(enabled); + organizeComicsFilesAction->setEnabled(enabled); resetComicRatingAction->setEnabled(enabled); getInfoAction->setEnabled(enabled); addToMenuAction->setEnabled(enabled); diff --git a/YACReaderLibrary/library_window_actions.h b/YACReaderLibrary/library_window_actions.h index 8750e7b49..4bf1e4e8a 100644 --- a/YACReaderLibrary/library_window_actions.h +++ b/YACReaderLibrary/library_window_actions.h @@ -84,6 +84,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 From e95e03e55d830a66df96d58f39678c9160cbb835 Mon Sep 17 00:00:00 2001 From: Anthony Harmitage Date: Mon, 20 Jul 2026 14:37:23 +0200 Subject: [PATCH 4/5] Organize files relative to the library root --- YACReaderLibrary/organize_files_dialog.h | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/YACReaderLibrary/organize_files_dialog.h b/YACReaderLibrary/organize_files_dialog.h index b4cd7c7e7..e87e5320b 100644 --- a/YACReaderLibrary/organize_files_dialog.h +++ b/YACReaderLibrary/organize_files_dialog.h @@ -8,21 +8,40 @@ 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: + // 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(); + // 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. When numberPadding is + // greater than zero the {number} token is zero-padded to that width. static QString buildRelativePath(const QString &pattern, const QString &publisher, const QString &series, @@ -33,8 +52,11 @@ class OrganizeFilesDialog : public QDialog const QString &extension, int numberPadding = 0); + // Replaces characters that are invalid in a path segment and trims it. static QString sanitizeSegment(QString segment); + // Zero-pads the leading digits of an issue number to at least "width" + // characters (e.g. "1" -> "01"). Non-numeric prefixes are left untouched. static QString padNumber(const QString &number, int width); private slots: From 1402d9a22b5c80b2c064229e4e5237502525cec8 Mon Sep 17 00:00:00 2001 From: luisangelsm Date: Sat, 22 Aug 2026 10:36:55 +0200 Subject: [PATCH 5/5] Add a feature flag to control the organize features --- YACReaderLibrary/CMakeLists.txt | 1 + YACReaderLibrary/feature_flags.h | 12 +++ YACReaderLibrary/library_window.cpp | 7 +- YACReaderLibrary/library_window_actions.cpp | 93 ++++++++++++--------- 4 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 YACReaderLibrary/feature_flags.h diff --git a/YACReaderLibrary/CMakeLists.txt b/YACReaderLibrary/CMakeLists.txt index 4e9c8724d..a0f8aa000 100644 --- a/YACReaderLibrary/CMakeLists.txt +++ b/YACReaderLibrary/CMakeLists.txt @@ -86,6 +86,7 @@ qt_add_executable(YACReaderLibrary WIN32 library_window.cpp library_window_actions.h library_window_actions.cpp + feature_flags.h create_library_dialog.h create_library_dialog.cpp add_library_dialog.h diff --git a/YACReaderLibrary/feature_flags.h b/YACReaderLibrary/feature_flags.h new file mode 100644 index 000000000..275b312c1 --- /dev/null +++ b/YACReaderLibrary/feature_flags.h @@ -0,0 +1,12 @@ +#ifndef YACREADER_LIBRARY_FEATURE_FLAGS_H +#define YACREADER_LIBRARY_FEATURE_FLAGS_H + +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; + +} // namespace YACReader::FeatureFlags + +#endif // YACREADER_LIBRARY_FEATURE_FLAGS_H diff --git a/YACReaderLibrary/library_window.cpp b/YACReaderLibrary/library_window.cpp index 969fd6649..afe5615f4 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -57,6 +57,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_model.h" #include "grid_comics_view.h" @@ -1692,7 +1693,8 @@ void LibraryWindow::showComicsContextMenu(const QPoint &point, bool showFullScre menu->addAction(actions.saveCoversToAction); menu->addSeparator(); menu->addAction(actions.openContainingFolderComicAction); - menu->addAction(actions.organizeComicsFilesAction); + if (YACReader::FeatureFlags::organizeFiles) + menu->addAction(actions.organizeComicsFilesAction); menu->addAction(actions.updateCurrentFolderAction); menu->addSeparator(); menu->addAction(actions.editSelectedComicsAction); @@ -3356,7 +3358,8 @@ void LibraryWindow::showFoldersContextMenu(const QPoint &point) menu.addAction(actions.openContainingFolderAction); menu.addAction(actions.renameFolderAction); - menu.addAction(actions.organizeFilesAction); + if (YACReader::FeatureFlags::organizeFiles) + menu.addAction(actions.organizeFilesAction); menu.addAction(actions.updateFolderAction); menu.addSeparator(); //------------------------------- menu.addAction(actions.rescanXMLFromCurrentFolderAction); diff --git a/YACReaderLibrary/library_window_actions.cpp b/YACReaderLibrary/library_window_actions.cpp index 5cf770d93..af818f2ed 100644 --- a/YACReaderLibrary/library_window_actions.cpp +++ b/YACReaderLibrary/library_window_actions.cpp @@ -2,6 +2,7 @@ #include "edit_shortcuts_dialog.h" #include "export_library_dialog.h" +#include "feature_flags.h" #include "help_about_dialog.h" #include "library_window.h" #include "recent_visibility_coordinator.h" @@ -234,6 +235,7 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti organizeFilesAction = new QAction(window); organizeFilesAction->setText(tr("Organize files")); + organizeFilesAction->setVisible(YACReader::FeatureFlags::organizeFiles); setFolderAsNotCompletedAction = new QAction(window); setFolderAsNotCompletedAction->setText(tr("Set as uncompleted")); @@ -299,6 +301,7 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti organizeComicsFilesAction = new QAction(window); organizeComicsFilesAction->setText(tr("Organize files")); + organizeComicsFilesAction->setVisible(YACReader::FeatureFlags::organizeFiles); resetComicRatingAction = new QAction(window); resetComicRatingAction->setText(tr("Reset rating")); @@ -411,7 +414,8 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti // actions not asigned to any widget window->addAction(saveCoversToAction); window->addAction(openContainingFolderAction); - window->addAction(organizeFilesAction); + if (YACReader::FeatureFlags::organizeFiles) + window->addAction(organizeFilesAction); window->addAction(updateCurrentFolderAction); window->addAction(resetComicRatingAction); window->addAction(setFolderAsCompletedAction); @@ -428,7 +432,8 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti window->addAction(deleteMetadataAction); window->addAction(rescanXMLFromCurrentFolderAction); window->addAction(openContainingFolderComicAction); - window->addAction(organizeComicsFilesAction); + if (YACReader::FeatureFlags::organizeFiles) + window->addAction(organizeComicsFilesAction); #ifndef Q_OS_MACOS window->addAction(toggleFullScreenAction); #endif @@ -487,13 +492,15 @@ void LibraryWindowActions::createConnections( // ContextMenus QObject::connect(openContainingFolderComicAction, &QAction::triggered, window, &LibraryWindow::openContainingFolderComic); - QObject::connect(organizeComicsFilesAction, &QAction::triggered, window, &LibraryWindow::organizeComicsFiles); + if (YACReader::FeatureFlags::organizeFiles) + 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); QObject::connect(setFolderAsUnreadAction, &QAction::triggered, window, &LibraryWindow::setFolderAsUnread); QObject::connect(openContainingFolderAction, &QAction::triggered, window, &LibraryWindow::openContainingFolder); - QObject::connect(organizeFilesAction, &QAction::triggered, window, &LibraryWindow::organizeFiles); + if (YACReader::FeatureFlags::organizeFiles) + QObject::connect(organizeFilesAction, &QAction::triggered, window, &LibraryWindow::organizeFiles); QObject::connect(setFolderCoverAction, &QAction::triggered, window, &LibraryWindow::setFolderCover); QObject::connect(deleteCustomFolderCoverAction, &QAction::triggered, window, &LibraryWindow::deleteCustomFolderCover); @@ -597,46 +604,50 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho // Get current theme for initial icons const auto &theme = ThemeManager::instance().getCurrentTheme(); - editShortcutsDialog->addActionsGroup("Comics", theme.shortcutsIcons.comicsIcon, - tmpList = QList() - << openComicAction - << saveCoversToAction - << setAsReadAction - << setAsNonReadAction - << setMangaAction - << setNormalAction - << openContainingFolderComicAction - << organizeComicsFilesAction - << resetComicRatingAction - << selectAllComicsAction - << editSelectedComicsAction - << asignOrderAction - << deleteMetadataAction - << deleteComicsAction - << getInfoAction); + tmpList = QList() + << openComicAction + << saveCoversToAction + << setAsReadAction + << setAsNonReadAction + << setMangaAction + << setNormalAction + << openContainingFolderComicAction + << organizeComicsFilesAction + << resetComicRatingAction + << selectAllComicsAction + << editSelectedComicsAction + << asignOrderAction + << deleteMetadataAction + << deleteComicsAction + << getInfoAction; + if (!YACReader::FeatureFlags::organizeFiles) + tmpList.removeOne(organizeComicsFilesAction); + editShortcutsDialog->addActionsGroup("Comics", theme.shortcutsIcons.comicsIcon, tmpList); allActions << tmpList; - editShortcutsDialog->addActionsGroup("Folders", theme.shortcutsIcons.foldersIcon, - tmpList = QList() - << addFolderAction - << renameFolderAction - << deleteFolderAction - << setRootIndexAction - << expandAllNodesAction - << colapseAllNodesAction - << openContainingFolderAction - << organizeFilesAction - << setFolderAsNotCompletedAction - << setFolderAsCompletedAction - << setFolderAsReadAction - << setFolderAsUnreadAction - << setFolderAsMangaAction - << setFolderAsNormalAction - << updateCurrentFolderAction - << rescanXMLFromCurrentFolderAction - << setFolderCoverAction - << deleteCustomFolderCoverAction); + tmpList = QList() + << addFolderAction + << renameFolderAction + << deleteFolderAction + << setRootIndexAction + << expandAllNodesAction + << colapseAllNodesAction + << openContainingFolderAction + << organizeFilesAction + << setFolderAsNotCompletedAction + << setFolderAsCompletedAction + << setFolderAsReadAction + << setFolderAsUnreadAction + << setFolderAsMangaAction + << setFolderAsNormalAction + << updateCurrentFolderAction + << rescanXMLFromCurrentFolderAction + << setFolderCoverAction + << deleteCustomFolderCoverAction; + if (!YACReader::FeatureFlags::organizeFiles) + tmpList.removeOne(organizeFilesAction); + editShortcutsDialog->addActionsGroup("Folders", theme.shortcutsIcons.foldersIcon, tmpList); allActions << tmpList; editShortcutsDialog->addActionsGroup("Lists", theme.shortcutsIcons.foldersIcon, // TODO change icon