diff --git a/YACReaderLibrary/CMakeLists.txt b/YACReaderLibrary/CMakeLists.txt index f5377460b..30de497c4 100644 --- a/YACReaderLibrary/CMakeLists.txt +++ b/YACReaderLibrary/CMakeLists.txt @@ -90,6 +90,10 @@ qt_add_executable(YACReaderLibrary WIN32 add_library_dialog.cpp rename_library_dialog.h rename_library_dialog.cpp + organize_files_dialog.h + organize_files_dialog.cpp + organize_files_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 bb3e7fcd8..4a2da984b 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -6,9 +6,12 @@ #include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -17,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -64,6 +68,8 @@ #include "library_creator.h" #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" @@ -1505,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); @@ -2629,6 +2636,203 @@ 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); +} + +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); + } +} + +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(); + if (!sourceIndex.isValid()) + return; + + const auto libraryId = libraries.getId(selectedLibrary->currentText()); + const auto folder = foldersModel->getFolder(sourceIndex); + const QString folderAbsolutePath = QDir::cleanPath(currentPath() + foldersModel->getFolderPath(sourceIndex)); + + 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; + } + + 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; + 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); + const QFileInfo sourceInfo(source); + if (!sourceInfo.exists()) + continue; + + 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(), + comic.info.number.toString(), + comic.info.title.toString(), + comic.info.volume.toString(), + comic.info.year.toString(), + extension, + numberPadding); + + QString destination = QDir::cleanPath(destinationRoot.absoluteFilePath(relative)); + if (destination == QDir::cleanPath(source)) + continue; + + 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 false; + } + + 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 : finalMoves) { + 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; + } + + 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(finalMoves.size()) + .arg(failures.size())); + } + + return moved > 0; +} + void LibraryWindow::setFolderAsNotCompleted() { // foldersModel->updateFolderCompletedStatus(foldersView->selectionModel()->selectedRows(),false); @@ -2983,6 +3187,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 5a0ba6143..be70e0a24 100644 --- a/YACReaderLibrary/library_window.h +++ b/YACReaderLibrary/library_window.h @@ -250,6 +250,8 @@ public slots: void startLibraryRepair(bool removeStaleLock); // void deleteLibrary(); void openContainingFolder(); + void organizeFiles(); + void organizeComicsFiles(); void setFolderAsNotCompleted(); void setFolderAsCompleted(); void setFolderAsRead(); @@ -329,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 d6b634d20..05f7bb014 100644 --- a/YACReaderLibrary/library_window_actions.cpp +++ b/YACReaderLibrary/library_window_actions.cpp @@ -228,6 +228,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); @@ -290,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); @@ -401,6 +407,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); @@ -417,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 @@ -476,11 +484,13 @@ 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); 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); @@ -592,6 +602,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << setMangaAction << setNormalAction << openContainingFolderComicAction + << organizeComicsFilesAction << resetComicRatingAction << selectAllComicsAction << editSelectedComicsAction @@ -610,6 +621,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho << expandAllNodesAction << colapseAllNodesAction << openContainingFolderAction + << organizeFilesAction << setFolderAsNotCompletedAction << setFolderAsCompletedAction << setFolderAsReadAction @@ -703,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); @@ -743,6 +756,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 4c60580ff..af0b72d31 100644 --- a/YACReaderLibrary/library_window_actions.h +++ b/YACReaderLibrary/library_window_actions.h @@ -63,6 +63,7 @@ class LibraryWindowActions QAction *colapseAllNodesAction; QAction *openContainingFolderAction; + QAction *organizeFilesAction; QAction *saveCoversToAction; //-- QAction *setFolderAsNotCompletedAction; @@ -81,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 new file mode 100644 index 000000000..388b3f2fc --- /dev/null +++ b/YACReaderLibrary/organize_files_dialog.cpp @@ -0,0 +1,179 @@ +#include "organize_files_dialog.h" + +#include "yacreader_global.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +OrganizeFilesDialog::OrganizeFilesDialog(const QString &libraryRoot, + const QString &selectedFolderPath, + QSettings *settings, + QWidget *parent) + : QDialog(parent), libraryRoot(libraryRoot), selectedFolderPath(selectedFolderPath), settings(settings) +{ + 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); + + 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); + + 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(relativeToRootCheck); + 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(); +} + +bool OrganizeFilesDialog::relativeToRoot() const +{ + return relativeToRootCheck->isChecked(); +} + +void OrganizeFilesDialog::updatePreview() +{ + 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)); +} + +QString OrganizeFilesDialog::sanitizeSegment(QString segment) +{ + static const QString invalid = QStringLiteral("<>:\"/\\|?*"); + for (QChar &c : segment) { + if (invalid.contains(c) || c < QChar(0x20)) + c = QLatin1Char('_'); + } + segment = segment.simplified(); + while (segment.endsWith(QLatin1Char('.')) || segment.endsWith(QLatin1Char(' '))) + segment.chop(1); + 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, + const QString &number, + const QString &title, + const QString &volume, + const QString &year, + 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(); + 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}"), padNumber(number, numberPadding)); + result.replace(QStringLiteral("{title}"), effectiveTitle); + result.replace(QStringLiteral("{volume}"), volume.trimmed()); + result.replace(QStringLiteral("{year}"), year.trimmed()); + + 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..e87e5320b --- /dev/null +++ b/YACReaderLibrary/organize_files_dialog.h @@ -0,0 +1,77 @@ +#ifndef ORGANIZE_FILES_DIALOG_H +#define ORGANIZE_FILES_DIALOG_H + +#include + +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: + // 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, + const QString &number, + const QString &title, + const QString &volume, + const QString &year, + 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: + void updatePreview(); + +private: + QLineEdit *patternEdit; + QLabel *previewLabel; + QCheckBox *relativeToRootCheck; + + QString libraryRoot; + QString selectedFolderPath; + QSettings *settings; + + void setupUI(); +}; + +#endif // ORGANIZE_FILES_DIALOG_H 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 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"