From 3f48d85b77755ca2808b7d6bf5ce4b9c61e8437b Mon Sep 17 00:00:00 2001 From: Andrey Raspopov Date: Mon, 15 Jun 2026 16:12:06 +0200 Subject: [PATCH 1/2] 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/2] 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"