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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions YACReaderLibrary/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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
Expand Down
142 changes: 142 additions & 0 deletions YACReaderLibrary/library_window.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
#include <QApplication>
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QFileDialog>
#include <QFileIconProvider>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QInputDialog>
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -2629,6 +2632,144 @@ void LibraryWindow::openContainingFolder()
QDesktopServices::openUrl(QUrl("file:///" + path, QUrl::TolerantMode));
}

static void collectComicsRecursively(qulonglong libraryId, qulonglong folderId, QList<ComicDB> &out)
{
const auto comics = DBHelper::getFolderComicsFromLibrary(libraryId, folderId);
for (auto *item : comics) {
if (auto *comic = static_cast<ComicDB *>(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);
}
}

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(libraryRoot, folderAbsolutePath, settings, this);
if (dialog.exec() != QDialog::Accepted)
return;

const QString pattern = dialog.formatPattern();
if (pattern.trimmed().isEmpty())
return;

QList<ComicDB> 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;
}

struct Move {
QString source;
QString destination;
};
QList<Move> moves;
const QDir destinationRoot(dialog.relativeToRoot() ? libraryRoot : 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;

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;
}

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()));
}

updateFolder(sourceIndex);
}

void LibraryWindow::setFolderAsNotCompleted()
{
// foldersModel->updateFolderCompletedStatus(foldersView->selectionModel()->selectedRows(),false);
Expand Down Expand Up @@ -2983,6 +3124,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);
Expand Down
1 change: 1 addition & 0 deletions YACReaderLibrary/library_window.h
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ public slots:
void startLibraryRepair(bool removeStaleLock);
// void deleteLibrary();
void openContainingFolder();
void organizeFiles();
void setFolderAsNotCompleted();
void setFolderAsCompleted();
void setFolderAsRead();
Expand Down
7 changes: 7 additions & 0 deletions YACReaderLibrary/library_window_actions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -401,6 +404,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);
Expand Down Expand Up @@ -481,6 +485,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);

Expand Down Expand Up @@ -610,6 +615,7 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho
<< expandAllNodesAction
<< colapseAllNodesAction
<< openContainingFolderAction
<< organizeFilesAction
<< setFolderAsNotCompletedAction
<< setFolderAsCompletedAction
<< setFolderAsReadAction
Expand Down Expand Up @@ -743,6 +749,7 @@ void LibraryWindowActions::disableFoldersActions(bool disabled)
colapseAllNodesAction->setDisabled(disabled);

openContainingFolderAction->setDisabled(disabled);
organizeFilesAction->setDisabled(disabled);

updateFolderAction->setDisabled(disabled);
rescanXMLFromCurrentFolderAction->setDisabled(disabled);
Expand Down
1 change: 1 addition & 0 deletions YACReaderLibrary/library_window_actions.h
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class LibraryWindowActions
QAction *colapseAllNodesAction;

QAction *openContainingFolderAction;
QAction *organizeFilesAction;
QAction *saveCoversToAction;
//--
QAction *setFolderAsNotCompletedAction;
Expand Down
158 changes: 158 additions & 0 deletions YACReaderLibrary/organize_files_dialog.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#include "organize_files_dialog.h"

#include "yacreader_global.h"

#include <QCheckBox>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileInfo>
#include <QLabel>
#include <QLineEdit>
#include <QSettings>
#include <QVBoxLayout>

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));
}

static QString 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::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();
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());

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;
}
Loading