From bb4aa8ccbfeee72fd7c57ddf35eca9f05aae7616 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 14:54:04 -0500 Subject: [PATCH 01/18] Add CBL reading list parser --- YACReaderLibrary/cbl_reader.h | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 YACReaderLibrary/cbl_reader.h diff --git a/YACReaderLibrary/cbl_reader.h b/YACReaderLibrary/cbl_reader.h new file mode 100644 index 000000000..b5e2745e1 --- /dev/null +++ b/YACReaderLibrary/cbl_reader.h @@ -0,0 +1,38 @@ +#ifndef CBL_READER_H +#define CBL_READER_H + +#include +#include + +struct CblBook +{ + QString series; + QString number; + QString volume; + QString year; + QString id; + int ordering = 0; +}; + +struct CblReadingList +{ + QString name; + QList books; +}; + +struct CblReadResult +{ + bool success = false; + CblReadingList readingList; + QString errorMessage; + qint64 errorLine = 0; + qint64 errorColumn = 0; +}; + +class CblReader +{ +public: + static CblReadResult read(const QString &filePath); +}; + +#endif // CBL_READER_H From 4274f51c1700e36c29c831490ecc5bc4c05ada37 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 14:54:13 -0500 Subject: [PATCH 02/18] Implement CBL reading list parser --- YACReaderLibrary/cbl_reader.cpp | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 YACReaderLibrary/cbl_reader.cpp diff --git a/YACReaderLibrary/cbl_reader.cpp b/YACReaderLibrary/cbl_reader.cpp new file mode 100644 index 000000000..cd517efd7 --- /dev/null +++ b/YACReaderLibrary/cbl_reader.cpp @@ -0,0 +1,75 @@ +#include "cbl_reader.h" + +#include +#include +#include + +CblReadResult CblReader::read(const QString &filePath) +{ + CblReadResult result; + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + result.errorMessage = file.errorString(); + return result; + } + + QXmlStreamReader xml(&file); + bool foundReadingList = false; + int ordering = 0; + + while (!xml.atEnd()) { + xml.readNext(); + + if (!xml.isStartElement()) + continue; + + if (!foundReadingList) { + if (xml.name() != QLatin1String("ReadingList")) { + result.errorMessage = QStringLiteral("The selected file is not a CBL reading list."); + return result; + } + foundReadingList = true; + continue; + } + + if (xml.name() == QLatin1String("Name")) { + result.readingList.name = xml.readElementText().trimmed(); + } else if (xml.name() == QLatin1String("Book")) { + const auto attributes = xml.attributes(); + + CblBook book; + book.series = attributes.value(QLatin1String("Series")).toString().trimmed(); + book.number = attributes.value(QLatin1String("Number")).toString().trimmed(); + book.volume = attributes.value(QLatin1String("Volume")).toString().trimmed(); + book.year = attributes.value(QLatin1String("Year")).toString().trimmed(); + book.ordering = ordering++; + + while (!(xml.isEndElement() && xml.name() == QLatin1String("Book")) && !xml.atEnd()) { + xml.readNext(); + if (xml.isStartElement() && xml.name() == QLatin1String("Id")) + book.id = xml.readElementText().trimmed(); + } + + result.readingList.books.append(book); + } + } + + if (xml.hasError()) { + result.errorMessage = xml.errorString(); + result.errorLine = xml.lineNumber(); + result.errorColumn = xml.columnNumber(); + return result; + } + + if (!foundReadingList) { + result.errorMessage = QStringLiteral("The selected file does not contain a ReadingList element."); + return result; + } + + if (result.readingList.name.isEmpty()) + result.readingList.name = QFileInfo(filePath).completeBaseName(); + + result.success = true; + return result; +} From 2f96f8c8a23e8b0b5f059efdda263769118185ad Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 14:54:26 -0500 Subject: [PATCH 03/18] Add CBL import action declaration --- YACReaderLibrary/library_window_actions.h | 1 + 1 file changed, 1 insertion(+) diff --git a/YACReaderLibrary/library_window_actions.h b/YACReaderLibrary/library_window_actions.h index 2c896c2af..4bc602755 100644 --- a/YACReaderLibrary/library_window_actions.h +++ b/YACReaderLibrary/library_window_actions.h @@ -130,6 +130,7 @@ class LibraryWindowActions // reading lists actions QAction *addReadingListAction; + QAction *importCblReadingListAction; QAction *deleteReadingListAction; QAction *addLabelAction; QAction *renameListAction; From c65950d59a7930ba1f2bffad1314a43185b00dda Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 14:54:32 -0500 Subject: [PATCH 04/18] Add CBL import coordinator slot --- YACReaderLibrary/reading_list_management_coordinator.h | 1 + 1 file changed, 1 insertion(+) diff --git a/YACReaderLibrary/reading_list_management_coordinator.h b/YACReaderLibrary/reading_list_management_coordinator.h index d41181efc..0129aa2a3 100644 --- a/YACReaderLibrary/reading_list_management_coordinator.h +++ b/YACReaderLibrary/reading_list_management_coordinator.h @@ -24,6 +24,7 @@ class ReadingListManagementCoordinator : public QObject public slots: void addReadingList(); + void importCblReadingList(); void deleteCurrentList(); void addLabel(); void renameCurrentList(); From 4b3f85897a25b7922f2f89ae2ddc78497fc226cc Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 14:54:45 -0500 Subject: [PATCH 05/18] Add initial CBL import preview --- .../reading_list_management_coordinator.cpp | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/YACReaderLibrary/reading_list_management_coordinator.cpp b/YACReaderLibrary/reading_list_management_coordinator.cpp index a691097da..f382a80d1 100644 --- a/YACReaderLibrary/reading_list_management_coordinator.cpp +++ b/YACReaderLibrary/reading_list_management_coordinator.cpp @@ -1,9 +1,11 @@ #include "reading_list_management_coordinator.h" #include "add_label_dialog.h" +#include "cbl_reader.h" #include "comic_model.h" #include "reading_list_model.h" +#include #include #include #include @@ -44,6 +46,54 @@ void ReadingListManagementCoordinator::addReadingList() listsModel->addReadingList(name); } +void ReadingListManagementCoordinator::importCblReadingList() +{ + const auto filePath = QFileDialog::getOpenFileName(dialogParent, + tr("Import CBL reading list"), + { }, + tr("Comic Book Reading Lists (*.cbl);;XML files (*.xml);;All files (*)")); + if (filePath.isEmpty()) + return; + + const auto result = CblReader::read(filePath); + if (!result.success) { + QString details = result.errorMessage; + if (result.errorLine > 0) + details += tr("\n\nLine %1, column %2").arg(result.errorLine).arg(result.errorColumn); + + QMessageBox::critical(dialogParent, tr("Unable to import CBL"), details); + return; + } + + QString preview; + const int previewLimit = 20; + const int count = result.readingList.books.size(); + + for (int i = 0; i < count && i < previewLimit; ++i) { + const auto &book = result.readingList.books.at(i); + preview += QStringLiteral("%1. %2 #%3") + .arg(i + 1) + .arg(book.series.isEmpty() ? tr("Unknown series") : book.series) + .arg(book.number.isEmpty() ? QStringLiteral("?") : book.number); + + if (!book.volume.isEmpty()) + preview += tr(" (Vol. %1)").arg(book.volume); + if (!book.year.isEmpty()) + preview += tr(" [%1]").arg(book.year); + preview += QLatin1Char('\n'); + } + + if (count > previewLimit) + preview += tr("\n...and %1 more entries.").arg(count - previewLimit); + + QMessageBox::information(dialogParent, + tr("CBL reading list parsed"), + tr("%1\n\n%2 entries found.\n\n%3\nThis is a preview only. No changes have been made to your library yet.") + .arg(result.readingList.name) + .arg(count) + .arg(preview)); +} + void ReadingListManagementCoordinator::deleteCurrentList() { const auto currentList = currentListProvider(); From db1a22ff10c6f68e4cd0bf39fb49f6b957d8e8e1 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 15:10:50 -0500 Subject: [PATCH 06/18] Make CBL parser header-only for initial integration --- YACReaderLibrary/cbl_reader.h | 73 ++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/YACReaderLibrary/cbl_reader.h b/YACReaderLibrary/cbl_reader.h index b5e2745e1..00eb05cb6 100644 --- a/YACReaderLibrary/cbl_reader.h +++ b/YACReaderLibrary/cbl_reader.h @@ -1,8 +1,11 @@ #ifndef CBL_READER_H #define CBL_READER_H +#include +#include #include #include +#include struct CblBook { @@ -32,7 +35,75 @@ struct CblReadResult class CblReader { public: - static CblReadResult read(const QString &filePath); + static CblReadResult read(const QString &filePath) + { + CblReadResult result; + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + result.errorMessage = file.errorString(); + return result; + } + + QXmlStreamReader xml(&file); + bool foundReadingList = false; + int ordering = 0; + + while (!xml.atEnd()) { + xml.readNext(); + + if (!xml.isStartElement()) + continue; + + if (!foundReadingList) { + if (xml.name() != QLatin1String("ReadingList")) { + result.errorMessage = QStringLiteral("The selected file is not a CBL reading list."); + return result; + } + foundReadingList = true; + continue; + } + + if (xml.name() == QLatin1String("Name")) { + result.readingList.name = xml.readElementText().trimmed(); + } else if (xml.name() == QLatin1String("Book")) { + const auto attributes = xml.attributes(); + + CblBook book; + book.series = attributes.value(QLatin1String("Series")).toString().trimmed(); + book.number = attributes.value(QLatin1String("Number")).toString().trimmed(); + book.volume = attributes.value(QLatin1String("Volume")).toString().trimmed(); + book.year = attributes.value(QLatin1String("Year")).toString().trimmed(); + book.ordering = ordering++; + + while (!(xml.isEndElement() && xml.name() == QLatin1String("Book")) && !xml.atEnd()) { + xml.readNext(); + if (xml.isStartElement() && xml.name() == QLatin1String("Id")) + book.id = xml.readElementText().trimmed(); + } + + result.readingList.books.append(book); + } + } + + if (xml.hasError()) { + result.errorMessage = xml.errorString(); + result.errorLine = xml.lineNumber(); + result.errorColumn = xml.columnNumber(); + return result; + } + + if (!foundReadingList) { + result.errorMessage = QStringLiteral("The selected file does not contain a ReadingList element."); + return result; + } + + if (result.readingList.name.isEmpty()) + result.readingList.name = QFileInfo(filePath).completeBaseName(); + + result.success = true; + return result; + } }; #endif // CBL_READER_H From da8d7c342c5d0059a229050686d0a3cef9ec7a43 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 15:10:56 -0500 Subject: [PATCH 07/18] Remove standalone CBL parser source for initial integration --- YACReaderLibrary/cbl_reader.cpp | 75 --------------------------------- 1 file changed, 75 deletions(-) delete mode 100644 YACReaderLibrary/cbl_reader.cpp diff --git a/YACReaderLibrary/cbl_reader.cpp b/YACReaderLibrary/cbl_reader.cpp deleted file mode 100644 index cd517efd7..000000000 --- a/YACReaderLibrary/cbl_reader.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include "cbl_reader.h" - -#include -#include -#include - -CblReadResult CblReader::read(const QString &filePath) -{ - CblReadResult result; - - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - result.errorMessage = file.errorString(); - return result; - } - - QXmlStreamReader xml(&file); - bool foundReadingList = false; - int ordering = 0; - - while (!xml.atEnd()) { - xml.readNext(); - - if (!xml.isStartElement()) - continue; - - if (!foundReadingList) { - if (xml.name() != QLatin1String("ReadingList")) { - result.errorMessage = QStringLiteral("The selected file is not a CBL reading list."); - return result; - } - foundReadingList = true; - continue; - } - - if (xml.name() == QLatin1String("Name")) { - result.readingList.name = xml.readElementText().trimmed(); - } else if (xml.name() == QLatin1String("Book")) { - const auto attributes = xml.attributes(); - - CblBook book; - book.series = attributes.value(QLatin1String("Series")).toString().trimmed(); - book.number = attributes.value(QLatin1String("Number")).toString().trimmed(); - book.volume = attributes.value(QLatin1String("Volume")).toString().trimmed(); - book.year = attributes.value(QLatin1String("Year")).toString().trimmed(); - book.ordering = ordering++; - - while (!(xml.isEndElement() && xml.name() == QLatin1String("Book")) && !xml.atEnd()) { - xml.readNext(); - if (xml.isStartElement() && xml.name() == QLatin1String("Id")) - book.id = xml.readElementText().trimmed(); - } - - result.readingList.books.append(book); - } - } - - if (xml.hasError()) { - result.errorMessage = xml.errorString(); - result.errorLine = xml.lineNumber(); - result.errorColumn = xml.columnNumber(); - return result; - } - - if (!foundReadingList) { - result.errorMessage = QStringLiteral("The selected file does not contain a ReadingList element."); - return result; - } - - if (result.readingList.name.isEmpty()) - result.readingList.name = QFileInfo(filePath).completeBaseName(); - - result.success = true; - return result; -} From e0809a442cf021af5df81dc3b248d51feee613b4 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 15:11:13 -0500 Subject: [PATCH 08/18] Expose Phase 1 CBL importer with application shortcut --- .../reading_list_management_coordinator.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/YACReaderLibrary/reading_list_management_coordinator.cpp b/YACReaderLibrary/reading_list_management_coordinator.cpp index f382a80d1..2f0dd52a7 100644 --- a/YACReaderLibrary/reading_list_management_coordinator.cpp +++ b/YACReaderLibrary/reading_list_management_coordinator.cpp @@ -5,8 +5,10 @@ #include "comic_model.h" #include "reading_list_model.h" +#include #include #include +#include #include #include #include @@ -22,6 +24,15 @@ ReadingListManagementCoordinator::ReadingListManagementCoordinator(QWidget *dial connect(listsModel, &ReadingListModel::addComicsToFavorites, comicsModel, QOverload &>::of(&ComicModel::addComicsToFavorites)); connect(listsModel, &ReadingListModel::addComicsToLabel, comicsModel, QOverload &, qulonglong>::of(&ComicModel::addComicsToLabel)); connect(listsModel, &ReadingListModel::addComicsToReadingList, comicsModel, QOverload &, qulonglong>::of(&ComicModel::addComicsToReadingList)); + + // Phase 1: expose the importer immediately without touching the database. + // The permanent toolbar action will replace this temporary shortcut once the + // import preview and matching flow are settled. + auto *importCblAction = new QAction(tr("Import CBL Reading List..."), dialogParent); + importCblAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+I"))); + importCblAction->setShortcutContext(Qt::ApplicationShortcut); + dialogParent->addAction(importCblAction); + connect(importCblAction, &QAction::triggered, this, &ReadingListManagementCoordinator::importCblReadingList); } void ReadingListManagementCoordinator::addReadingList() From b3b07cb89d3c66e4c345363207faa707db31bd3b Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 15:11:52 -0500 Subject: [PATCH 09/18] Expose library database path for CBL matching --- YACReaderLibrary/db/reading_list_model.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/YACReaderLibrary/db/reading_list_model.h b/YACReaderLibrary/db/reading_list_model.h index 7f5efb515..d95525098 100644 --- a/YACReaderLibrary/db/reading_list_model.h +++ b/YACReaderLibrary/db/reading_list_model.h @@ -54,6 +54,7 @@ class ReadingListModel : public QAbstractItemModel void rename(const QModelIndex &mi, const QString &name); void deleteItem(const QModelIndex &mi); const QList getLabels(); + QString databasePath() const { return _databasePath; } enum Roles { TypeListsRole = Qt::UserRole + 1, @@ -116,4 +117,4 @@ class ReadingListModel : public QAbstractItemModel Q_DECLARE_METATYPE(ReadingListModel::TypeSpecialList) -#endif // READING_LIST_MODEL_H +#endif // READING_LIST_MODEL_H \ No newline at end of file From 57d461879c8a92fed8f299b0dea124489ae7fc76 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 15:12:36 -0500 Subject: [PATCH 10/18] Add native CBL matching preview against library metadata --- .../reading_list_management_coordinator.cpp | 205 ++++++++++++++++-- 1 file changed, 191 insertions(+), 14 deletions(-) diff --git a/YACReaderLibrary/reading_list_management_coordinator.cpp b/YACReaderLibrary/reading_list_management_coordinator.cpp index 2f0dd52a7..2a736b654 100644 --- a/YACReaderLibrary/reading_list_management_coordinator.cpp +++ b/YACReaderLibrary/reading_list_management_coordinator.cpp @@ -3,6 +3,7 @@ #include "add_label_dialog.h" #include "cbl_reader.h" #include "comic_model.h" +#include "data_base_management.h" #include "reading_list_model.h" #include @@ -11,10 +12,121 @@ #include #include #include +#include +#include +#include +#include #include #include +namespace { +struct LibraryComicMatchData +{ + qulonglong id = 0; + QString fileName; + QString series; + QString number; + QString volume; +}; + +enum class CblMatchState { + Matched, + Missing, + Ambiguous +}; + +struct CblMatchResult +{ + CblMatchState state = CblMatchState::Missing; + QList candidates; +}; + +QString normalized(const QString &value) +{ + return value.simplified().toCaseFolded(); +} + +bool sameValue(const QString &left, const QString &right) +{ + return normalized(left) == normalized(right); +} + +CblMatchResult matchBook(const CblBook &book, const QList &libraryComics) +{ + CblMatchResult result; + + QList seriesNumberMatches; + QList exactMatches; + + for (const auto &comic : libraryComics) { + if (!sameValue(book.series, comic.series) || !sameValue(book.number, comic.number)) + continue; + + seriesNumberMatches.append(comic); + + if (!book.volume.isEmpty() && sameValue(book.volume, comic.volume)) + exactMatches.append(comic); + } + + if (!book.volume.isEmpty() && exactMatches.size() == 1) { + result.state = CblMatchState::Matched; + result.candidates = exactMatches; + return result; + } + + if (!book.volume.isEmpty() && exactMatches.size() > 1) { + result.state = CblMatchState::Ambiguous; + result.candidates = exactMatches; + return result; + } + + if (seriesNumberMatches.size() == 1) { + result.state = CblMatchState::Matched; + result.candidates = seriesNumberMatches; + return result; + } + + if (seriesNumberMatches.size() > 1) { + result.state = CblMatchState::Ambiguous; + result.candidates = seriesNumberMatches; + return result; + } + + // Metadata is preferred, but older libraries can have sparse ComicInfo data. + // Fall back to the user's filename convention: "Series #Number" with an + // optional leading zero and optional "(of N)" issue suffix. + if (!book.series.isEmpty() && !book.number.isEmpty()) { + const QString issuePattern = QStringLiteral("#0*%1(?:\\s*\\(of\\s+\\d+\\))?(?=\\D|$)") + .arg(QRegularExpression::escape(book.number)); + const QRegularExpression issueExpression(issuePattern, QRegularExpression::CaseInsensitiveOption); + const QString normalizedSeries = normalized(book.series); + + QList fileMatches; + for (const auto &comic : libraryComics) { + if (!normalized(comic.fileName).contains(normalizedSeries)) + continue; + if (issueExpression.match(comic.fileName).hasMatch()) + fileMatches.append(comic); + } + + if (fileMatches.size() == 1) { + result.state = CblMatchState::Matched; + result.candidates = fileMatches; + return result; + } + + if (fileMatches.size() > 1) { + result.state = CblMatchState::Ambiguous; + result.candidates = fileMatches; + return result; + } + } + + return result; +} +} + ReadingListManagementCoordinator::ReadingListManagementCoordinator(QWidget *dialogParent, ReadingListModel *listsModel, ComicModel *comicsModel, @@ -76,32 +188,97 @@ void ReadingListManagementCoordinator::importCblReadingList() return; } + if (listsModel->databasePath().isEmpty()) { + QMessageBox::warning(dialogParent, + tr("CBL import unavailable"), + tr("Open a library before importing a CBL reading list.")); + return; + } + + QList libraryComics; + QString databaseError; + QString connectionName; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + + QSqlQuery query(db); + if (!query.exec(QStringLiteral("SELECT c.id, c.fileName, ci.series, ci.number, ci.volume " + "FROM comic c " + "INNER JOIN comic_info ci ON c.comicInfoId = ci.id"))) { + databaseError = query.lastError().text(); + } else { + while (query.next()) { + LibraryComicMatchData comic; + comic.id = query.value(0).toULongLong(); + comic.fileName = query.value(1).toString(); + comic.series = query.value(2).toString(); + comic.number = query.value(3).toString(); + comic.volume = query.value(4).toString(); + libraryComics.append(comic); + } + } + } + QSqlDatabase::removeDatabase(connectionName); + + if (!databaseError.isEmpty()) { + QMessageBox::critical(dialogParent, + tr("Unable to inspect library"), + tr("YACReader could not read comic metadata for CBL matching.\n\n%1").arg(databaseError)); + return; + } + QString preview; - const int previewLimit = 20; + const int previewLimit = 30; const int count = result.readingList.books.size(); + int matchedCount = 0; + int missingCount = 0; + int ambiguousCount = 0; - for (int i = 0; i < count && i < previewLimit; ++i) { + for (int i = 0; i < count; ++i) { const auto &book = result.readingList.books.at(i); - preview += QStringLiteral("%1. %2 #%3") - .arg(i + 1) - .arg(book.series.isEmpty() ? tr("Unknown series") : book.series) - .arg(book.number.isEmpty() ? QStringLiteral("?") : book.number); - - if (!book.volume.isEmpty()) - preview += tr(" (Vol. %1)").arg(book.volume); - if (!book.year.isEmpty()) - preview += tr(" [%1]").arg(book.year); - preview += QLatin1Char('\n'); + const auto match = matchBook(book, libraryComics); + + if (match.state == CblMatchState::Matched) + ++matchedCount; + else if (match.state == CblMatchState::Ambiguous) + ++ambiguousCount; + else + ++missingCount; + + if (i >= previewLimit) + continue; + + const QString bookName = QStringLiteral("%1 #%2") + .arg(book.series.isEmpty() ? tr("Unknown series") : book.series) + .arg(book.number.isEmpty() ? QStringLiteral("?") : book.number); + + if (match.state == CblMatchState::Matched) { + preview += tr("%1. [MATCHED] %2 -> %3\n") + .arg(i + 1) + .arg(bookName) + .arg(match.candidates.constFirst().fileName); + } else if (match.state == CblMatchState::Ambiguous) { + preview += tr("%1. [AMBIGUOUS] %2 -> %3 possible matches\n") + .arg(i + 1) + .arg(bookName) + .arg(match.candidates.size()); + } else { + preview += tr("%1. [MISSING] %2\n").arg(i + 1).arg(bookName); + } } if (count > previewLimit) preview += tr("\n...and %1 more entries.").arg(count - previewLimit); QMessageBox::information(dialogParent, - tr("CBL reading list parsed"), - tr("%1\n\n%2 entries found.\n\n%3\nThis is a preview only. No changes have been made to your library yet.") + tr("CBL import preview"), + tr("%1\n\n%2 entries\n%3 matched\n%4 missing\n%5 ambiguous\n\n%6\n\nPreview only. No changes have been made to your library yet.") .arg(result.readingList.name) .arg(count) + .arg(matchedCount) + .arg(missingCount) + .arg(ambiguousCount) .arg(preview)); } From 64a75eade758329ec834994ed96e60820cd8b201 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Tue, 1 Sep 2026 23:45:57 -0500 Subject: [PATCH 11/18] Add transactional CBL reading list import storage --- .../reading_list_management_coordinator.cpp | 225 +++++++++++++++++- 1 file changed, 213 insertions(+), 12 deletions(-) diff --git a/YACReaderLibrary/reading_list_management_coordinator.cpp b/YACReaderLibrary/reading_list_management_coordinator.cpp index 2a736b654..b5373ee9e 100644 --- a/YACReaderLibrary/reading_list_management_coordinator.cpp +++ b/YACReaderLibrary/reading_list_management_coordinator.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -31,9 +32,9 @@ struct LibraryComicMatchData }; enum class CblMatchState { - Matched, - Missing, - Ambiguous + Matched = 0, + Missing = 1, + Ambiguous = 2 }; struct CblMatchResult @@ -42,6 +43,12 @@ struct CblMatchResult QList candidates; }; +struct MatchedCblEntry +{ + CblBook book; + CblMatchResult match; +}; + QString normalized(const QString &value) { return value.simplified().toCaseFolded(); @@ -125,6 +132,154 @@ CblMatchResult matchBook(const CblBook &book, const QList return result; } + +bool execSql(QSqlQuery &query, QString *error) +{ + if (query.exec()) + return true; + if (error) + *error = query.lastError().text(); + return false; +} + +bool ensureCblImportTables(QSqlDatabase &db, QString *error) +{ + QSqlQuery meta(db); + meta.prepare(QStringLiteral( + "CREATE TABLE IF NOT EXISTS cbl_reading_list_meta (" + "reading_list_id INTEGER PRIMARY KEY, " + "source_name TEXT, " + "imported_at INTEGER NOT NULL, " + "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE)")); + if (!execSql(meta, error)) + return false; + + QSqlQuery entries(db); + entries.prepare(QStringLiteral( + "CREATE TABLE IF NOT EXISTS cbl_reading_list_entry (" + "id INTEGER PRIMARY KEY, " + "reading_list_id INTEGER NOT NULL, " + "ordering INTEGER NOT NULL, " + "comic_id INTEGER, " + "series TEXT, " + "number TEXT, " + "volume TEXT, " + "year TEXT, " + "source_id TEXT, " + "match_state INTEGER NOT NULL, " + "candidate_count INTEGER NOT NULL DEFAULT 0, " + "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE, " + "FOREIGN KEY(comic_id) REFERENCES comic(id) ON DELETE SET NULL, " + "UNIQUE(reading_list_id, ordering))")); + if (!execSql(entries, error)) + return false; + + QSqlQuery index(db); + index.prepare(QStringLiteral( + "CREATE INDEX IF NOT EXISTS cbl_reading_list_entry_ordering_index " + "ON cbl_reading_list_entry(reading_list_id, ordering)")); + return execSql(index, error); +} + +bool persistCblReadingList(QSqlDatabase &db, + const CblReadingList &readingList, + const QList &entries, + const QString &sourceName, + qulonglong *readingListId, + QString *error) +{ + if (!db.transaction()) { + if (error) + *error = db.lastError().text(); + return false; + } + + auto rollback = [&db, error](const QString &message) { + db.rollback(); + if (error) + *error = message; + return false; + }; + + if (!ensureCblImportTables(db, error)) { + const auto message = error ? *error : QStringLiteral("Unable to create CBL import tables."); + return rollback(message); + } + + QSqlQuery createList(db); + createList.prepare(QStringLiteral("INSERT INTO reading_list (name) VALUES (:name)")); + createList.bindValue(QStringLiteral(":name"), readingList.name); + if (!createList.exec()) + return rollback(createList.lastError().text()); + + const qulonglong newReadingListId = createList.lastInsertId().toULongLong(); + if (newReadingListId == 0) + return rollback(QStringLiteral("YACReader did not return an id for the new reading list.")); + + QSqlQuery meta(db); + meta.prepare(QStringLiteral( + "INSERT INTO cbl_reading_list_meta (reading_list_id, source_name, imported_at) " + "VALUES (:reading_list_id, :source_name, :imported_at)")); + meta.bindValue(QStringLiteral(":reading_list_id"), newReadingListId); + meta.bindValue(QStringLiteral(":source_name"), sourceName); + meta.bindValue(QStringLiteral(":imported_at"), QDateTime::currentSecsSinceEpoch()); + if (!meta.exec()) + return rollback(meta.lastError().text()); + + QSet linkedComicIds; + + for (const auto &entry : entries) { + qulonglong comicId = 0; + if (entry.match.state == CblMatchState::Matched && !entry.match.candidates.isEmpty()) + comicId = entry.match.candidates.constFirst().id; + + QSqlQuery insertEntry(db); + insertEntry.prepare(QStringLiteral( + "INSERT INTO cbl_reading_list_entry " + "(reading_list_id, ordering, comic_id, series, number, volume, year, source_id, match_state, candidate_count) " + "VALUES (:reading_list_id, :ordering, :comic_id, :series, :number, :volume, :year, :source_id, :match_state, :candidate_count)")); + insertEntry.bindValue(QStringLiteral(":reading_list_id"), newReadingListId); + insertEntry.bindValue(QStringLiteral(":ordering"), entry.book.ordering); + if (comicId != 0) + insertEntry.bindValue(QStringLiteral(":comic_id"), comicId); + else + insertEntry.bindValue(QStringLiteral(":comic_id"), QVariant()); + insertEntry.bindValue(QStringLiteral(":series"), entry.book.series); + insertEntry.bindValue(QStringLiteral(":number"), entry.book.number); + insertEntry.bindValue(QStringLiteral(":volume"), entry.book.volume); + insertEntry.bindValue(QStringLiteral(":year"), entry.book.year); + insertEntry.bindValue(QStringLiteral(":source_id"), entry.book.id); + insertEntry.bindValue(QStringLiteral(":match_state"), static_cast(entry.match.state)); + insertEntry.bindValue(QStringLiteral(":candidate_count"), entry.match.candidates.size()); + if (!insertEntry.exec()) + return rollback(insertEntry.lastError().text()); + + // Keep matched entries visible to existing YACReader builds. Imported CBL + // metadata remains authoritative for ordering and missing placeholders. + // The legacy relation cannot contain the same comic twice in one list, + // so a duplicate CBL occurrence is retained in cbl_reading_list_entry but + // linked only once here. + if (comicId != 0 && !linkedComicIds.contains(comicId)) { + QSqlQuery link(db); + link.prepare(QStringLiteral( + "INSERT INTO comic_reading_list (reading_list_id, comic_id, ordering) " + "VALUES (:reading_list_id, :comic_id, :ordering)")); + link.bindValue(QStringLiteral(":reading_list_id"), newReadingListId); + link.bindValue(QStringLiteral(":comic_id"), comicId); + link.bindValue(QStringLiteral(":ordering"), entry.book.ordering); + if (!link.exec()) + return rollback(link.lastError().text()); + linkedComicIds.insert(comicId); + } + } + + if (!db.commit()) + return rollback(db.lastError().text()); + + if (readingListId) + *readingListId = newReadingListId; + return true; +} } ReadingListManagementCoordinator::ReadingListManagementCoordinator(QWidget *dialogParent, @@ -137,9 +292,6 @@ ReadingListManagementCoordinator::ReadingListManagementCoordinator(QWidget *dial connect(listsModel, &ReadingListModel::addComicsToLabel, comicsModel, QOverload &, qulonglong>::of(&ComicModel::addComicsToLabel)); connect(listsModel, &ReadingListModel::addComicsToReadingList, comicsModel, QOverload &, qulonglong>::of(&ComicModel::addComicsToReadingList)); - // Phase 1: expose the importer immediately without touching the database. - // The permanent toolbar action will replace this temporary shortcut once the - // import preview and matching flow are settled. auto *importCblAction = new QAction(tr("Import CBL Reading List..."), dialogParent); importCblAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+I"))); importCblAction->setShortcutContext(Qt::ApplicationShortcut); @@ -234,10 +386,13 @@ void ReadingListManagementCoordinator::importCblReadingList() int matchedCount = 0; int missingCount = 0; int ambiguousCount = 0; + QList matchedEntries; + matchedEntries.reserve(count); for (int i = 0; i < count; ++i) { const auto &book = result.readingList.books.at(i); const auto match = matchBook(book, libraryComics); + matchedEntries.append({ book, match }); if (match.state == CblMatchState::Matched) ++matchedCount; @@ -271,15 +426,61 @@ void ReadingListManagementCoordinator::importCblReadingList() if (count > previewLimit) preview += tr("\n...and %1 more entries.").arg(count - previewLimit); + const auto answer = QMessageBox::question( + dialogParent, + tr("Import CBL reading list"), + tr("%1\n\n%2 entries\n%3 matched\n%4 missing\n%5 ambiguous\n\n%6\n\n" + "Import this reading list? Matched comics will be added immediately. " + "Missing and ambiguous entries will be preserved for placeholder/manual matching support.") + .arg(result.readingList.name) + .arg(count) + .arg(matchedCount) + .arg(missingCount) + .arg(ambiguousCount) + .arg(preview), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + + if (answer != QMessageBox::Yes) + return; + + QString importError; + qulonglong readingListId = 0; + connectionName.clear(); + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + if (!db.isOpen()) { + importError = tr("Unable to open the library database for writing."); + } else { + persistCblReadingList(db, + result.readingList, + matchedEntries, + QFileInfo(filePath).fileName(), + &readingListId, + &importError); + } + } + if (!connectionName.isEmpty()) + QSqlDatabase::removeDatabase(connectionName); + + if (!importError.isEmpty() || readingListId == 0) { + QMessageBox::critical(dialogParent, + tr("CBL import failed"), + tr("No partial import was kept. The database transaction was rolled back.\n\n%1") + .arg(importError.isEmpty() ? tr("Unknown database error.") : importError)); + return; + } + + listsModel->setupReadingListsData(listsModel->databasePath()); + QMessageBox::information(dialogParent, - tr("CBL import preview"), - tr("%1\n\n%2 entries\n%3 matched\n%4 missing\n%5 ambiguous\n\n%6\n\nPreview only. No changes have been made to your library yet.") + tr("CBL import complete"), + tr("%1 was imported.\n\n%2 matched comics are available in the reading list now. " + "%3 unresolved entries were preserved in CBL import storage for the next placeholder/manual-match step.") .arg(result.readingList.name) - .arg(count) .arg(matchedCount) - .arg(missingCount) - .arg(ambiguousCount) - .arg(preview)); + .arg(missingCount + ambiguousCount)); } void ReadingListManagementCoordinator::deleteCurrentList() From 434564b8dff7bac216f9697a16adf69de8437469 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Wed, 2 Sep 2026 09:37:57 -0500 Subject: [PATCH 12/18] Fix folder grid delegate bindings on newer Qt --- YACReaderLibrary/qml/FolderGridDelegate.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/YACReaderLibrary/qml/FolderGridDelegate.qml b/YACReaderLibrary/qml/FolderGridDelegate.qml index b62c92cee..9c4683dc9 100644 --- a/YACReaderLibrary/qml/FolderGridDelegate.qml +++ b/YACReaderLibrary/qml/FolderGridDelegate.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick // Delegate for GridContentModel folder rows; required properties intentionally match its role names. From 4dc9cc0116da959aadbd2038e3a94bf0c13e1616 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Wed, 2 Sep 2026 09:45:54 -0500 Subject: [PATCH 13/18] Revert folder delegate bound behavior experiment --- YACReaderLibrary/qml/FolderGridDelegate.qml | 2 -- 1 file changed, 2 deletions(-) diff --git a/YACReaderLibrary/qml/FolderGridDelegate.qml b/YACReaderLibrary/qml/FolderGridDelegate.qml index 9c4683dc9..b62c92cee 100644 --- a/YACReaderLibrary/qml/FolderGridDelegate.qml +++ b/YACReaderLibrary/qml/FolderGridDelegate.qml @@ -1,5 +1,3 @@ -pragma ComponentBehavior: Bound - import QtQuick // Delegate for GridContentModel folder rows; required properties intentionally match its role names. From 6c6d73218daab73a497b3dfcb938a3f7bbbdd00e Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Wed, 2 Sep 2026 14:01:45 -0500 Subject: [PATCH 14/18] Fix MSVC manifest embedding for Qt 6.9 builds --- YACReader/CMakeLists.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/YACReader/CMakeLists.txt b/YACReader/CMakeLists.txt index 7b993474f..4749342b1 100644 --- a/YACReader/CMakeLists.txt +++ b/YACReader/CMakeLists.txt @@ -273,7 +273,10 @@ target_link_libraries(YACReader PRIVATE if(WIN32) target_sources(YACReader PRIVATE icon.rc) if(MSVC) - target_link_options(YACReader PRIVATE "/MANIFESTINPUT:${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest") + target_link_options(YACReader PRIVATE + "/MANIFEST:EMBED" + "/MANIFESTINPUT:${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest" + ) endif() target_link_libraries(YACReader PRIVATE oleaut32 ole32 shell32 user32) endif() @@ -309,4 +312,4 @@ if(UNIX AND NOT APPLE) install(FILES "${PROJECT_SOURCE_DIR}/YACReader.svg" DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps) -endif() +endif() \ No newline at end of file From 1f65ff384865c4b036febf82be481a07df0a6e16 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Wed, 2 Sep 2026 14:06:31 -0500 Subject: [PATCH 15/18] Fix embedded manifest for library server --- YACReaderLibraryServer/CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/YACReaderLibraryServer/CMakeLists.txt b/YACReaderLibraryServer/CMakeLists.txt index 8341d9d9d..668f77865 100644 --- a/YACReaderLibraryServer/CMakeLists.txt +++ b/YACReaderLibraryServer/CMakeLists.txt @@ -72,7 +72,10 @@ endif() # Platform-specific if(WIN32) if(MSVC) - target_link_options(YACReaderLibraryServer PRIVATE "/MANIFESTINPUT:${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest") + target_link_options(YACReaderLibraryServer PRIVATE + "/MANIFEST:EMBED" + "/MANIFESTINPUT:${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest" + ) endif() target_link_libraries(YACReaderLibraryServer PRIVATE oleaut32 ole32 shell32 user32) endif() From 9067daf2a1547f3e75452adb698702da54b98961 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Wed, 2 Sep 2026 14:08:44 -0500 Subject: [PATCH 16/18] Use CMake manifest source handling on Windows --- YACReader/CMakeLists.txt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/YACReader/CMakeLists.txt b/YACReader/CMakeLists.txt index 4749342b1..1c80c9aae 100644 --- a/YACReader/CMakeLists.txt +++ b/YACReader/CMakeLists.txt @@ -271,13 +271,10 @@ target_link_libraries(YACReader PRIVATE # Platform-specific if(WIN32) - target_sources(YACReader PRIVATE icon.rc) - if(MSVC) - target_link_options(YACReader PRIVATE - "/MANIFEST:EMBED" - "/MANIFESTINPUT:${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest" - ) - endif() + target_sources(YACReader PRIVATE + icon.rc + ${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest + ) target_link_libraries(YACReader PRIVATE oleaut32 ole32 shell32 user32) endif() From 84df14f306b57ec04b53d72acb7d81c10658d826 Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Wed, 2 Sep 2026 14:08:58 -0500 Subject: [PATCH 17/18] Use CMake manifest source handling for library server --- YACReaderLibraryServer/CMakeLists.txt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/YACReaderLibraryServer/CMakeLists.txt b/YACReaderLibraryServer/CMakeLists.txt index 668f77865..e8bb18b52 100644 --- a/YACReaderLibraryServer/CMakeLists.txt +++ b/YACReaderLibraryServer/CMakeLists.txt @@ -71,12 +71,9 @@ endif() # Platform-specific if(WIN32) - if(MSVC) - target_link_options(YACReaderLibraryServer PRIVATE - "/MANIFEST:EMBED" - "/MANIFESTINPUT:${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest" - ) - endif() + target_sources(YACReaderLibraryServer PRIVATE + ${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest + ) target_link_libraries(YACReaderLibraryServer PRIVATE oleaut32 ole32 shell32 user32) endif() From 07e7020284e5625a61c198ece9a38271986193ef Mon Sep 17 00:00:00 2001 From: BSavage81 Date: Thu, 10 Sep 2026 20:15:08 -0500 Subject: [PATCH 18/18] Add CBL reading list import and smart lists --- YACReaderLibrary/CMakeLists.txt | 8 +- YACReaderLibrary/cbl_reader.h | 21 +- YACReaderLibrary/db/comic_model.cpp | 192 +- YACReaderLibrary/db/comic_model.h | 1 + YACReaderLibrary/db/reading_list_item.cpp | 23 +- YACReaderLibrary/db/reading_list_item.h | 6 +- YACReaderLibrary/db/reading_list_model.cpp | 57 +- YACReaderLibrary/db/reading_list_model.h | 9 +- YACReaderLibrary/db_helper.cpp | 130 +- YACReaderLibrary/db_helper.h | 2 + YACReaderLibrary/grid_content_model.cpp | 3 + YACReaderLibrary/grid_content_model.h | 3 +- YACReaderLibrary/library_window.cpp | 16 + YACReaderLibrary/library_window_actions.cpp | 55 + YACReaderLibrary/library_window_actions.h | 8 + YACReaderLibrary/qml/ComicGridDelegate.qml | 23 + .../reading_list_management_coordinator.cpp | 1827 ++++++++++++++++- .../reading_list_management_coordinator.h | 9 + 18 files changed, 2291 insertions(+), 102 deletions(-) diff --git a/YACReaderLibrary/CMakeLists.txt b/YACReaderLibrary/CMakeLists.txt index 805fb58a4..d99535fb2 100644 --- a/YACReaderLibrary/CMakeLists.txt +++ b/YACReaderLibrary/CMakeLists.txt @@ -577,10 +577,10 @@ endif() # Platform-specific if(WIN32) - target_sources(YACReaderLibrary PRIVATE icon.rc) - if(MSVC) - target_link_options(YACReaderLibrary PRIVATE "/MANIFESTINPUT:${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest") - endif() + target_sources(YACReaderLibrary PRIVATE + icon.rc + ${PROJECT_SOURCE_DIR}/cmake/windows/yacreader.manifest + ) target_link_libraries(YACReaderLibrary PRIVATE oleaut32 ole32 shell32 user32) endif() diff --git a/YACReaderLibrary/cbl_reader.h b/YACReaderLibrary/cbl_reader.h index 00eb05cb6..71a35e4ee 100644 --- a/YACReaderLibrary/cbl_reader.h +++ b/YACReaderLibrary/cbl_reader.h @@ -13,7 +13,11 @@ struct CblBook QString number; QString volume; QString year; + QString format; + QString fileName; QString id; + QString comicVineSeriesId; + QString comicVineIssueId; int ordering = 0; }; @@ -74,12 +78,27 @@ class CblReader book.number = attributes.value(QLatin1String("Number")).toString().trimmed(); book.volume = attributes.value(QLatin1String("Volume")).toString().trimmed(); book.year = attributes.value(QLatin1String("Year")).toString().trimmed(); + book.format = attributes.value(QLatin1String("Format")).toString().trimmed(); book.ordering = ordering++; while (!(xml.isEndElement() && xml.name() == QLatin1String("Book")) && !xml.atEnd()) { xml.readNext(); - if (xml.isStartElement() && xml.name() == QLatin1String("Id")) + if (!xml.isStartElement()) + continue; + + if (xml.name() == QLatin1String("Id")) { book.id = xml.readElementText().trimmed(); + } else if (xml.name() == QLatin1String("FileName")) { + book.fileName = xml.readElementText().trimmed(); + } else if (xml.name() == QLatin1String("Database")) { + const auto databaseAttributes = xml.attributes(); + const auto databaseName = databaseAttributes.value(QLatin1String("Name")).toString().trimmed(); + if (databaseName.compare(QLatin1String("cv"), Qt::CaseInsensitive) == 0 + || databaseName.compare(QLatin1String("comicvine"), Qt::CaseInsensitive) == 0) { + book.comicVineSeriesId = databaseAttributes.value(QLatin1String("Series")).toString().trimmed(); + book.comicVineIssueId = databaseAttributes.value(QLatin1String("Issue")).toString().trimmed(); + } + } } result.readingList.books.append(book); diff --git a/YACReaderLibrary/db/comic_model.cpp b/YACReaderLibrary/db/comic_model.cpp index bcaab8543..11f9248f9 100644 --- a/YACReaderLibrary/db/comic_model.cpp +++ b/YACReaderLibrary/db/comic_model.cpp @@ -10,6 +10,9 @@ #include "reading_list_model.h" #include "yacreader_global_gui.h" +#include +#include +#include #include #include #include @@ -271,6 +274,7 @@ QHash ComicModel::roleNames() const roles[SeriesRole] = "series"; roles[VolumeRole] = "volume"; roles[StoryArcRole] = "story_arc"; + roles[IsPlaceholderRole] = "is_placeholder"; return roles; } @@ -370,6 +374,8 @@ QVariant ComicModel::data(const QModelIndex &index, int role) const return item->data(Volume); else if (role == StoryArcRole) return item->data(StoryArc); + else if (role == IsPlaceholderRole) + return item->data(Id).toLongLong() < 0; if (role != Qt::DisplayRole) return QVariant(); @@ -397,6 +403,9 @@ Qt::ItemFlags ComicModel::flags(const QModelIndex &index) const { if (!index.isValid()) return { }; + const auto item = static_cast(index.internalPointer()); + if (item->data(Id).toLongLong() < 0) + return Qt::ItemIsEnabled | Qt::ItemIsSelectable; if (index.column() == ComicModel::Rating) return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled; @@ -521,6 +530,134 @@ QStringList ComicModel::getPaths(const QString &_source) #define COMIC_MODEL_QUERY_FIELDS "ci.number,ci.title,c.fileName,ci.numPages,c.id,c.parentId,c.path,ci.hash,ci.read,ci.currentPage,ci.rating,ci.hasBeenOpened,ci.date,ci.added,ci.type,ci.lastTimeOpened,ci.series,ci.volume,ci.storyArc" +namespace { +QString likePattern(const QString &value) +{ + return QStringLiteral("%") + value.trimmed() + QStringLiteral("%"); +} + +bool addTextRule(const QJsonObject &rules, const QString &key, const QStringList &columns, QStringList &where, QHash &binds) +{ + const QString value = rules.value(key).toString().trimmed(); + if (value.isEmpty()) + return false; + + QStringList matches; + const QString bindName = QStringLiteral(":%1").arg(key); + for (const auto &column : columns) + matches << QStringLiteral("COALESCE(%1,'') LIKE %2 COLLATE NOCASE").arg(column, bindName); + where << QStringLiteral("(%1)").arg(matches.join(QStringLiteral(" OR "))); + binds.insert(bindName, likePattern(value)); + return true; +} + +QString smartListSortOrder(const QJsonObject &rules) +{ + const QString direction = rules.value(QStringLiteral("sortDirection")).toString(QStringLiteral("asc")) == QStringLiteral("desc") + ? QStringLiteral("DESC") + : QStringLiteral("ASC"); + const QString textDirection = QStringLiteral("COLLATE NOCASE ") + direction; + const QString sortField = rules.value(QStringLiteral("sortField")).toString(QStringLiteral("series")); + + if (sortField == QStringLiteral("title")) + return QStringLiteral("COALESCE(ci.title,'') %1, c.fileName %1").arg(textDirection); + if (sortField == QStringLiteral("fileName")) + return QStringLiteral("c.fileName %1").arg(textDirection); + if (sortField == QStringLiteral("year")) + return QStringLiteral("CAST(substr(COALESCE(ci.date,''), -4) AS INTEGER) %1, COALESCE(ci.series,'') %2, CAST(ci.number AS REAL) %1, ci.number %2, c.fileName %2") + .arg(direction, textDirection); + if (sortField == QStringLiteral("added")) + return QStringLiteral("COALESCE(ci.added,0) %1, COALESCE(ci.series,'') %2, CAST(ci.number AS REAL) %1, ci.number %2, c.fileName %2") + .arg(direction, textDirection); + if (sortField == QStringLiteral("lastOpened")) + return QStringLiteral("COALESCE(ci.lastTimeOpened,0) %1, COALESCE(ci.series,'') %2, CAST(ci.number AS REAL) %1, ci.number %2, c.fileName %2") + .arg(direction, textDirection); + if (sortField == QStringLiteral("rating")) + return QStringLiteral("COALESCE(ci.rating,0) %1, COALESCE(ci.series,'') %2, CAST(ci.number AS REAL) %1, ci.number %2, c.fileName %2") + .arg(direction, textDirection); + if (sortField == QStringLiteral("publisher")) + return QStringLiteral("COALESCE(ci.publisher,'') %1, COALESCE(ci.series,'') %1, CAST(ci.number AS REAL) %2, ci.number %1, c.fileName %1") + .arg(textDirection, direction); + + return QStringLiteral("COALESCE(ci.series,'') %1, CAST(ci.number AS REAL) %2, ci.number %1, c.fileName %1") + .arg(textDirection, direction); +} + +bool prepareSmartListQuery(QSqlQuery &query, const QString &rulesJson) +{ + const auto document = QJsonDocument::fromJson(rulesJson.toUtf8()); + const QJsonObject rules = document.object(); + QStringList where; + QHash binds; + + addTextRule(rules, QStringLiteral("series"), { QStringLiteral("ci.series"), QStringLiteral("ci.alternateSeries") }, where, binds); + addTextRule(rules, QStringLiteral("title"), { QStringLiteral("ci.title") }, where, binds); + addTextRule(rules, QStringLiteral("publisher"), { QStringLiteral("ci.publisher"), QStringLiteral("ci.imprint") }, where, binds); + addTextRule(rules, QStringLiteral("storyArc"), { QStringLiteral("ci.storyArc"), QStringLiteral("ci.seriesGroup") }, where, binds); + addTextRule(rules, QStringLiteral("path"), { QStringLiteral("c.path"), QStringLiteral("c.fileName") }, where, binds); + addTextRule(rules, QStringLiteral("tag"), { QStringLiteral("ci.tags"), QStringLiteral("ci.characters"), QStringLiteral("ci.teams"), QStringLiteral("ci.locations"), QStringLiteral("ci.mainCharacterOrTeam") }, where, binds); + addTextRule(rules, QStringLiteral("creator"), { QStringLiteral("ci.writer"), QStringLiteral("ci.penciller"), QStringLiteral("ci.inker"), QStringLiteral("ci.colorist"), QStringLiteral("ci.letterer"), QStringLiteral("ci.coverArtist"), QStringLiteral("ci.editor") }, where, binds); + + const int readState = rules.value(QStringLiteral("readState")).toInt(0); + if (readState == 1) + where << QStringLiteral("COALESCE(ci.read,0) = 0"); + else if (readState == 2) + where << QStringLiteral("COALESCE(ci.read,0) <> 0"); + + const int openedState = rules.value(QStringLiteral("openedState")).toInt(0); + if (openedState == 1) + where << QStringLiteral("COALESCE(ci.hasBeenOpened,0) = 0"); + else if (openedState == 2) + where << QStringLiteral("COALESCE(ci.hasBeenOpened,0) <> 0"); + + const int ratingMin = rules.value(QStringLiteral("ratingMin")).toInt(0); + if (ratingMin > 0) { + where << QStringLiteral("COALESCE(ci.rating,0) >= :ratingMin"); + binds.insert(QStringLiteral(":ratingMin"), ratingMin); + } + + const int type = rules.value(QStringLiteral("type")).toInt(-1); + if (type >= 0) { + where << QStringLiteral("COALESCE(ci.type,0) = :type"); + binds.insert(QStringLiteral(":type"), type); + } + + const int yearFrom = rules.value(QStringLiteral("yearFrom")).toInt(0); + if (yearFrom > 0) { + where << QStringLiteral("CAST(substr(COALESCE(ci.date,''), -4) AS INTEGER) >= :yearFrom"); + binds.insert(QStringLiteral(":yearFrom"), yearFrom); + } + + const int yearTo = rules.value(QStringLiteral("yearTo")).toInt(0); + if (yearTo > 0) { + where << QStringLiteral("CAST(substr(COALESCE(ci.date,''), -4) AS INTEGER) <= :yearTo"); + binds.insert(QStringLiteral(":yearTo"), yearTo); + } + + const int addedWithinDays = rules.value(QStringLiteral("addedWithinDays")).toInt(0); + if (addedWithinDays > 0) { + where << QStringLiteral("COALESCE(ci.added,0) >= :addedAfter"); + binds.insert(QStringLiteral(":addedAfter"), QDateTime::currentSecsSinceEpoch() - qint64(addedWithinDays) * 86400); + } + + const int openedWithinDays = rules.value(QStringLiteral("openedWithinDays")).toInt(0); + if (openedWithinDays > 0) { + where << QStringLiteral("COALESCE(ci.lastTimeOpened,0) >= :openedAfter"); + binds.insert(QStringLiteral(":openedAfter"), QDateTime::currentSecsSinceEpoch() - qint64(openedWithinDays) * 86400); + } + + const QString whereClause = where.isEmpty() ? QString() : QStringLiteral("WHERE ") + where.join(QStringLiteral(" AND ")); + const QString orderClause = smartListSortOrder(rules); + query.prepare(QStringLiteral("SELECT " COMIC_MODEL_QUERY_FIELDS " " + "FROM comic c INNER JOIN comic_info ci ON (c.comicInfoId = ci.id) " + "%1 ORDER BY %2") + .arg(whereClause, orderClause)); + for (auto it = binds.constBegin(); it != binds.constEnd(); ++it) + query.bindValue(it.key(), it.value()); + return true; +} +} + QList ComicModel::createFolderModelData(unsigned long long folderId, const QString &databasePath) const { QList modelData; @@ -624,13 +761,58 @@ QList ComicModel::createReadingListData(unsigned long long parentRe enableResorting = ids.length() == 1; // only resorting if no sublists exist const auto &readingListIds = ids; + DBHelper::ensureReadingListEntries(db); + db.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS reading_list_smart (reading_list_id INTEGER PRIMARY KEY, rules_json TEXT NOT NULL, " + "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE)")); for (const auto id : readingListIds) { + DBHelper::relinkMissingReadingListEntries(db, id); QSqlQuery selectQuery(db); - selectQuery.prepare("SELECT " COMIC_MODEL_QUERY_FIELDS " " - "FROM comic c INNER JOIN comic_info ci ON (c.comicInfoId = ci.id) " - "INNER JOIN comic_reading_list crl ON (c.id == crl.comic_id) " - "WHERE crl.reading_list_id = :parentReadingList " - "ORDER BY crl.ordering"); + QSqlQuery smartList(db); + smartList.prepare("SELECT rules_json FROM reading_list_smart WHERE reading_list_id = :id"); + smartList.bindValue(":id", id); + const bool isSmartList = smartList.exec() && smartList.next(); + if (isSmartList) { + enableResorting = false; + prepareSmartListQuery(selectQuery, smartList.value(0).toString()); + selectQuery.exec(); + modelData << createModelDataForList(selectQuery); + continue; + } + + QSqlQuery tableCheck(db); + const bool hasCblTables = tableCheck.exec("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cbl_reading_list_meta'") + && tableCheck.next(); + bool isImportedCbl = false; + if (hasCblTables) { + QSqlQuery importedList(db); + importedList.prepare("SELECT 1 FROM cbl_reading_list_meta WHERE reading_list_id = :id"); + importedList.bindValue(":id", id); + isImportedCbl = importedList.exec() && importedList.next(); + } + + if (isImportedCbl) { + selectQuery.prepare( + "SELECT COALESCE(ci.number,e.number), ci.title, " + "CASE WHEN c.id IS NULL THEN '[MISSING]' ELSE c.fileName END, " + "COALESCE(ci.numPages,0), CASE WHEN c.id IS NULL THEN -e.id ELSE c.id END, c.parentId, c.path, ci.hash, " + "COALESCE(ci.read,0), COALESCE(ci.currentPage,0), COALESCE(ci.rating,0), COALESCE(ci.hasBeenOpened,0), " + "COALESCE(ci.date,e.year), ci.added, ci.type, ci.lastTimeOpened, COALESCE(ci.series,e.series), COALESCE(ci.volume,e.volume), ci.storyArc " + "FROM cbl_reading_list_entry e " + "LEFT JOIN comic c ON c.id = e.comic_id " + "LEFT JOIN comic_info ci ON c.comicInfoId = ci.id " + "WHERE e.reading_list_id = :parentReadingList ORDER BY e.ordering"); + } else { + selectQuery.prepare( + "SELECT COALESCE(ci.number,e.number), ci.title, " + "CASE WHEN c.id IS NULL THEN '[MISSING]' ELSE c.fileName END, " + "COALESCE(ci.numPages,0), CASE WHEN c.id IS NULL THEN -e.id ELSE c.id END, c.parentId, c.path, ci.hash, " + "COALESCE(ci.read,0), COALESCE(ci.currentPage,0), COALESCE(ci.rating,0), COALESCE(ci.hasBeenOpened,0), " + "COALESCE(ci.date,e.year), ci.added, ci.type, ci.lastTimeOpened, COALESCE(ci.series,e.series), COALESCE(ci.volume,e.volume), ci.storyArc " + "FROM reading_list_entry e " + "LEFT JOIN comic c ON c.id = e.comic_id " + "LEFT JOIN comic_info ci ON c.comicInfoId = ci.id " + "WHERE e.reading_list_id = :parentReadingList ORDER BY e.ordering"); + } selectQuery.bindValue(":parentReadingList", id); selectQuery.exec(); diff --git a/YACReaderLibrary/db/comic_model.h b/YACReaderLibrary/db/comic_model.h index c996bd93d..dfd5018df 100644 --- a/YACReaderLibrary/db/comic_model.h +++ b/YACReaderLibrary/db/comic_model.h @@ -72,6 +72,7 @@ class ComicModel : public QAbstractItemModel SeriesRole, VolumeRole, StoryArcRole, + IsPlaceholderRole, }; enum Mode { diff --git a/YACReaderLibrary/db/reading_list_item.cpp b/YACReaderLibrary/db/reading_list_item.cpp index 985fe4aad..7315f36e4 100644 --- a/YACReaderLibrary/db/reading_list_item.cpp +++ b/YACReaderLibrary/db/reading_list_item.cpp @@ -140,20 +140,21 @@ qulonglong LabelItem::getId() const //------------------------------------------------------ -ReadingListItem::ReadingListItem(const QList &data, ReadingListItem *p) - : ListItem(data), parent(p) +ReadingListItem::ReadingListItem(const QList &data, ReadingListItem *p, bool folder, bool smartList) + : ListItem(data), parent(p), folder(folder), smart(smartList) { } QIcon ReadingListItem::getIcon() const { const auto &theme = ThemeManager::instance().getCurrentTheme(); - if (parent->getId() == 0) - return theme.readingListIcons.listIcon; // top level list - else if (theme.sidebarIcons.useSystemFolderIcons) + if (isFolder() && theme.sidebarIcons.useSystemFolderIcons) return QFileIconProvider().icon(QFileIconProvider::Folder); - else + if (isFolder()) return theme.navigationTree.folderIcon; // sublist + if (isSmartList()) + return theme.readingListIcons.currentlyReadingIcon; + return theme.readingListIcons.listIcon; } int ReadingListItem::childCount() const @@ -261,6 +262,16 @@ QList ReadingListItem::children() return childItems; } +bool ReadingListItem::isFolder() const +{ + return folder || !childItems.isEmpty(); +} + +bool ReadingListItem::isSmartList() const +{ + return smart; +} + int ReadingListItem::row() const { if (parent) diff --git a/YACReaderLibrary/db/reading_list_item.h b/YACReaderLibrary/db/reading_list_item.h index 61bfbf6e5..e745e3d49 100644 --- a/YACReaderLibrary/db/reading_list_item.h +++ b/YACReaderLibrary/db/reading_list_item.h @@ -62,7 +62,7 @@ class LabelItem : public ListItem class ReadingListItem : public ListItem { public: - ReadingListItem(const QList &data, ReadingListItem *parent = nullptr); + ReadingListItem(const QList &data, ReadingListItem *parent = nullptr, bool folder = false, bool smartList = false); QIcon getIcon() const override; ReadingListItem *parent; int childCount() const; @@ -77,9 +77,13 @@ class ReadingListItem : public ListItem int getOrdering() const; void setOrdering(const int ordering); QList children(); + bool isFolder() const; + bool isSmartList() const; private: QList childItems; + bool folder; + bool smart; enum DataIndexes { Name, diff --git a/YACReaderLibrary/db/reading_list_model.cpp b/YACReaderLibrary/db/reading_list_model.cpp index 85b5f7297..dc6b7245e 100644 --- a/YACReaderLibrary/db/reading_list_model.cpp +++ b/YACReaderLibrary/db/reading_list_model.cpp @@ -79,6 +79,12 @@ QVariant ReadingListModel::data(const QModelIndex &index, int role) const return item->getId(); } + if (role == ReadingListModel::IsFolderRole && typeid(*item) == typeid(ReadingListItem)) + return static_cast(item)->isFolder(); + + if (role == ReadingListModel::IsSmartListRole && typeid(*item) == typeid(ReadingListItem)) + return static_cast(item)->isSmartList(); + if (role == ReadingListModel::SpecialListTypeRole && typeid(*item) == typeid(SpecialListItem)) { auto specialListItem = static_cast(item); return QVariant::fromValue(specialListItem->getType()); @@ -277,6 +283,8 @@ bool ReadingListModel::dropComics(const QMimeData *data, Qt::DropAction action, } if (rowIsReadingList(dest.row(), parentDest)) { + if (dest.data(IsSmartListRole).toBool()) + return false; QLOG_DEBUG() << "///////////addComicsToReadingList : " << comicIds << " to " << dest.data(IDRole).toULongLong(); emit addComicsToReadingList(comicIds, dest.data(IDRole).toULongLong()); return true; @@ -423,6 +431,24 @@ void ReadingListModel::addReadingList(const QString &name) QSqlDatabase::removeDatabase(connectionName); } +void ReadingListModel::addReadingListFolder(const QString &name) +{ + QString connectionName; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(_databasePath); + db.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS reading_list_folder (reading_list_id INTEGER PRIMARY KEY, " + "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE)")); + const qulonglong id = DBHelper::insertReadingList(name, db); + QSqlQuery markFolder(db); + markFolder.prepare(QStringLiteral("INSERT INTO reading_list_folder (reading_list_id) VALUES (:id)")); + markFolder.bindValue(QStringLiteral(":id"), id); + markFolder.exec(); + connectionName = db.connectionName(); + } + QSqlDatabase::removeDatabase(connectionName); + setupReadingListsData(_databasePath); +} + void ReadingListModel::addReadingListAt(const QString &name, const QModelIndex &mi) { QString connectionName = ""; @@ -480,6 +506,20 @@ bool ReadingListModel::isReadingSubList(const QModelIndex &mi) return false; } +bool ReadingListModel::isReadingListFolder(const QModelIndex &mi) +{ + if (!isReadingList(mi)) + return false; + return static_cast(mi.internalPointer())->isFolder(); +} + +bool ReadingListModel::isSmartList(const QModelIndex &mi) +{ + if (!isReadingList(mi)) + return false; + return static_cast(mi.internalPointer())->isSmartList(); +} + QString ReadingListModel::name(const QModelIndex &mi) { return data(mi, Qt::DisplayRole).toString(); @@ -584,6 +624,8 @@ void ReadingListModel::setupReadingListsData(QSqlQuery &sqlquery, ReadingListIte int completed = record.indexOf("completed"); int ordering = record.indexOf("ordering"); int parentId = record.indexOf("parentId"); + int isFolder = record.indexOf("isFolder"); + int isSmartList = record.indexOf("isSmartList"); while (sqlquery.next()) { ReadingListItem *rli = new ReadingListItem(QList() @@ -591,7 +633,10 @@ void ReadingListModel::setupReadingListsData(QSqlQuery &sqlquery, ReadingListIte << sqlquery.value(id) << sqlquery.value(finished) << sqlquery.value(completed) - << sqlquery.value(ordering)); + << sqlquery.value(ordering), + nullptr, + isFolder >= 0 && sqlquery.value(isFolder).toBool(), + isSmartList >= 0 && sqlquery.value(isSmartList).toBool()); ReadingListItem *currentParent; if (sqlquery.value(parentId).isNull()) @@ -665,10 +710,18 @@ void ReadingListModel::setupLabels(QSqlDatabase &db) void ReadingListModel::setupReadingLists(QSqlDatabase &db) { + DBHelper::ensureReadingListEntries(db); // setup root item rootItem = new ReadingListItem(QList() << "ROOT" << 0 << true << false); - QSqlQuery selectQuery("select * from reading_list order by parentId IS NULL DESC", db); + db.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS reading_list_folder (reading_list_id INTEGER PRIMARY KEY, " + "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE)")); + db.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS reading_list_smart (reading_list_id INTEGER PRIMARY KEY, rules_json TEXT NOT NULL, " + "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE)")); + QSqlQuery selectQuery("SELECT rl.*, rlf.reading_list_id IS NOT NULL AS isFolder, rls.reading_list_id IS NOT NULL AS isSmartList " + "FROM reading_list rl LEFT JOIN reading_list_folder rlf ON rlf.reading_list_id = rl.id " + "LEFT JOIN reading_list_smart rls ON rls.reading_list_id = rl.id " + "ORDER BY rl.parentId IS NULL DESC", db); // setup reading lists setupReadingListsData(selectQuery, rootItem); diff --git a/YACReaderLibrary/db/reading_list_model.h b/YACReaderLibrary/db/reading_list_model.h index d95525098..f4b1f8b84 100644 --- a/YACReaderLibrary/db/reading_list_model.h +++ b/YACReaderLibrary/db/reading_list_model.h @@ -46,10 +46,13 @@ class ReadingListModel : public QAbstractItemModel void setupReadingListsData(QString path); void addNewLabel(const QString &name, YACReader::LabelColors color); void addReadingList(const QString &name); // top level reading list + void addReadingListFolder(const QString &name); void addReadingListAt(const QString &name, const QModelIndex &mi); bool isEditable(const QModelIndex &mi); bool isReadingList(const QModelIndex &mi); bool isReadingSubList(const QModelIndex &mi); + bool isReadingListFolder(const QModelIndex &mi); + bool isSmartList(const QModelIndex &mi); QString name(const QModelIndex &mi); void rename(const QModelIndex &mi, const QString &name); void deleteItem(const QModelIndex &mi); @@ -60,7 +63,9 @@ class ReadingListModel : public QAbstractItemModel TypeListsRole = Qt::UserRole + 1, IDRole, LabelColorRole, - SpecialListTypeRole + SpecialListTypeRole, + IsFolderRole, + IsSmartListRole }; enum TypeList { @@ -117,4 +122,4 @@ class ReadingListModel : public QAbstractItemModel Q_DECLARE_METATYPE(ReadingListModel::TypeSpecialList) -#endif // READING_LIST_MODEL_H \ No newline at end of file +#endif // READING_LIST_MODEL_H diff --git a/YACReaderLibrary/db_helper.cpp b/YACReaderLibrary/db_helper.cpp index 6dda78507..678acf589 100644 --- a/YACReaderLibrary/db_helper.cpp +++ b/YACReaderLibrary/db_helper.cpp @@ -629,16 +629,28 @@ void DBHelper::deleteComicsFromLabel(const QList &comicsList, qulonglon void DBHelper::deleteComicsFromReadingList(const QList &comicsList, qulonglong readingListId, QSqlDatabase &db) { + ensureReadingListEntries(db); db.transaction(); QLOG_DEBUG() << "deleteComicsFromReadingList----------------------------------"; QSqlQuery query(db); query.prepare("DELETE FROM comic_reading_list WHERE comic_id = :comic_id AND reading_list_id = :reading_list_id"); + QSqlQuery deleteEntry(db); for (const auto &comic : comicsList) { + const auto signedId = static_cast(comic.id); query.bindValue(":comic_id", comic.id); query.bindValue(":reading_list_id", readingListId); query.exec(); + if (signedId < 0) { + deleteEntry.prepare("DELETE FROM reading_list_entry WHERE id = :entry_id AND reading_list_id = :reading_list_id"); + deleteEntry.bindValue(":entry_id", -signedId); + } else { + deleteEntry.prepare("DELETE FROM reading_list_entry WHERE comic_id = :comic_id AND reading_list_id = :reading_list_id"); + deleteEntry.bindValue(":comic_id", comic.id); + } + deleteEntry.bindValue(":reading_list_id", readingListId); + deleteEntry.exec(); } db.commit(); @@ -1382,6 +1394,7 @@ void DBHelper::reasignOrderToComicsInLabel(qulonglong labelId, QList void DBHelper::reasignOrderToComicsInReadingList(qulonglong readingListId, QList comicIds, QSqlDatabase &db) { + ensureReadingListEntries(db); QSqlQuery updateOrdering(db); updateOrdering.prepare("UPDATE comic_reading_list SET " "ordering = :ordering " @@ -1390,10 +1403,23 @@ void DBHelper::reasignOrderToComicsInReadingList(qulonglong readingListId, QList int order = 0; const auto &readingListComicIds = comicIds; for (const auto id : readingListComicIds) { + const auto signedId = static_cast(id); updateOrdering.bindValue(":ordering", order++); - updateOrdering.bindValue(":comic_id", id); + updateOrdering.bindValue(":comic_id", signedId > 0 ? id : 0); updateOrdering.bindValue(":reading_list_id", readingListId); updateOrdering.exec(); + + QSqlQuery updateEntry(db); + if (signedId < 0) { + updateEntry.prepare("UPDATE reading_list_entry SET ordering = :ordering WHERE id = :entry_id AND reading_list_id = :reading_list_id"); + updateEntry.bindValue(":entry_id", -signedId); + } else { + updateEntry.prepare("UPDATE reading_list_entry SET ordering = :ordering WHERE comic_id = :comic_id AND reading_list_id = :reading_list_id"); + updateEntry.bindValue(":comic_id", id); + } + updateEntry.bindValue(":ordering", order - 1); + updateEntry.bindValue(":reading_list_id", readingListId); + updateEntry.exec(); QLOG_TRACE() << updateOrdering.lastError().databaseText() << "-" << updateOrdering.lastError().driverText(); } @@ -1768,7 +1794,11 @@ void DBHelper::insertComicsInLabel(const QList &comicsList, qulonglong void DBHelper::insertComicsInReadingList(const QList &comicsList, qulonglong readingListId, QSqlDatabase &db) { - QSqlQuery getNumComics("SELECT count(*) FROM comic_reading_list;", db); + ensureReadingListEntries(db); + QSqlQuery getNumComics(db); + getNumComics.prepare("SELECT count(*) FROM reading_list_entry WHERE reading_list_id = :reading_list_id"); + getNumComics.bindValue(":reading_list_id", readingListId); + getNumComics.exec(); getNumComics.next(); int numComics = getNumComics.value(0).toInt(); @@ -1778,15 +1808,111 @@ void DBHelper::insertComicsInReadingList(const QList &comicsList, qulon QSqlQuery query(db); query.prepare("INSERT INTO comic_reading_list (reading_list_id, comic_id, ordering) " "VALUES (:reading_list_id, :comic_id, :ordering)"); + QSqlQuery entry(db); + entry.prepare("INSERT OR IGNORE INTO reading_list_entry " + "(reading_list_id, comic_id, ordering, series, number, volume, year, format, file_name, comicvine_issue_id, hash) " + "VALUES (:reading_list_id, :comic_id, :ordering, :series, :number, :volume, :year, :format, :file_name, :comicvine_issue_id, :hash)"); for (const auto &comic : comicsList) { query.bindValue(":reading_list_id", readingListId); query.bindValue(":comic_id", comic.id); query.bindValue(":ordering", numComics++); query.exec(); + entry.bindValue(":reading_list_id", readingListId); + entry.bindValue(":comic_id", comic.id); + entry.bindValue(":ordering", numComics - 1); + entry.bindValue(":series", comic.info.series); + entry.bindValue(":number", comic.info.number); + entry.bindValue(":volume", comic.info.volume); + entry.bindValue(":year", comic.info.date.toString().right(4)); + entry.bindValue(":format", comic.info.format); + entry.bindValue(":file_name", comic.name); + entry.bindValue(":comicvine_issue_id", comic.info.comicVineID); + entry.bindValue(":hash", comic.info.hash); + entry.exec(); + } + + db.commit(); +} + +bool DBHelper::ensureReadingListEntries(QSqlDatabase &db) +{ + QSqlQuery query(db); + if (!query.exec("CREATE TABLE IF NOT EXISTS reading_list_entry (" + "id INTEGER PRIMARY KEY, reading_list_id INTEGER NOT NULL, comic_id INTEGER, ordering INTEGER NOT NULL, " + "series TEXT, number TEXT, volume TEXT, year TEXT, format TEXT, file_name TEXT, comicvine_issue_id TEXT, hash TEXT, " + "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE, " + "FOREIGN KEY(comic_id) REFERENCES comic(id) ON DELETE SET NULL)")) + return false; + bool hasHash = false; + QSqlQuery columns(db); + if (columns.exec("PRAGMA table_info(reading_list_entry)")) { + while (columns.next()) + hasHash = hasHash || columns.value(1).toString() == QStringLiteral("hash"); } + if (!hasHash && !query.exec("ALTER TABLE reading_list_entry ADD COLUMN hash TEXT")) + return false; + if (!query.exec("CREATE UNIQUE INDEX IF NOT EXISTS reading_list_entry_comic " + "ON reading_list_entry(reading_list_id, comic_id) WHERE comic_id IS NOT NULL")) + return false; + if (!query.exec("CREATE INDEX IF NOT EXISTS reading_list_entry_ordering " + "ON reading_list_entry(reading_list_id, ordering)")) + return false; + + return query.exec("INSERT OR IGNORE INTO reading_list_entry " + "(reading_list_id, comic_id, ordering, series, number, volume, year, format, file_name, comicvine_issue_id, hash) " + "SELECT crl.reading_list_id, c.id, crl.ordering, ci.series, ci.number, ci.volume, substr(ci.date, -4), " + "ci.format, c.fileName, ci.comicVineID, ci.hash FROM comic_reading_list crl " + "INNER JOIN comic c ON c.id = crl.comic_id " + "INNER JOIN comic_info ci ON ci.id = c.comicInfoId"); +} + +int DBHelper::relinkMissingReadingListEntries(QSqlDatabase &db, qulonglong readingListId) +{ + ensureReadingListEntries(db); + int relinked = 0; + const auto relinkTable = [&](const QString &table, bool imported) { + QSqlQuery exists(db); + exists.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :table"); + exists.bindValue(":table", table); + if (!exists.exec() || !exists.next()) + return; + + const QString idFilter = readingListId == 0 ? QString() : QStringLiteral(" AND e.reading_list_id = %1").arg(readingListId); + const QString hashMatch = imported ? QString() : QStringLiteral("((COALESCE(e.hash,'') <> '') AND ci.hash = e.hash) OR "); + const QString safeMatch = QStringLiteral( + "(%1((COALESCE(e.comicvine_issue_id,'') <> '') AND ci.comicVineID = e.comicvine_issue_id) OR " + "(LOWER(TRIM(ci.series)) = LOWER(TRIM(e.series)) AND LOWER(TRIM(ci.number)) = LOWER(TRIM(e.number)) " + "AND (COALESCE(e.volume,'') = '' OR LOWER(TRIM(ci.volume)) = LOWER(TRIM(e.volume))) " + "AND (COALESCE(e.year,'') = '' OR substr(ci.date,-4) = e.year)))") + .arg(hashMatch); + QSqlQuery update(db); + update.prepare(QStringLiteral( + "UPDATE %1 AS e SET comic_id = (SELECT c.id FROM comic c INNER JOIN comic_info ci ON ci.id = c.comicInfoId " + "WHERE %2 LIMIT 1)%3 WHERE e.comic_id IS NULL%4 AND 1 = (SELECT COUNT(*) FROM comic c " + "INNER JOIN comic_info ci ON ci.id = c.comicInfoId WHERE %2)") + .arg(table, + safeMatch, + imported ? QStringLiteral(", match_state = 0, match_tier = 3") : QString(), + idFilter)); + if (update.exec()) + relinked += update.numRowsAffected(); + }; + db.transaction(); + relinkTable(QStringLiteral("reading_list_entry"), false); + relinkTable(QStringLiteral("cbl_reading_list_entry"), true); + QSqlQuery legacy(db); + legacy.exec(QStringLiteral( + "INSERT OR IGNORE INTO comic_reading_list(reading_list_id, comic_id, ordering) " + "SELECT reading_list_id, comic_id, ordering FROM reading_list_entry WHERE comic_id IS NOT NULL%1") + .arg(readingListId == 0 ? QString() : QStringLiteral(" AND reading_list_id = %1").arg(readingListId))); + legacy.exec(QStringLiteral( + "INSERT OR IGNORE INTO comic_reading_list(reading_list_id, comic_id, ordering) " + "SELECT reading_list_id, comic_id, ordering FROM cbl_reading_list_entry WHERE comic_id IS NOT NULL%1") + .arg(readingListId == 0 ? QString() : QStringLiteral(" AND reading_list_id = %1").arg(readingListId))); db.commit(); + return relinked; } // queries diff --git a/YACReaderLibrary/db_helper.h b/YACReaderLibrary/db_helper.h index febd4d92b..f772351a2 100644 --- a/YACReaderLibrary/db_helper.h +++ b/YACReaderLibrary/db_helper.h @@ -63,6 +63,8 @@ class DBHelper static void insertComicsInFavorites(const QList &comicsList, QSqlDatabase &db); static void insertComicsInLabel(const QList &comicsList, qulonglong labelId, QSqlDatabase &db); static void insertComicsInReadingList(const QList &comicsList, qulonglong readingListId, QSqlDatabase &db); + static bool ensureReadingListEntries(QSqlDatabase &db); + static int relinkMissingReadingListEntries(QSqlDatabase &db, qulonglong readingListId = 0); // updates static void update(qulonglong libraryId, ComicInfo &comicInfo); static void update(ComicDB *comics, QSqlDatabase &db); diff --git a/YACReaderLibrary/grid_content_model.cpp b/YACReaderLibrary/grid_content_model.cpp index d6fee766a..a7938278a 100644 --- a/YACReaderLibrary/grid_content_model.cpp +++ b/YACReaderLibrary/grid_content_model.cpp @@ -101,6 +101,8 @@ QVariant GridContentModel::data(const QModelIndex &index, int role) const return sourceIndex.data(ComicModel::ShowRecentRole); case RecentRangeRole: return sourceIndex.data(ComicModel::RecentRangeRole); + case PlaceholderRole: + return sourceIndex.data(ComicModel::IsPlaceholderRole); default: return { }; } @@ -127,6 +129,7 @@ QHash GridContentModel::roleNames() const { RecentRangeRole, "recent_range" }, { UpdatedRole, "updated" }, { FinishedRole, "is_finished" }, + { PlaceholderRole, "is_placeholder" }, }; } diff --git a/YACReaderLibrary/grid_content_model.h b/YACReaderLibrary/grid_content_model.h index 6df74bc8f..bfc5500d5 100644 --- a/YACReaderLibrary/grid_content_model.h +++ b/YACReaderLibrary/grid_content_model.h @@ -39,7 +39,8 @@ class GridContentModel : public QAbstractListModel ShowRecentRole, RecentRangeRole, UpdatedRole, - FinishedRole + FinishedRole, + PlaceholderRole }; explicit GridContentModel(QObject *parent = nullptr); diff --git a/YACReaderLibrary/library_window.cpp b/YACReaderLibrary/library_window.cpp index 7e6e9d3c7..7e3e82e92 100644 --- a/YACReaderLibrary/library_window.cpp +++ b/YACReaderLibrary/library_window.cpp @@ -329,6 +329,9 @@ void LibraryWindow::doLayout() foldersTitle->addAction(actions.colapseAllNodesAction); readingListsTitle->addAction(actions.addReadingListAction); + readingListsTitle->addAction(actions.addSmartListAction); + readingListsTitle->addAction(actions.importCblReadingListAction); + readingListsTitle->addAction(actions.addReadingListFolderAction); // readingListsTitle->addSepartor(); readingListsTitle->addAction(actions.addLabelAction); // readingListsTitle->addSepartor(); @@ -336,6 +339,19 @@ void LibraryWindow::doLayout() readingListsTitle->addAction(actions.deleteReadingListAction); readingListsTitle->addSpacing(3); + listsView->setContextMenuPolicy(Qt::ActionsContextMenu); + listsView->addAction(actions.editSmartListAction); + listsView->addAction(actions.editCblReadingListAction); + listsView->addAction(actions.addSmartListAction); + listsView->addAction(actions.importCblReadingListAction); + listsView->addAction(actions.updateCblReadingListAction); + listsView->addAction(actions.exportReadingListAction); + listsView->addAction(actions.showMissingComicsAction); + listsView->addAction(actions.addReadingListFolderAction); + listsView->addAction(actions.moveReadingListToFolderAction); + listsView->addAction(actions.renameListAction); + listsView->addAction(actions.deleteReadingListAction); + // FINAL LAYOUT------------------------------------------------------------- contentViewsManager = new YACReaderContentViewsManager(settings, this); diff --git a/YACReaderLibrary/library_window_actions.cpp b/YACReaderLibrary/library_window_actions.cpp index 6925dc20d..226a8bfb7 100644 --- a/YACReaderLibrary/library_window_actions.cpp +++ b/YACReaderLibrary/library_window_actions.cpp @@ -408,6 +408,34 @@ void LibraryWindowActions::createActions(LibraryWindow *window, QSettings *setti addReadingListAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(ADD_READING_LIST_ACTION_YL)); addReadingListAction->setToolTip(tr("Add a new reading list to the current library")); + addSmartListAction = new QAction(tr("Add smart list..."), window); + addSmartListAction->setToolTip(tr("Create a dynamic reading list from saved rules")); + + importCblReadingListAction = new QAction(tr("Import CBL reading list..."), window); + importCblReadingListAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+I"))); + importCblReadingListAction->setToolTip(tr("Import a Comic Book Reading List")); + + updateCblReadingListAction = new QAction(tr("Update from source CBL..."), window); + updateCblReadingListAction->setToolTip(tr("Re-import the selected list from its original CBL file")); + + exportReadingListAction = new QAction(tr("Export reading list..."), window); + exportReadingListAction->setToolTip(tr("Export the selected reading list as a CBL file")); + + showMissingComicsAction = new QAction(tr("Missing comics..."), window); + showMissingComicsAction->setToolTip(tr("View missing comics and export a PDF report")); + + editSmartListAction = new QAction(tr("Edit smart list..."), window); + editSmartListAction->setToolTip(tr("Change the saved rules for the selected smart list")); + + addReadingListFolderAction = new QAction(tr("Add reading-list folder"), window); + addReadingListFolderAction->setToolTip(tr("Create a folder for organizing reading lists")); + + moveReadingListToFolderAction = new QAction(tr("Move reading list to folder..."), window); + moveReadingListToFolderAction->setToolTip(tr("Move the selected reading list into a folder or back to the top level")); + + editCblReadingListAction = new QAction(tr("Edit CBL reading list..."), window); + editCblReadingListAction->setToolTip(tr("Repair matches and reorder an imported CBL reading list")); + deleteReadingListAction = new QAction(tr("Remove reading list"), window); deleteReadingListAction->setData(REMOVE_READING_LIST_ACTION_YL); deleteReadingListAction->setShortcut(ShortcutsManager::getShortcutsManager().getShortcut(REMOVE_READING_LIST_ACTION_YL)); @@ -592,6 +620,15 @@ void LibraryWindowActions::createConnections( // lists QObject::connect(addReadingListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::addReadingList); + QObject::connect(addSmartListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::addSmartList); + QObject::connect(importCblReadingListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::importCblReadingList); + QObject::connect(updateCblReadingListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::updateCblReadingListFromSource); + QObject::connect(exportReadingListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::exportReadingList); + QObject::connect(showMissingComicsAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::showMissingComics); + QObject::connect(addReadingListFolderAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::addReadingListFolder); + QObject::connect(moveReadingListToFolderAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::moveReadingListToFolder); + QObject::connect(editSmartListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::editSmartList); + QObject::connect(editCblReadingListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::editCblReadingList); QObject::connect(deleteReadingListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::deleteCurrentList); QObject::connect(addLabelAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::addLabel); QObject::connect(renameListAction, &QAction::triggered, readingListManagementCoordinator, &ReadingListManagementCoordinator::renameCurrentList); @@ -713,6 +750,15 @@ void LibraryWindowActions::setUpShortcutsManagement(EditShortcutsDialog *editSho editShortcutsDialog->addActionsGroup("Lists", theme.shortcutsIcons.foldersIcon, // TODO change icon tmpList = QList() << addReadingListAction + << addSmartListAction + << importCblReadingListAction + << updateCblReadingListAction + << exportReadingListAction + << showMissingComicsAction + << addReadingListFolderAction + << moveReadingListToFolderAction + << editSmartListAction + << editCblReadingListAction << deleteReadingListAction << addLabelAction << renameListAction); @@ -871,6 +917,15 @@ void LibraryWindowActions::updateTheme(const Theme &theme) expandAllNodesAction->setIcon(sidebarIcons.expandIcon); colapseAllNodesAction->setIcon(sidebarIcons.colapseIcon); addReadingListAction->setIcon(sidebarIcons.addNewIcon); + addSmartListAction->setIcon(theme.readingListIcons.currentlyReadingIcon); + importCblReadingListAction->setIcon(sidebarIcons.openLibraryIcon); + updateCblReadingListAction->setIcon(sidebarIcons.openLibraryIcon); + exportReadingListAction->setIcon(menuIcons.exportLibraryIcon); + showMissingComicsAction->setIcon(menuIcons.exportLibraryIcon); + addReadingListFolderAction->setIcon(theme.navigationTree.folderIcon); + moveReadingListToFolderAction->setIcon(theme.navigationTree.folderIcon); + editSmartListAction->setIcon(sidebarIcons.renameListIcon); + editCblReadingListAction->setIcon(sidebarIcons.renameListIcon); deleteReadingListAction->setIcon(sidebarIcons.deleteIcon); addLabelAction->setIcon(sidebarIcons.addLabelIcon); renameListAction->setIcon(sidebarIcons.renameListIcon); diff --git a/YACReaderLibrary/library_window_actions.h b/YACReaderLibrary/library_window_actions.h index 4bc602755..043c4f0e9 100644 --- a/YACReaderLibrary/library_window_actions.h +++ b/YACReaderLibrary/library_window_actions.h @@ -130,7 +130,15 @@ class LibraryWindowActions // reading lists actions QAction *addReadingListAction; + QAction *addSmartListAction; + QAction *addReadingListFolderAction; + QAction *moveReadingListToFolderAction; QAction *importCblReadingListAction; + QAction *updateCblReadingListAction; + QAction *exportReadingListAction; + QAction *showMissingComicsAction; + QAction *editSmartListAction; + QAction *editCblReadingListAction; QAction *deleteReadingListAction; QAction *addLabelAction; QAction *renameListAction; diff --git a/YACReaderLibrary/qml/ComicGridDelegate.qml b/YACReaderLibrary/qml/ComicGridDelegate.qml index 2b095be37..c932b05be 100644 --- a/YACReaderLibrary/qml/ComicGridDelegate.qml +++ b/YACReaderLibrary/qml/ComicGridDelegate.qml @@ -22,6 +22,7 @@ Rectangle { required property double added_date required property bool show_recent required property double recent_range + required property bool is_placeholder required property int currentViewIndex required property var selectionHelper @@ -113,6 +114,8 @@ Rectangle { hoverEnabled: true onDoubleClicked: { + if (cell.is_placeholder) + return cell.selectionHelper.selectOnly(cell.source_index) cell.setCurrentViewRowRequested(cell.index) cell.activateRequested(cell.index) @@ -184,6 +187,26 @@ Rectangle { mipmap: true asynchronous: true cache: false + visible: !cell.is_placeholder + } + + Rectangle { + width: coverWidth + height: coverHeight + anchors { horizontalCenter: parent.horizontalCenter; top: realCell.top } + visible: cell.is_placeholder + color: Qt.darker(realCell.color, 1.15) + border { color: comicCoverBorderColor; width: 1 } + + Text { + anchors.centerIn: parent + width: parent.width - 20 + text: qsTr("Missing issue") + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + color: itemTitleColor + font.bold: true + } } Rectangle { diff --git a/YACReaderLibrary/reading_list_management_coordinator.cpp b/YACReaderLibrary/reading_list_management_coordinator.cpp index b5373ee9e..25f6238be 100644 --- a/YACReaderLibrary/reading_list_management_coordinator.cpp +++ b/YACReaderLibrary/reading_list_management_coordinator.cpp @@ -4,21 +4,50 @@ #include "cbl_reader.h" #include "comic_model.h" #include "data_base_management.h" +#include "db_helper.h" #include "reading_list_model.h" #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include #include +#include +#include #include +#include #include #include +#include +#include +#include #include +#include +#include +#include #include #include #include +#include +#include +#include +#include +#include +#include #include +#include +#include #include namespace { @@ -29,6 +58,21 @@ struct LibraryComicMatchData QString series; QString number; QString volume; + QString year; + QString format; + QString alternateSeries; + QString comicVineIssueId; +}; + +enum class CblMatchTier { + None = 0, + SavedRemap, + ComicVineIssue, + ExactMetadata, + NormalizedMetadata, + AlternateSeries, + FileName, + Manual }; enum class CblMatchState { @@ -41,6 +85,8 @@ struct CblMatchResult { CblMatchState state = CblMatchState::Missing; QList candidates; + CblMatchTier tier = CblMatchTier::None; + bool saveRemap = false; }; struct MatchedCblEntry @@ -49,25 +95,211 @@ struct MatchedCblEntry CblMatchResult match; }; +struct MissingComicEntry +{ + QString series; + QString number; + QString volume; + QString year; + QString format; + QString fileName; + QString sourceId; + QString comicVineSeriesId; + QString comicVineIssueId; +}; + +QString safeFileName(QString name) +{ + name.replace(QRegularExpression(QStringLiteral("[<>:\"/\\|?*]")), QStringLiteral("_")); + return name; +} + +bool writeCblFile(const QString &filePath, const QString &name, const QList &books, QString *error) +{ + QSaveFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + if (error) + *error = file.errorString(); + return false; + } + QXmlStreamWriter xml(&file); + xml.setAutoFormatting(true); + xml.writeStartDocument(); + xml.writeStartElement(QStringLiteral("ReadingList")); + xml.writeNamespace(QStringLiteral("http://www.w3.org/2001/XMLSchema"), QStringLiteral("xsd")); + xml.writeNamespace(QStringLiteral("http://www.w3.org/2001/XMLSchema-instance"), QStringLiteral("xsi")); + xml.writeTextElement(QStringLiteral("Name"), name); + xml.writeTextElement(QStringLiteral("NumIssues"), QString::number(books.size())); + xml.writeStartElement(QStringLiteral("Books")); + for (const auto &book : books) { + xml.writeStartElement(QStringLiteral("Book")); + const auto attribute = [&xml](const QString &key, const QString &value) { + if (!value.isEmpty()) + xml.writeAttribute(key, value); + }; + attribute(QStringLiteral("Series"), book.series); + attribute(QStringLiteral("Number"), book.number); + attribute(QStringLiteral("Volume"), book.volume); + attribute(QStringLiteral("Year"), book.year); + attribute(QStringLiteral("Format"), book.format); + if (!book.id.isEmpty()) + xml.writeTextElement(QStringLiteral("Id"), book.id); + if (!book.fileName.isEmpty()) + xml.writeTextElement(QStringLiteral("FileName"), book.fileName); + if (!book.comicVineSeriesId.isEmpty() || !book.comicVineIssueId.isEmpty()) { + xml.writeStartElement(QStringLiteral("Database")); + xml.writeAttribute(QStringLiteral("Name"), QStringLiteral("cv")); + attribute(QStringLiteral("Series"), book.comicVineSeriesId); + attribute(QStringLiteral("Issue"), book.comicVineIssueId); + xml.writeEndElement(); + } + xml.writeEndElement(); + } + xml.writeEndElement(); + xml.writeEndElement(); + xml.writeEndDocument(); + if (xml.hasError() || !file.commit()) { + if (error) + *error = file.errorString().isEmpty() ? QObject::tr("The CBL file could not be written.") : file.errorString(); + return false; + } + return true; +} + QString normalized(const QString &value) { return value.simplified().toCaseFolded(); } +QString normalizedIssueNumber(const QString &value) +{ + QString result = normalized(value); + static const QRegularExpression simpleNumber(QStringLiteral("^0+(\\d+(?:\\.\\d+)?)$")); + const auto match = simpleNumber.match(result); + if (match.hasMatch()) + result = match.captured(1); + return result; +} + +QString normalizedSeries(const QString &value, bool relaxed = false) +{ + QString result = normalized(value); + result.remove(QRegularExpression(QStringLiteral("\\s*\\((?:(?:vol(?:ume)?\\.?\\s*)?\\d{1,4})\\)\\s*$"), + QRegularExpression::CaseInsensitiveOption)); + if (!relaxed) + return result; + + static const QRegularExpression article(QStringLiteral("^(?:the|a|an|le|la|les|el|los|las|der|die|das|l['’])\\s+"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression edition( + QStringLiteral("\\s+(?:director['’]s cut|deluxe(?: edition)?|omnibus|trade paperback|tpb|hardcover|hc|complete(?: collection)?|absolute|new edition|revised edition|anniversary edition|collected edition|compendium|gallery edition|artist['’]s edition)$"), + QRegularExpression::CaseInsensitiveOption); + result.remove(article); + result.remove(edition); + result.remove(QRegularExpression(QStringLiteral("[^\\p{L}\\p{N}]+"))); + return result; +} + bool sameValue(const QString &left, const QString &right) { return normalized(left) == normalized(right); } -CblMatchResult matchBook(const CblBook &book, const QList &libraryComics) +bool matchesRequiredDates(const CblBook &book, const LibraryComicMatchData &comic) +{ + // CBL Volume commonly contains the series start year (for example, 2016). + // Treat supplied dates as constraints: missing or different target metadata + // is not safe enough for an automatic match, but remains available manually. + if (!book.volume.isEmpty() + && (comic.volume.isEmpty() || !sameValue(book.volume, comic.volume))) + return false; + + if (!book.year.isEmpty() + && (comic.year.isEmpty() || !sameValue(book.year, comic.year))) + return false; + + return true; +} + +QString remapKey(const CblBook &book) +{ + return normalizedSeries(book.series, true) + QLatin1Char('|') + + normalized(book.volume) + QLatin1Char('|') + normalizedIssueNumber(book.number); +} + +QString normalizedComicVineIssueId(const QString &value) +{ + const auto trimmed = value.trimmed(); + if (trimmed.isEmpty()) + return { }; + + static const QRegularExpression issueIdExpression( + QStringLiteral("(?:^|/issue/|4000-)(\\d+)/?(?:[?#].*)?$"), + QRegularExpression::CaseInsensitiveOption); + const auto match = issueIdExpression.match(trimmed); + return match.hasMatch() ? match.captured(1) : QString(); +} + +CblMatchResult resolvedMatch(const QList &matches, CblMatchTier tier) { CblMatchResult result; + result.candidates = matches; + result.tier = tier; + result.state = matches.size() == 1 ? CblMatchState::Matched + : (matches.isEmpty() ? CblMatchState::Missing : CblMatchState::Ambiguous); + return result; +} + +CblMatchResult matchBook(const CblBook &book, + const QList &libraryComics, + const QHash &remaps, + QProgressDialog *progress = nullptr) +{ + int inspectedComics = 0; + const auto continueMatching = [&] { + if (++inspectedComics % 100 == 0) + QApplication::processEvents(); + return progress == nullptr || !progress->wasCanceled(); + }; + + const auto remappedId = remaps.value(remapKey(book)); + if (remappedId != 0) { + for (const auto &comic : libraryComics) { + if (!continueMatching()) + return { }; + if (comic.id == remappedId) + return resolvedMatch({ comic }, CblMatchTier::SavedRemap); + } + } + + const auto cblComicVineIssueId = normalizedComicVineIssueId(book.comicVineIssueId); + if (!cblComicVineIssueId.isEmpty()) { + QList comicVineMatches; + for (const auto &comic : libraryComics) { + if (!continueMatching()) + return { }; + if (normalizedComicVineIssueId(comic.comicVineIssueId) == cblComicVineIssueId) + comicVineMatches.append(comic); + } + + if (comicVineMatches.size() == 1) { + return resolvedMatch(comicVineMatches, CblMatchTier::ComicVineIssue); + } + + if (comicVineMatches.size() > 1) { + return resolvedMatch(comicVineMatches, CblMatchTier::ComicVineIssue); + } + } QList seriesNumberMatches; QList exactMatches; for (const auto &comic : libraryComics) { - if (!sameValue(book.series, comic.series) || !sameValue(book.number, comic.number)) + if (!continueMatching()) + return { }; + if (!sameValue(book.series, comic.series) + || normalizedIssueNumber(book.number) != normalizedIssueNumber(comic.number) + || !matchesRequiredDates(book, comic)) continue; seriesNumberMatches.append(comic); @@ -77,28 +309,64 @@ CblMatchResult matchBook(const CblBook &book, const QList } if (!book.volume.isEmpty() && exactMatches.size() == 1) { - result.state = CblMatchState::Matched; - result.candidates = exactMatches; - return result; + return resolvedMatch(exactMatches, CblMatchTier::ExactMetadata); } if (!book.volume.isEmpty() && exactMatches.size() > 1) { - result.state = CblMatchState::Ambiguous; - result.candidates = exactMatches; - return result; + return resolvedMatch(exactMatches, CblMatchTier::ExactMetadata); } if (seriesNumberMatches.size() == 1) { - result.state = CblMatchState::Matched; - result.candidates = seriesNumberMatches; - return result; + return resolvedMatch(seriesNumberMatches, CblMatchTier::ExactMetadata); } if (seriesNumberMatches.size() > 1) { - result.state = CblMatchState::Ambiguous; - result.candidates = seriesNumberMatches; - return result; + QList narrowed = seriesNumberMatches; + if (!book.year.isEmpty()) { + QList byYear; + for (const auto &comic : std::as_const(narrowed)) { + if (!comic.year.isEmpty() && qAbs(comic.year.toInt() - book.year.toInt()) <= 1) + byYear.append(comic); + } + if (!byYear.isEmpty()) + narrowed = byYear; + } + if (!book.format.isEmpty()) { + QList byFormat; + for (const auto &comic : std::as_const(narrowed)) { + if (sameValue(comic.format, book.format)) + byFormat.append(comic); + } + if (!byFormat.isEmpty()) + narrowed = byFormat; + } + return resolvedMatch(narrowed, CblMatchTier::ExactMetadata); + } + + QList normalizedMatches; + const auto relaxedSeries = normalizedSeries(book.series, true); + for (const auto &comic : libraryComics) { + if (!continueMatching()) + return { }; + if (normalizedIssueNumber(book.number) == normalizedIssueNumber(comic.number) + && relaxedSeries == normalizedSeries(comic.series, true) + && matchesRequiredDates(book, comic)) + normalizedMatches.append(comic); + } + if (!normalizedMatches.isEmpty()) + return resolvedMatch(normalizedMatches, CblMatchTier::NormalizedMetadata); + + QList alternateMatches; + for (const auto &comic : libraryComics) { + if (!continueMatching()) + return { }; + if (normalizedIssueNumber(book.number) == normalizedIssueNumber(comic.number) + && relaxedSeries == normalizedSeries(comic.alternateSeries, true) + && matchesRequiredDates(book, comic)) + alternateMatches.append(comic); } + if (!alternateMatches.isEmpty()) + return resolvedMatch(alternateMatches, CblMatchTier::AlternateSeries); // Metadata is preferred, but older libraries can have sparse ComicInfo data. // Fall back to the user's filename convention: "Series #Number" with an @@ -111,6 +379,10 @@ CblMatchResult matchBook(const CblBook &book, const QList QList fileMatches; for (const auto &comic : libraryComics) { + if (!continueMatching()) + return { }; + if (!matchesRequiredDates(book, comic)) + continue; if (!normalized(comic.fileName).contains(normalizedSeries)) continue; if (issueExpression.match(comic.fileName).hasMatch()) @@ -118,19 +390,260 @@ CblMatchResult matchBook(const CblBook &book, const QList } if (fileMatches.size() == 1) { - result.state = CblMatchState::Matched; - result.candidates = fileMatches; - return result; + return resolvedMatch(fileMatches, CblMatchTier::FileName); } if (fileMatches.size() > 1) { - result.state = CblMatchState::Ambiguous; - result.candidates = fileMatches; - return result; + return resolvedMatch(fileMatches, CblMatchTier::FileName); } } - return result; + return { }; +} + +QString matchTierName(CblMatchTier tier) +{ + switch (tier) { + case CblMatchTier::SavedRemap: return QObject::tr("saved choice"); + case CblMatchTier::ComicVineIssue: return QObject::tr("ComicVine ID"); + case CblMatchTier::ExactMetadata: return QObject::tr("exact metadata"); + case CblMatchTier::NormalizedMetadata: return QObject::tr("normalized metadata"); + case CblMatchTier::AlternateSeries: return QObject::tr("alternate series"); + case CblMatchTier::FileName: return QObject::tr("filename"); + case CblMatchTier::Manual: return QObject::tr("manual choice"); + case CblMatchTier::None: return QObject::tr("unresolved"); + } + return QObject::tr("unresolved"); +} + +QString comicLabel(const LibraryComicMatchData &comic) +{ + QString label = QStringLiteral("%1 #%2").arg(comic.series, comic.number); + if (!comic.volume.isEmpty()) + label += QObject::tr(" (volume %1)").arg(comic.volume); + if (!comic.year.isEmpty()) + label += QStringLiteral(" — %1").arg(comic.year); + if (!comic.fileName.isEmpty()) + label += QStringLiteral(" — %1").arg(comic.fileName); + return label; +} + +bool reviewMatches(QWidget *parent, + const QString &name, + QList *entries, + const QList &libraryComics, + const QString &acceptText) +{ + QDialog dialog(parent); + dialog.setWindowTitle(QObject::tr("Review CBL reading list")); + dialog.resize(1050, 650); + + auto *layout = new QVBoxLayout(&dialog); + auto *summary = new QLabel(QObject::tr("Review %1. Ambiguous and missing entries remain as placeholders unless you choose a comic.").arg(name), &dialog); + summary->setWordWrap(true); + layout->addWidget(summary); + + auto *filter = new QComboBox(&dialog); + filter->addItem(QObject::tr("All entries"), -1); + filter->addItem(QObject::tr("Matched"), static_cast(CblMatchState::Matched)); + filter->addItem(QObject::tr("Missing"), static_cast(CblMatchState::Missing)); + filter->addItem(QObject::tr("Ambiguous"), static_cast(CblMatchState::Ambiguous)); + layout->addWidget(filter); + + auto *moveUp = new QPushButton(QObject::tr("Move up"), &dialog); + auto *moveDown = new QPushButton(QObject::tr("Move down"), &dialog); + auto *undo = new QPushButton(QObject::tr("Undo"), &dialog); + undo->setEnabled(false); + auto *saveState = new QLabel(QObject::tr("No unsaved changes"), &dialog); + auto *orderLayout = new QHBoxLayout; + orderLayout->addWidget(moveUp); + orderLayout->addWidget(moveDown); + orderLayout->addWidget(undo); + orderLayout->addWidget(saveState); + orderLayout->addStretch(1); + layout->addLayout(orderLayout); + + auto *table = new QTableWidget(entries->size(), 5, &dialog); + table->setHorizontalHeaderLabels({ QObject::tr("Order"), QObject::tr("CBL entry"), QObject::tr("Status"), QObject::tr("Match method"), QObject::tr("Library comic") }); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->hide(); + table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + table->horizontalHeader()->setSectionResizeMode(4, QHeaderView::Stretch); + for (int row = 0; row < entries->size(); ++row) { + const auto &entry = entries->at(row); + const QString status = entry.match.state == CblMatchState::Matched ? QObject::tr("Matched") + : entry.match.state == CblMatchState::Ambiguous ? QObject::tr("Ambiguous") : QObject::tr("Missing"); + table->setItem(row, 0, new QTableWidgetItem(QString::number(row + 1))); + table->setItem(row, 1, new QTableWidgetItem(QStringLiteral("%1 #%2").arg(entry.book.series, entry.book.number))); + table->setItem(row, 2, new QTableWidgetItem(status)); + table->setItem(row, 3, new QTableWidgetItem(matchTierName(entry.match.tier))); + table->setItem(row, 4, new QTableWidgetItem(entry.match.state == CblMatchState::Matched && !entry.match.candidates.isEmpty() + ? comicLabel(entry.match.candidates.constFirst()) : QString())); + } + layout->addWidget(table, 1); + + auto *comicSearch = new QLineEdit(&dialog); + comicSearch->setPlaceholderText(QObject::tr("Search series, issue, year, or filename...")); + layout->addWidget(comicSearch); + + auto *comicTree = new QTreeWidget(&dialog); + comicTree->setHeaderLabels({ QObject::tr("Library series and comics") }); + comicTree->setUniformRowHeights(true); + comicTree->setRootIsDecorated(true); + comicTree->setMinimumHeight(190); + QHash seriesItems; + QHash comicItemsById; + for (const auto &comic : libraryComics) { + const QString seriesName = comic.series.isEmpty() ? QObject::tr("Unknown series") : comic.series; + const QString key = normalized(seriesName); + auto *seriesItem = seriesItems.value(key, nullptr); + if (!seriesItem) { + seriesItem = new QTreeWidgetItem(comicTree, { seriesName }); + seriesItem->setFlags(seriesItem->flags() & ~Qt::ItemIsSelectable); + seriesItems.insert(key, seriesItem); + } + auto *comicItem = new QTreeWidgetItem(seriesItem, { comicLabel(comic) }); + comicItem->setData(0, Qt::UserRole, QVariant::fromValue(comic.id)); + comicItemsById.insert(comic.id, comicItem); + } + comicTree->sortItems(0, Qt::AscendingOrder); + layout->addWidget(comicTree, 1); + + auto *applyChoice = new QPushButton(QObject::tr("Use selected comic"), &dialog); + auto *clearChoice = new QPushButton(QObject::tr("Leave as placeholder"), &dialog); + auto *choiceLayout = new QHBoxLayout; + choiceLayout->addStretch(1); + choiceLayout->addWidget(applyChoice); + choiceLayout->addWidget(clearChoice); + layout->addLayout(choiceLayout); + + auto refreshRow = [table, entries](int row) { + const auto &entry = entries->at(row); + const QString status = entry.match.state == CblMatchState::Matched ? QObject::tr("Matched") + : entry.match.state == CblMatchState::Ambiguous ? QObject::tr("Ambiguous") : QObject::tr("Missing"); + table->item(row, 2)->setText(status); + table->item(row, 3)->setText(matchTierName(entry.match.tier)); + table->item(row, 4)->setText(entry.match.state == CblMatchState::Matched && !entry.match.candidates.isEmpty() + ? comicLabel(entry.match.candidates.constFirst()) : QString()); + }; + auto refreshAllRows = [table, entries, refreshRow, filter] { + const int wanted = filter->currentData().toInt(); + for (int row = 0; row < entries->size(); ++row) { + auto &entry = (*entries)[row]; + entry.book.ordering = row; + table->item(row, 0)->setText(QString::number(row + 1)); + table->item(row, 1)->setText(QStringLiteral("%1 #%2").arg(entry.book.series, entry.book.number)); + refreshRow(row); + table->setRowHidden(row, wanted >= 0 && static_cast(entry.match.state) != wanted); + } + }; + QList> undoStack; + const auto rememberChange = [&] { + undoStack.append(*entries); + if (undoStack.size() > 50) + undoStack.removeFirst(); + undo->setEnabled(true); + saveState->setText(QObject::tr("Unsaved changes")); + }; + QObject::connect(applyChoice, &QPushButton::clicked, &dialog, [=] { + const int row = table->currentRow(); + const auto selected = comicTree->selectedItems(); + const auto comicId = selected.isEmpty() ? 0 : selected.constFirst()->data(0, Qt::UserRole).toULongLong(); + if (row < 0 || comicId == 0) + return; + rememberChange(); + for (const auto &comic : libraryComics) { + if (comic.id == comicId) { + auto &entry = (*entries)[row]; + entry.match = resolvedMatch({ comic }, CblMatchTier::Manual); + entry.match.saveRemap = true; + refreshRow(row); + break; + } + } + }); + QObject::connect(clearChoice, &QPushButton::clicked, &dialog, [=] { + const int row = table->currentRow(); + if (row < 0) + return; + rememberChange(); + (*entries)[row].match = { }; + refreshRow(row); + }); + QObject::connect(moveUp, &QPushButton::clicked, &dialog, [=] { + const int row = table->currentRow(); + if (row <= 0) + return; + rememberChange(); + entries->swapItemsAt(row, row - 1); + refreshAllRows(); + table->selectRow(row - 1); + }); + QObject::connect(moveDown, &QPushButton::clicked, &dialog, [=] { + const int row = table->currentRow(); + if (row < 0 || row >= entries->size() - 1) + return; + rememberChange(); + entries->swapItemsAt(row, row + 1); + refreshAllRows(); + table->selectRow(row + 1); + }); + QObject::connect(undo, &QPushButton::clicked, &dialog, [&] { + if (undoStack.isEmpty()) + return; + *entries = undoStack.takeLast(); + refreshAllRows(); + undo->setEnabled(!undoStack.isEmpty()); + saveState->setText(undoStack.isEmpty() ? QObject::tr("No unsaved changes") : QObject::tr("Unsaved changes")); + }); + QObject::connect(filter, QOverload::of(&QComboBox::currentIndexChanged), &dialog, [=](int) { + const int wanted = filter->currentData().toInt(); + for (int row = 0; row < entries->size(); ++row) + table->setRowHidden(row, wanted >= 0 && static_cast(entries->at(row).match.state) != wanted); + }); + QObject::connect(comicSearch, &QLineEdit::textChanged, &dialog, [=](const QString &text) { + const QString needle = normalized(text); + for (int seriesRow = 0; seriesRow < comicTree->topLevelItemCount(); ++seriesRow) { + auto *seriesItem = comicTree->topLevelItem(seriesRow); + const bool seriesMatches = needle.isEmpty() || normalized(seriesItem->text(0)).contains(needle); + bool anyVisible = seriesMatches; + for (int comicRow = 0; comicRow < seriesItem->childCount(); ++comicRow) { + auto *comicItem = seriesItem->child(comicRow); + const bool visible = seriesMatches || normalized(comicItem->text(0)).contains(needle); + comicItem->setHidden(!visible); + anyVisible = anyVisible || visible; + } + seriesItem->setHidden(!anyVisible); + seriesItem->setExpanded(!needle.isEmpty() && anyVisible); + } + }); + QObject::connect(comicTree, &QTreeWidget::itemDoubleClicked, applyChoice, [=](QTreeWidgetItem *item, int) { + if (item && item->data(0, Qt::UserRole).toULongLong() != 0) + applyChoice->click(); + }); + QObject::connect(table, &QTableWidget::currentCellChanged, &dialog, [=](int currentRow, int, int, int) { + if (currentRow < 0) + return; + const auto &entry = entries->at(currentRow); + if (entry.match.state == CblMatchState::Matched && !entry.match.candidates.isEmpty()) { + const auto wantedId = entry.match.candidates.constFirst().id; + if (auto *item = comicItemsById.value(wantedId, nullptr)) { + comicTree->setCurrentItem(item); + comicTree->scrollToItem(item); + } + } + }); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + buttons->button(QDialogButtonBox::Ok)->setText(acceptText); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + if (!entries->isEmpty()) + table->selectRow(0); + return dialog.exec() == QDialog::Accepted; } bool execSql(QSqlQuery &query, QString *error) @@ -142,6 +655,25 @@ bool execSql(QSqlQuery &query, QString *error) return false; } +bool ensureColumn(QSqlDatabase &db, const QString &table, const QString &name, const QString &definition, QString *error) +{ + QSqlQuery columns(db); + if (!columns.exec(QStringLiteral("PRAGMA table_info(%1)").arg(table))) { + if (error) + *error = columns.lastError().text(); + return false; + } + + while (columns.next()) { + if (columns.value(1).toString() == name) + return true; + } + + QSqlQuery alter(db); + alter.prepare(QStringLiteral("ALTER TABLE %1 ADD COLUMN %2 %3").arg(table, name, definition)); + return execSql(alter, error); +} + bool ensureCblImportTables(QSqlDatabase &db, QString *error) { QSqlQuery meta(db); @@ -149,6 +681,8 @@ bool ensureCblImportTables(QSqlDatabase &db, QString *error) "CREATE TABLE IF NOT EXISTS cbl_reading_list_meta (" "reading_list_id INTEGER PRIMARY KEY, " "source_name TEXT, " + "source_path TEXT, " + "source_hash TEXT, " "imported_at INTEGER NOT NULL, " "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE)")); if (!execSql(meta, error)) @@ -165,8 +699,13 @@ bool ensureCblImportTables(QSqlDatabase &db, QString *error) "number TEXT, " "volume TEXT, " "year TEXT, " + "format TEXT, " + "file_name TEXT, " "source_id TEXT, " + "comicvine_series_id TEXT, " + "comicvine_issue_id TEXT, " "match_state INTEGER NOT NULL, " + "match_tier INTEGER NOT NULL DEFAULT 0, " "candidate_count INTEGER NOT NULL DEFAULT 0, " "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE, " "FOREIGN KEY(comic_id) REFERENCES comic(id) ON DELETE SET NULL, " @@ -174,6 +713,26 @@ bool ensureCblImportTables(QSqlDatabase &db, QString *error) if (!execSql(entries, error)) return false; + QSqlQuery remaps(db); + remaps.prepare(QStringLiteral( + "CREATE TABLE IF NOT EXISTS cbl_remap_rule (" + "id INTEGER PRIMARY KEY, " + "series_key TEXT NOT NULL, volume_key TEXT NOT NULL DEFAULT '', number_key TEXT NOT NULL DEFAULT '', " + "comic_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " + "FOREIGN KEY(comic_id) REFERENCES comic(id) ON DELETE CASCADE, " + "UNIQUE(series_key, volume_key, number_key))")); + if (!execSql(remaps, error)) + return false; + + if (!ensureColumn(db, QStringLiteral("cbl_reading_list_meta"), QStringLiteral("source_path"), QStringLiteral("TEXT"), error) + || !ensureColumn(db, QStringLiteral("cbl_reading_list_meta"), QStringLiteral("source_hash"), QStringLiteral("TEXT"), error) + || !ensureColumn(db, QStringLiteral("cbl_reading_list_entry"), QStringLiteral("comicvine_series_id"), QStringLiteral("TEXT"), error) + || !ensureColumn(db, QStringLiteral("cbl_reading_list_entry"), QStringLiteral("comicvine_issue_id"), QStringLiteral("TEXT"), error) + || !ensureColumn(db, QStringLiteral("cbl_reading_list_entry"), QStringLiteral("format"), QStringLiteral("TEXT"), error) + || !ensureColumn(db, QStringLiteral("cbl_reading_list_entry"), QStringLiteral("file_name"), QStringLiteral("TEXT"), error) + || !ensureColumn(db, QStringLiteral("cbl_reading_list_entry"), QStringLiteral("match_tier"), QStringLiteral("INTEGER NOT NULL DEFAULT 0"), error)) + return false; + QSqlQuery index(db); index.prepare(QStringLiteral( "CREATE INDEX IF NOT EXISTS cbl_reading_list_entry_ordering_index " @@ -185,6 +744,11 @@ bool persistCblReadingList(QSqlDatabase &db, const CblReadingList &readingList, const QList &entries, const QString &sourceName, + const QString &sourcePath, + const QString &sourceHash, + qulonglong parentReadingListId, + qulonglong forcedExistingId, + bool createCopy, qulonglong *readingListId, QString *error) { @@ -206,22 +770,68 @@ bool persistCblReadingList(QSqlDatabase &db, return rollback(message); } - QSqlQuery createList(db); - createList.prepare(QStringLiteral("INSERT INTO reading_list (name) VALUES (:name)")); - createList.bindValue(QStringLiteral(":name"), readingList.name); - if (!createList.exec()) - return rollback(createList.lastError().text()); + qulonglong newReadingListId = forcedExistingId; + if (newReadingListId == 0 && !createCopy) { + QSqlQuery existing(db); + existing.prepare(QStringLiteral("SELECT reading_list_id FROM cbl_reading_list_meta " + "WHERE source_path = :source_path OR (source_path IS NULL AND source_name = :source_name) LIMIT 1")); + existing.bindValue(QStringLiteral(":source_path"), sourcePath); + existing.bindValue(QStringLiteral(":source_name"), sourceName); + if (!existing.exec()) + return rollback(existing.lastError().text()); + if (existing.next()) + newReadingListId = existing.value(0).toULongLong(); + } - const qulonglong newReadingListId = createList.lastInsertId().toULongLong(); + if (newReadingListId != 0) { + const bool hasMatch = std::any_of(entries.cbegin(), entries.cend(), [](const MatchedCblEntry &entry) { + return entry.match.state == CblMatchState::Matched; + }); + QSqlQuery previousMatches(db); + previousMatches.prepare(QStringLiteral("SELECT COUNT(*) FROM cbl_reading_list_entry WHERE reading_list_id = :id AND comic_id IS NOT NULL")); + previousMatches.bindValue(QStringLiteral(":id"), newReadingListId); + if (!previousMatches.exec() || !previousMatches.next()) + return rollback(previousMatches.lastError().text()); + if (!hasMatch && previousMatches.value(0).toInt() > 0) + return rollback(QStringLiteral("No comics matched during re-import, so the existing reading list was left unchanged.")); + + QSqlQuery updateList(db); + updateList.prepare(QStringLiteral("UPDATE reading_list SET name = :name WHERE id = :id")); + updateList.bindValue(QStringLiteral(":name"), readingList.name); + updateList.bindValue(QStringLiteral(":id"), newReadingListId); + if (!updateList.exec()) + return rollback(updateList.lastError().text()); + for (const auto &table : { QStringLiteral("comic_reading_list"), QStringLiteral("cbl_reading_list_entry") }) { + QSqlQuery clear(db); + clear.prepare(QStringLiteral("DELETE FROM %1 WHERE reading_list_id = :id").arg(table)); + clear.bindValue(QStringLiteral(":id"), newReadingListId); + if (!clear.exec()) + return rollback(clear.lastError().text()); + } + } else { + QSqlQuery createList(db); + createList.prepare(parentReadingListId == 0 + ? QStringLiteral("INSERT INTO reading_list (name) VALUES (:name)") + : QStringLiteral("INSERT INTO reading_list (name, parentId, ordering) " + "VALUES (:name, :parent_id, (SELECT COUNT(*) FROM reading_list WHERE parentId = :parent_id))")); + createList.bindValue(QStringLiteral(":name"), readingList.name); + if (parentReadingListId != 0) + createList.bindValue(QStringLiteral(":parent_id"), parentReadingListId); + if (!createList.exec()) + return rollback(createList.lastError().text()); + newReadingListId = createList.lastInsertId().toULongLong(); + } if (newReadingListId == 0) return rollback(QStringLiteral("YACReader did not return an id for the new reading list.")); QSqlQuery meta(db); meta.prepare(QStringLiteral( - "INSERT INTO cbl_reading_list_meta (reading_list_id, source_name, imported_at) " - "VALUES (:reading_list_id, :source_name, :imported_at)")); + "INSERT OR REPLACE INTO cbl_reading_list_meta (reading_list_id, source_name, source_path, source_hash, imported_at) " + "VALUES (:reading_list_id, :source_name, :source_path, :source_hash, :imported_at)")); meta.bindValue(QStringLiteral(":reading_list_id"), newReadingListId); meta.bindValue(QStringLiteral(":source_name"), sourceName); + meta.bindValue(QStringLiteral(":source_path"), sourcePath); + meta.bindValue(QStringLiteral(":source_hash"), sourceHash); meta.bindValue(QStringLiteral(":imported_at"), QDateTime::currentSecsSinceEpoch()); if (!meta.exec()) return rollback(meta.lastError().text()); @@ -236,8 +846,8 @@ bool persistCblReadingList(QSqlDatabase &db, QSqlQuery insertEntry(db); insertEntry.prepare(QStringLiteral( "INSERT INTO cbl_reading_list_entry " - "(reading_list_id, ordering, comic_id, series, number, volume, year, source_id, match_state, candidate_count) " - "VALUES (:reading_list_id, :ordering, :comic_id, :series, :number, :volume, :year, :source_id, :match_state, :candidate_count)")); + "(reading_list_id, ordering, comic_id, series, number, volume, year, format, file_name, source_id, comicvine_series_id, comicvine_issue_id, match_state, match_tier, candidate_count) " + "VALUES (:reading_list_id, :ordering, :comic_id, :series, :number, :volume, :year, :format, :file_name, :source_id, :comicvine_series_id, :comicvine_issue_id, :match_state, :match_tier, :candidate_count)")); insertEntry.bindValue(QStringLiteral(":reading_list_id"), newReadingListId); insertEntry.bindValue(QStringLiteral(":ordering"), entry.book.ordering); if (comicId != 0) @@ -248,12 +858,31 @@ bool persistCblReadingList(QSqlDatabase &db, insertEntry.bindValue(QStringLiteral(":number"), entry.book.number); insertEntry.bindValue(QStringLiteral(":volume"), entry.book.volume); insertEntry.bindValue(QStringLiteral(":year"), entry.book.year); + insertEntry.bindValue(QStringLiteral(":format"), entry.book.format); + insertEntry.bindValue(QStringLiteral(":file_name"), entry.book.fileName); insertEntry.bindValue(QStringLiteral(":source_id"), entry.book.id); + insertEntry.bindValue(QStringLiteral(":comicvine_series_id"), entry.book.comicVineSeriesId); + insertEntry.bindValue(QStringLiteral(":comicvine_issue_id"), entry.book.comicVineIssueId); insertEntry.bindValue(QStringLiteral(":match_state"), static_cast(entry.match.state)); + insertEntry.bindValue(QStringLiteral(":match_tier"), static_cast(entry.match.tier)); insertEntry.bindValue(QStringLiteral(":candidate_count"), entry.match.candidates.size()); if (!insertEntry.exec()) return rollback(insertEntry.lastError().text()); + if (comicId != 0 && entry.match.saveRemap) { + QSqlQuery remap(db); + remap.prepare(QStringLiteral( + "INSERT OR REPLACE INTO cbl_remap_rule (series_key, volume_key, number_key, comic_id, created_at) " + "VALUES (:series, :volume, :number, :comic_id, :created_at)")); + remap.bindValue(QStringLiteral(":series"), normalizedSeries(entry.book.series, true)); + remap.bindValue(QStringLiteral(":volume"), normalized(entry.book.volume)); + remap.bindValue(QStringLiteral(":number"), normalizedIssueNumber(entry.book.number)); + remap.bindValue(QStringLiteral(":comic_id"), comicId); + remap.bindValue(QStringLiteral(":created_at"), QDateTime::currentSecsSinceEpoch()); + if (!remap.exec()) + return rollback(remap.lastError().text()); + } + // Keep matched entries visible to existing YACReader builds. Imported CBL // metadata remains authoritative for ordering and missing placeholders. // The legacy relation cannot contain the same comic twice in one list, @@ -280,6 +909,185 @@ bool persistCblReadingList(QSqlDatabase &db, *readingListId = newReadingListId; return true; } + +void ensureSmartListTables(QSqlDatabase &db) +{ + db.exec(QStringLiteral("CREATE TABLE IF NOT EXISTS reading_list_smart (reading_list_id INTEGER PRIMARY KEY, rules_json TEXT NOT NULL, " + "FOREIGN KEY(reading_list_id) REFERENCES reading_list(id) ON DELETE CASCADE)")); +} + +QJsonObject smartListRulesFromDialog(QLineEdit *series, + QLineEdit *title, + QLineEdit *creator, + QLineEdit *publisher, + QLineEdit *storyArc, + QLineEdit *tag, + QLineEdit *path, + QComboBox *readState, + QComboBox *openedState, + QComboBox *type, + QSpinBox *ratingMin, + QSpinBox *yearFrom, + QSpinBox *yearTo, + QSpinBox *addedWithinDays, + QSpinBox *openedWithinDays, + QComboBox *sortField, + QComboBox *sortDirection) +{ + QJsonObject rules; + const auto addText = [&rules](const QString &key, QLineEdit *edit) { + const QString value = edit->text().trimmed(); + if (!value.isEmpty()) + rules.insert(key, value); + }; + addText(QStringLiteral("series"), series); + addText(QStringLiteral("title"), title); + addText(QStringLiteral("creator"), creator); + addText(QStringLiteral("publisher"), publisher); + addText(QStringLiteral("storyArc"), storyArc); + addText(QStringLiteral("tag"), tag); + addText(QStringLiteral("path"), path); + if (readState->currentData().toInt() != 0) + rules.insert(QStringLiteral("readState"), readState->currentData().toInt()); + if (openedState->currentData().toInt() != 0) + rules.insert(QStringLiteral("openedState"), openedState->currentData().toInt()); + if (type->currentData().toInt() >= 0) + rules.insert(QStringLiteral("type"), type->currentData().toInt()); + if (ratingMin->value() > 0) + rules.insert(QStringLiteral("ratingMin"), ratingMin->value()); + if (yearFrom->value() > 0) + rules.insert(QStringLiteral("yearFrom"), yearFrom->value()); + if (yearTo->value() > 0) + rules.insert(QStringLiteral("yearTo"), yearTo->value()); + if (addedWithinDays->value() > 0) + rules.insert(QStringLiteral("addedWithinDays"), addedWithinDays->value()); + if (openedWithinDays->value() > 0) + rules.insert(QStringLiteral("openedWithinDays"), openedWithinDays->value()); + rules.insert(QStringLiteral("sortField"), sortField->currentData().toString()); + rules.insert(QStringLiteral("sortDirection"), sortDirection->currentData().toString()); + return rules; +} + +bool editSmartListDefinition(QWidget *parent, QString *name, QJsonObject *rules) +{ + QDialog dialog(parent); + dialog.setWindowTitle(QObject::tr("Smart list")); + dialog.resize(560, 520); + + auto *layout = new QVBoxLayout(&dialog); + auto *form = new QFormLayout; + layout->addLayout(form); + + auto *nameEdit = new QLineEdit(*name, &dialog); + form->addRow(QObject::tr("Name:"), nameEdit); + + auto makeLineEdit = [&dialog, rules](const QString &key) { + return new QLineEdit(rules->value(key).toString(), &dialog); + }; + auto *series = makeLineEdit(QStringLiteral("series")); + auto *title = makeLineEdit(QStringLiteral("title")); + auto *creator = makeLineEdit(QStringLiteral("creator")); + auto *publisher = makeLineEdit(QStringLiteral("publisher")); + auto *storyArc = makeLineEdit(QStringLiteral("storyArc")); + auto *tag = makeLineEdit(QStringLiteral("tag")); + auto *path = makeLineEdit(QStringLiteral("path")); + form->addRow(QObject::tr("Series contains:"), series); + form->addRow(QObject::tr("Title contains:"), title); + form->addRow(QObject::tr("Creator contains:"), creator); + form->addRow(QObject::tr("Publisher contains:"), publisher); + form->addRow(QObject::tr("Story arc contains:"), storyArc); + form->addRow(QObject::tr("Tags/characters contain:"), tag); + form->addRow(QObject::tr("Path or filename contains:"), path); + + auto *readState = new QComboBox(&dialog); + readState->addItem(QObject::tr("Any"), 0); + readState->addItem(QObject::tr("Unread"), 1); + readState->addItem(QObject::tr("Read"), 2); + readState->setCurrentIndex(readState->findData(rules->value(QStringLiteral("readState")).toInt(0))); + form->addRow(QObject::tr("Read state:"), readState); + + auto *openedState = new QComboBox(&dialog); + openedState->addItem(QObject::tr("Any"), 0); + openedState->addItem(QObject::tr("Never opened"), 1); + openedState->addItem(QObject::tr("Opened"), 2); + openedState->setCurrentIndex(openedState->findData(rules->value(QStringLiteral("openedState")).toInt(0))); + form->addRow(QObject::tr("Opened state:"), openedState); + + auto *type = new QComboBox(&dialog); + type->addItem(QObject::tr("Any"), -1); + type->addItem(QObject::tr("Comic"), 0); + type->addItem(QObject::tr("Manga"), 1); + type->addItem(QObject::tr("Manga left to right"), 2); + type->addItem(QObject::tr("Web comic"), 3); + type->addItem(QObject::tr("4-koma"), 4); + type->setCurrentIndex(type->findData(rules->value(QStringLiteral("type")).toInt(-1))); + form->addRow(QObject::tr("Type:"), type); + + auto makeSpin = [&dialog](int maximum) { + auto *spin = new QSpinBox(&dialog); + spin->setRange(0, maximum); + spin->setSpecialValueText(QObject::tr("Any")); + return spin; + }; + auto *ratingMin = makeSpin(5); + auto *yearFrom = makeSpin(9999); + auto *yearTo = makeSpin(9999); + auto *addedWithinDays = makeSpin(36500); + auto *openedWithinDays = makeSpin(36500); + ratingMin->setValue(rules->value(QStringLiteral("ratingMin")).toInt(0)); + yearFrom->setValue(rules->value(QStringLiteral("yearFrom")).toInt(0)); + yearTo->setValue(rules->value(QStringLiteral("yearTo")).toInt(0)); + addedWithinDays->setValue(rules->value(QStringLiteral("addedWithinDays")).toInt(0)); + openedWithinDays->setValue(rules->value(QStringLiteral("openedWithinDays")).toInt(0)); + form->addRow(QObject::tr("Minimum rating:"), ratingMin); + form->addRow(QObject::tr("Year from:"), yearFrom); + form->addRow(QObject::tr("Year to:"), yearTo); + form->addRow(QObject::tr("Added in last days:"), addedWithinDays); + form->addRow(QObject::tr("Opened in last days:"), openedWithinDays); + + auto *sortField = new QComboBox(&dialog); + sortField->addItem(QObject::tr("Series and issue"), QStringLiteral("series")); + sortField->addItem(QObject::tr("Title"), QStringLiteral("title")); + sortField->addItem(QObject::tr("File name"), QStringLiteral("fileName")); + sortField->addItem(QObject::tr("Publication year"), QStringLiteral("year")); + sortField->addItem(QObject::tr("Added date"), QStringLiteral("added")); + sortField->addItem(QObject::tr("Last opened"), QStringLiteral("lastOpened")); + sortField->addItem(QObject::tr("Rating"), QStringLiteral("rating")); + sortField->addItem(QObject::tr("Publisher"), QStringLiteral("publisher")); + const int sortIndex = sortField->findData(rules->value(QStringLiteral("sortField")).toString(QStringLiteral("series"))); + sortField->setCurrentIndex(sortIndex < 0 ? 0 : sortIndex); + form->addRow(QObject::tr("Sort by:"), sortField); + + auto *sortDirection = new QComboBox(&dialog); + sortDirection->addItem(QObject::tr("Ascending"), QStringLiteral("asc")); + sortDirection->addItem(QObject::tr("Descending"), QStringLiteral("desc")); + const int directionIndex = sortDirection->findData(rules->value(QStringLiteral("sortDirection")).toString(QStringLiteral("asc"))); + sortDirection->setCurrentIndex(directionIndex < 0 ? 0 : directionIndex); + form->addRow(QObject::tr("Sort direction:"), sortDirection); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, [&] { + const auto trimmedName = nameEdit->text().trimmed(); + if (trimmedName.isEmpty()) { + QMessageBox::information(&dialog, QObject::tr("Smart list"), QObject::tr("Enter a name for the smart list.")); + return; + } + const auto updatedRules = smartListRulesFromDialog(series, title, creator, publisher, storyArc, tag, path, + readState, openedState, type, ratingMin, yearFrom, yearTo, + addedWithinDays, openedWithinDays, sortField, sortDirection); + if (updatedRules.size() <= 2) { + QMessageBox::information(&dialog, QObject::tr("Smart list"), QObject::tr("Add at least one rule.")); + return; + } + *name = trimmedName; + *rules = updatedRules; + dialog.accept(); + }); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + + return dialog.exec() == QDialog::Accepted; +} } ReadingListManagementCoordinator::ReadingListManagementCoordinator(QWidget *dialogParent, @@ -292,11 +1100,6 @@ ReadingListManagementCoordinator::ReadingListManagementCoordinator(QWidget *dial connect(listsModel, &ReadingListModel::addComicsToLabel, comicsModel, QOverload &, qulonglong>::of(&ComicModel::addComicsToLabel)); connect(listsModel, &ReadingListModel::addComicsToReadingList, comicsModel, QOverload &, qulonglong>::of(&ComicModel::addComicsToReadingList)); - auto *importCblAction = new QAction(tr("Import CBL Reading List..."), dialogParent); - importCblAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+I"))); - importCblAction->setShortcutContext(Qt::ApplicationShortcut); - dialogParent->addAction(importCblAction); - connect(importCblAction, &QAction::triggered, this, &ReadingListManagementCoordinator::importCblReadingList); } void ReadingListManagementCoordinator::addReadingList() @@ -315,12 +1118,210 @@ void ReadingListManagementCoordinator::addReadingList() if (!accepted) return; - if (currentList.isValid() && listsModel->isReadingList(currentList)) + if (currentList.isValid() && listsModel->isReadingListFolder(currentList)) listsModel->addReadingListAt(name, currentList); else listsModel->addReadingList(name); } +void ReadingListManagementCoordinator::addSmartList() +{ + const auto currentList = currentListProvider(); + if (currentList.isValid() && listsModel->isReadingSubList(currentList)) + return; + + QString name = tr("New smart list"); + QJsonObject rules; + if (!editSmartListDefinition(dialogParent, &name, &rules)) + return; + + QString error; + QString connectionName; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + ensureSmartListTables(db); + + qulonglong id = 0; + if (currentList.isValid() && listsModel->isReadingListFolder(currentList)) { + QSqlQuery childCount(db); + childCount.prepare(QStringLiteral("SELECT COUNT(*) FROM reading_list WHERE parentId = :parentId")); + childCount.bindValue(QStringLiteral(":parentId"), currentList.data(ReadingListModel::IDRole).toULongLong()); + const int ordering = childCount.exec() && childCount.next() ? childCount.value(0).toInt() : 0; + id = DBHelper::insertReadingSubList(name, currentList.data(ReadingListModel::IDRole).toULongLong(), ordering, db); + } else { + id = DBHelper::insertReadingList(name, db); + } + + QSqlQuery smart(db); + smart.prepare(QStringLiteral("INSERT INTO reading_list_smart (reading_list_id, rules_json) VALUES (:id, :rules)")); + smart.bindValue(QStringLiteral(":id"), id); + smart.bindValue(QStringLiteral(":rules"), QString::fromUtf8(QJsonDocument(rules).toJson(QJsonDocument::Compact))); + if (!smart.exec()) + error = smart.lastError().text(); + } + QSqlDatabase::removeDatabase(connectionName); + + if (!error.isEmpty()) { + QMessageBox::critical(dialogParent, tr("Unable to save smart list"), error); + return; + } + + listsModel->setupReadingListsData(listsModel->databasePath()); + emit currentListReselectionRequested(); +} + +void ReadingListManagementCoordinator::editSmartList() +{ + const auto currentList = currentListProvider(); + if (!currentList.isValid() || !listsModel->isSmartList(currentList)) { + QMessageBox::information(dialogParent, tr("Edit smart list"), tr("Select a smart list first.")); + return; + } + + const auto readingListId = currentList.data(ReadingListModel::IDRole).toULongLong(); + QString name = listsModel->name(currentList); + QJsonObject rules; + QString error; + QString connectionName; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + ensureSmartListTables(db); + QSqlQuery smart(db); + smart.prepare(QStringLiteral("SELECT rules_json FROM reading_list_smart WHERE reading_list_id = :id")); + smart.bindValue(QStringLiteral(":id"), readingListId); + if (!smart.exec()) { + error = smart.lastError().text(); + } else if (!smart.next()) { + error = tr("This reading list is not a smart list."); + } else { + rules = QJsonDocument::fromJson(smart.value(0).toString().toUtf8()).object(); + } + } + QSqlDatabase::removeDatabase(connectionName); + + if (!error.isEmpty()) { + QMessageBox::information(dialogParent, tr("Edit smart list"), error); + return; + } + if (!editSmartListDefinition(dialogParent, &name, &rules)) + return; + + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + DBHelper::renameList(readingListId, name, db); + QSqlQuery smart(db); + smart.prepare(QStringLiteral("UPDATE reading_list_smart SET rules_json = :rules WHERE reading_list_id = :id")); + smart.bindValue(QStringLiteral(":rules"), QString::fromUtf8(QJsonDocument(rules).toJson(QJsonDocument::Compact))); + smart.bindValue(QStringLiteral(":id"), readingListId); + if (!smart.exec()) + error = smart.lastError().text(); + } + QSqlDatabase::removeDatabase(connectionName); + + if (!error.isEmpty()) { + QMessageBox::critical(dialogParent, tr("Unable to save smart list"), error); + return; + } + + listsModel->setupReadingListsData(listsModel->databasePath()); + emit currentListReselectionRequested(); +} + +void ReadingListManagementCoordinator::addReadingListFolder() +{ + bool accepted = false; + const auto name = QInputDialog::getText(dialogParent, + tr("Add reading-list folder"), + tr("Folder name:"), + QLineEdit::Normal, + { }, + &accepted); + if (accepted && !name.trimmed().isEmpty()) + listsModel->addReadingListFolder(name.trimmed()); +} + +void ReadingListManagementCoordinator::moveReadingListToFolder() +{ + const auto currentList = currentListProvider(); + if (!currentList.isValid() || !listsModel->isReadingList(currentList)) { + QMessageBox::information(dialogParent, + tr("Move reading list"), + tr("Select a reading list to move.")); + return; + } + + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + const auto connectionName = db.connectionName(); + QSqlQuery folders(db); + if (!folders.exec(QStringLiteral("SELECT rl.id, rl.name FROM reading_list rl " + "INNER JOIN reading_list_folder rlf ON rlf.reading_list_id = rl.id " + "ORDER BY rl.name COLLATE NOCASE"))) { + QMessageBox::critical(dialogParent, tr("Unable to move reading list"), folders.lastError().text()); + db.close(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(connectionName); + return; + } + + QStringList choices { tr("Top level") }; + QList folderIds { 0 }; + while (folders.next()) { + folderIds.append(folders.value(0).toULongLong()); + choices.append(folders.value(1).toString()); + } + + bool accepted = false; + const auto choice = QInputDialog::getItem(dialogParent, + tr("Move reading list"), + tr("Destination:"), + choices, + 0, + false, + &accepted); + if (!accepted) { + db.close(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(connectionName); + return; + } + + const auto destinationId = folderIds.at(choices.indexOf(choice)); + QSqlQuery count(db); + if (destinationId == 0) + count.prepare(QStringLiteral("SELECT COUNT(*) FROM reading_list WHERE parentId IS NULL")); + else { + count.prepare(QStringLiteral("SELECT COUNT(*) FROM reading_list WHERE parentId = :parentId")); + count.bindValue(QStringLiteral(":parentId"), destinationId); + } + + int ordering = 0; + if (count.exec() && count.next()) + ordering = count.value(0).toInt(); + + QSqlQuery move(db); + move.prepare(QStringLiteral("UPDATE reading_list SET parentId = :parentId, ordering = :ordering WHERE id = :id")); + move.bindValue(QStringLiteral(":parentId"), destinationId == 0 ? QVariant() : QVariant::fromValue(destinationId)); + move.bindValue(QStringLiteral(":ordering"), ordering); + move.bindValue(QStringLiteral(":id"), currentList.data(ReadingListModel::IDRole)); + const auto moved = move.exec(); + const auto error = move.lastError().text(); + + db.close(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(connectionName); + + if (!moved) { + QMessageBox::critical(dialogParent, tr("Unable to move reading list"), error); + return; + } + + listsModel->setupReadingListsData(listsModel->databasePath()); + emit currentListReselectionRequested(); +} + void ReadingListManagementCoordinator::importCblReadingList() { const auto filePath = QFileDialog::getOpenFileName(dialogParent, @@ -330,6 +1331,43 @@ void ReadingListManagementCoordinator::importCblReadingList() if (filePath.isEmpty()) return; + importCblReadingListFromPath(filePath); +} + +void ReadingListManagementCoordinator::updateCblReadingListFromSource() +{ + const auto currentList = currentListProvider(); + if (!currentList.isValid() || !listsModel->isReadingList(currentList)) { + QMessageBox::information(dialogParent, tr("Update reading list"), tr("Select an imported CBL reading list first.")); + return; + } + + const auto readingListId = currentList.data(ReadingListModel::IDRole).toULongLong(); + QString sourcePath; + QString connectionName; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + QSqlQuery source(db); + source.prepare(QStringLiteral("SELECT source_path FROM cbl_reading_list_meta WHERE reading_list_id = :id")); + source.bindValue(QStringLiteral(":id"), readingListId); + if (source.exec() && source.next()) + sourcePath = source.value(0).toString(); + } + QSqlDatabase::removeDatabase(connectionName); + + if (sourcePath.isEmpty() || !QFileInfo::exists(sourcePath)) { + QMessageBox::warning(dialogParent, + tr("Source CBL unavailable"), + tr("The original CBL file could not be found. Use Import CBL to choose it again.")); + return; + } + importCblReadingListFromPath(sourcePath, readingListId); +} + +void ReadingListManagementCoordinator::importCblReadingListFromPath(const QString &filePath, qulonglong updateReadingListId) +{ + const auto result = CblReader::read(filePath); if (!result.success) { QString details = result.errorMessage; @@ -348,14 +1386,32 @@ void ReadingListManagementCoordinator::importCblReadingList() } QList libraryComics; + QHash remaps; QString databaseError; QString connectionName; + bool matchingCanceled = false; + QProgressDialog matchingProgress(tr("Loading library comics for matching..."), + tr("Cancel"), + 0, + 0, + dialogParent); + matchingProgress.setWindowTitle(tr("Matching comics")); + matchingProgress.setWindowModality(Qt::WindowModal); + matchingProgress.setMinimumDuration(0); + matchingProgress.setAutoClose(false); + matchingProgress.setAutoReset(false); + matchingProgress.show(); + QApplication::processEvents(); { QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); connectionName = db.connectionName(); + if (!ensureCblImportTables(db, &databaseError)) { + // databaseError is reported below + } + QSqlQuery query(db); - if (!query.exec(QStringLiteral("SELECT c.id, c.fileName, ci.series, ci.number, ci.volume " + if (databaseError.isEmpty() && !query.exec(QStringLiteral("SELECT c.id, c.fileName, ci.series, ci.number, ci.volume, ci.comicVineID, ci.date, ci.format, ci.alternateSeries " "FROM comic c " "INNER JOIN comic_info ci ON c.comicInfoId = ci.id"))) { databaseError = query.lastError().text(); @@ -367,21 +1423,146 @@ void ReadingListManagementCoordinator::importCblReadingList() comic.series = query.value(2).toString(); comic.number = query.value(3).toString(); comic.volume = query.value(4).toString(); + comic.comicVineIssueId = query.value(5).toString(); + comic.year = query.value(6).toString().right(4); + comic.format = query.value(7).toString(); + comic.alternateSeries = query.value(8).toString(); libraryComics.append(comic); + + if (libraryComics.size() % 100 == 0) { + matchingProgress.setLabelText(tr("Loading library comics for matching... %1") + .arg(libraryComics.size())); + QApplication::processEvents(); + if (matchingProgress.wasCanceled()) { + matchingCanceled = true; + break; + } + } + } + + QSqlQuery remapQuery(db); + if (!matchingCanceled && !remapQuery.exec(QStringLiteral("SELECT series_key, volume_key, number_key, comic_id FROM cbl_remap_rule"))) { + databaseError = remapQuery.lastError().text(); + } else if (!matchingCanceled) { + while (remapQuery.next()) { + const QString key = remapQuery.value(0).toString() + QLatin1Char('|') + + remapQuery.value(1).toString() + QLatin1Char('|') + remapQuery.value(2).toString(); + remaps.insert(key, remapQuery.value(3).toULongLong()); + } } } } QSqlDatabase::removeDatabase(connectionName); + if (matchingCanceled || matchingProgress.wasCanceled()) { + matchingProgress.close(); + return; + } + + matchingProgress.hide(); + bool createCopy = false; + if (updateReadingListId == 0) { + QString duplicateConnection; + qulonglong duplicateId = 0; + QString duplicateName; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + duplicateConnection = db.connectionName(); + ensureCblImportTables(db, nullptr); + QSqlQuery duplicate(db); + duplicate.prepare(QStringLiteral( + "SELECT rl.id, rl.name FROM reading_list rl " + "LEFT JOIN cbl_reading_list_meta m ON m.reading_list_id = rl.id " + "WHERE m.source_path = :path OR LOWER(rl.name) = LOWER(:name) LIMIT 1")); + duplicate.bindValue(QStringLiteral(":path"), QFileInfo(filePath).canonicalFilePath()); + duplicate.bindValue(QStringLiteral(":name"), result.readingList.name); + if (duplicate.exec() && duplicate.next()) { + duplicateId = duplicate.value(0).toULongLong(); + duplicateName = duplicate.value(1).toString(); + } + } + QSqlDatabase::removeDatabase(duplicateConnection); + + if (duplicateId != 0) { + QMessageBox duplicateDialog(dialogParent); + duplicateDialog.setWindowTitle(tr("Reading list already exists")); + duplicateDialog.setText(tr("A reading list named “%1” or imported from this source already exists.").arg(duplicateName)); + duplicateDialog.setInformativeText(tr("Update replaces its entries after review. Create copy keeps both lists.")); + auto *updateButton = duplicateDialog.addButton(tr("Update existing"), QMessageBox::AcceptRole); + auto *copyButton = duplicateDialog.addButton(tr("Create copy"), QMessageBox::ActionRole); + duplicateDialog.addButton(QMessageBox::Cancel); + duplicateDialog.exec(); + if (duplicateDialog.clickedButton() == updateButton) + updateReadingListId = duplicateId; + else if (duplicateDialog.clickedButton() == copyButton) + createCopy = true; + else + return; + } + } + if (!databaseError.isEmpty()) { + matchingProgress.close(); QMessageBox::critical(dialogParent, tr("Unable to inspect library"), tr("YACReader could not read comic metadata for CBL matching.\n\n%1").arg(databaseError)); return; } - QString preview; - const int previewLimit = 30; + if (updateReadingListId != 0) { + QStringList oldOrder; + QString comparisonConnection; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + comparisonConnection = db.connectionName(); + QSqlQuery oldEntries(db); + oldEntries.prepare(QStringLiteral("SELECT series, volume, number FROM cbl_reading_list_entry " + "WHERE reading_list_id = :id ORDER BY ordering")); + oldEntries.bindValue(QStringLiteral(":id"), updateReadingListId); + if (oldEntries.exec()) { + while (oldEntries.next()) { + CblBook oldBook; + oldBook.series = oldEntries.value(0).toString(); + oldBook.volume = oldEntries.value(1).toString(); + oldBook.number = oldEntries.value(2).toString(); + oldOrder.append(remapKey(oldBook)); + } + } + } + QSqlDatabase::removeDatabase(comparisonConnection); + + QStringList newOrder; + QHash oldCounts; + QHash newCounts; + for (const auto &key : oldOrder) + ++oldCounts[key]; + for (const auto &book : result.readingList.books) { + const auto key = remapKey(book); + newOrder.append(key); + ++newCounts[key]; + } + int added = 0; + int removed = 0; + for (auto it = newCounts.cbegin(); it != newCounts.cend(); ++it) + added += qMax(0, it.value() - oldCounts.value(it.key())); + for (auto it = oldCounts.cbegin(); it != oldCounts.cend(); ++it) + removed += qMax(0, it.value() - newCounts.value(it.key())); + const bool reordered = added == 0 && removed == 0 && oldOrder != newOrder; + const auto answer = QMessageBox::question( + dialogParent, + tr("Update from source CBL"), + tr("Source comparison:\n\n%1 added\n%2 removed\n%3\n\nContinue to matching review?") + .arg(added) + .arg(removed) + .arg(reordered ? tr("Order changed") : tr("No order-only changes detected")), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::Yes); + if (answer != QMessageBox::Yes) + return; + } + + matchingProgress.show(); + const int count = result.readingList.books.size(); int matchedCount = 0; int missingCount = 0; @@ -389,9 +1570,13 @@ void ReadingListManagementCoordinator::importCblReadingList() QList matchedEntries; matchedEntries.reserve(count); + matchingProgress.setLabelText(tr("Matching comics... 0 of %1").arg(count)); + matchingProgress.setRange(0, qMax(count, 1)); + matchingProgress.setValue(0); + for (int i = 0; i < count; ++i) { const auto &book = result.readingList.books.at(i); - const auto match = matchBook(book, libraryComics); + const auto match = matchBook(book, libraryComics, remaps, &matchingProgress); matchedEntries.append({ book, match }); if (match.state == CblMatchState::Matched) @@ -401,51 +1586,40 @@ void ReadingListManagementCoordinator::importCblReadingList() else ++missingCount; - if (i >= previewLimit) - continue; - - const QString bookName = QStringLiteral("%1 #%2") - .arg(book.series.isEmpty() ? tr("Unknown series") : book.series) - .arg(book.number.isEmpty() ? QStringLiteral("?") : book.number); - - if (match.state == CblMatchState::Matched) { - preview += tr("%1. [MATCHED] %2 -> %3\n") - .arg(i + 1) - .arg(bookName) - .arg(match.candidates.constFirst().fileName); - } else if (match.state == CblMatchState::Ambiguous) { - preview += tr("%1. [AMBIGUOUS] %2 -> %3 possible matches\n") - .arg(i + 1) - .arg(bookName) - .arg(match.candidates.size()); - } else { - preview += tr("%1. [MISSING] %2\n").arg(i + 1).arg(bookName); + matchingProgress.setValue(i + 1); + matchingProgress.setLabelText(tr("Matching comics... %1 of %2").arg(i + 1).arg(count)); + if ((i + 1) % 25 == 0 || i + 1 == count) + QApplication::processEvents(); + if (matchingProgress.wasCanceled()) { + matchingProgress.close(); + return; } } - if (count > previewLimit) - preview += tr("\n...and %1 more entries.").arg(count - previewLimit); - - const auto answer = QMessageBox::question( - dialogParent, - tr("Import CBL reading list"), - tr("%1\n\n%2 entries\n%3 matched\n%4 missing\n%5 ambiguous\n\n%6\n\n" - "Import this reading list? Matched comics will be added immediately. " - "Missing and ambiguous entries will be preserved for placeholder/manual matching support.") - .arg(result.readingList.name) - .arg(count) - .arg(matchedCount) - .arg(missingCount) - .arg(ambiguousCount) - .arg(preview), - QMessageBox::Yes | QMessageBox::No, - QMessageBox::No); + matchingProgress.close(); - if (answer != QMessageBox::Yes) + if (!reviewMatches(dialogParent, result.readingList.name, &matchedEntries, libraryComics, tr("Import"))) return; + matchedCount = missingCount = ambiguousCount = 0; + for (const auto &entry : std::as_const(matchedEntries)) { + if (entry.match.state == CblMatchState::Matched) + ++matchedCount; + else if (entry.match.state == CblMatchState::Ambiguous) + ++ambiguousCount; + else + ++missingCount; + } + QString importError; qulonglong readingListId = 0; + const auto selectedList = currentListProvider(); + const qulonglong parentReadingListId = listsModel->isReadingListFolder(selectedList) + ? selectedList.data(ReadingListModel::IDRole).toULongLong() : 0; + QString sourceHash; + QFile sourceFile(filePath); + if (sourceFile.open(QIODevice::ReadOnly)) + sourceHash = QString::fromLatin1(QCryptographicHash::hash(sourceFile.readAll(), QCryptographicHash::Sha256).toHex()); connectionName.clear(); { QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); @@ -457,6 +1631,11 @@ void ReadingListManagementCoordinator::importCblReadingList() result.readingList, matchedEntries, QFileInfo(filePath).fileName(), + QFileInfo(filePath).canonicalFilePath(), + sourceHash, + parentReadingListId, + updateReadingListId, + createCopy, &readingListId, &importError); } @@ -483,6 +1662,498 @@ void ReadingListManagementCoordinator::importCblReadingList() .arg(missingCount + ambiguousCount)); } +void ReadingListManagementCoordinator::exportReadingList() +{ + const auto currentList = currentListProvider(); + if (!currentList.isValid() || !listsModel->isReadingList(currentList)) { + QMessageBox::information(dialogParent, + tr("Export reading list"), + tr("Select a reading list to export.")); + return; + } + + const auto readingListId = currentList.data(ReadingListModel::IDRole).toULongLong(); + const auto readingListName = listsModel->name(currentList); + const bool exportingFolder = listsModel->isReadingListFolder(currentList); + bool separateFiles = false; + if (exportingFolder) { + bool accepted = false; + const auto mode = QInputDialog::getItem(dialogParent, + tr("Export reading-list folder"), + tr("Export format:"), + { tr("One combined CBL"), tr("Separate CBL files") }, + 0, + false, + &accepted); + if (!accepted) + return; + separateFiles = mode == tr("Separate CBL files"); + } + QList books; + QMap> booksByList; + QString exportError; + QString connectionName; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + DBHelper::ensureReadingListEntries(db); + ensureCblImportTables(db, &exportError); + + bool importedCbl = false; + QSqlQuery tableCheck(db); + if (tableCheck.exec(QStringLiteral("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cbl_reading_list_meta'")) + && tableCheck.next()) { + QSqlQuery importedCheck(db); + importedCheck.prepare(QStringLiteral("SELECT 1 FROM cbl_reading_list_meta WHERE reading_list_id = :id")); + importedCheck.bindValue(QStringLiteral(":id"), readingListId); + importedCbl = importedCheck.exec() && importedCheck.next(); + } + + QSqlQuery entries(db); + if (exportingFolder) { + entries.prepare(QStringLiteral( + "SELECT series, number, volume, year, format, file_name, source_id, comicvine_series_id, comicvine_issue_id, list_name FROM (" + "SELECT rl.ordering list_order, e.ordering entry_order, e.series, e.number, e.volume, e.year, e.format, e.file_name, " + "e.source_id, e.comicvine_series_id, e.comicvine_issue_id, rl.name list_name FROM reading_list rl " + "INNER JOIN cbl_reading_list_meta m ON m.reading_list_id = rl.id " + "INNER JOIN cbl_reading_list_entry e ON e.reading_list_id = rl.id WHERE rl.parentId = :imported_folder_id " + "UNION ALL " + "SELECT rl.ordering, e.ordering, COALESCE(ci.series,e.series), COALESCE(ci.number,e.number), COALESCE(ci.volume,e.volume), " + "substr(COALESCE(ci.date,e.year),-4), COALESCE(ci.format,e.format), COALESCE(c.fileName,e.file_name), '', '', " + "COALESCE(ci.comicVineID,e.comicvine_issue_id), rl.name FROM reading_list rl " + "INNER JOIN reading_list_entry e ON e.reading_list_id = rl.id " + "LEFT JOIN comic c ON c.id = e.comic_id LEFT JOIN comic_info ci ON ci.id = c.comicInfoId " + "WHERE rl.parentId = :regular_folder_id AND NOT EXISTS (SELECT 1 FROM cbl_reading_list_meta m WHERE m.reading_list_id = rl.id)) " + "ORDER BY list_order, entry_order")); + } else if (importedCbl) { + entries.prepare(QStringLiteral( + "SELECT series, number, volume, year, format, file_name, source_id, " + "comicvine_series_id, comicvine_issue_id " + "FROM cbl_reading_list_entry WHERE reading_list_id = :id ORDER BY ordering")); + } else { + DBHelper::ensureReadingListEntries(db); + entries.prepare(QStringLiteral( + "SELECT COALESCE(ci.series,e.series), COALESCE(ci.number,e.number), COALESCE(ci.volume,e.volume), " + "substr(COALESCE(ci.date,e.year),-4), COALESCE(ci.format,e.format), COALESCE(c.fileName,e.file_name), " + "'', '', COALESCE(ci.comicVineID,e.comicvine_issue_id) " + "FROM reading_list_entry e " + "LEFT JOIN comic c ON c.id = e.comic_id " + "LEFT JOIN comic_info ci ON ci.id = c.comicInfoId " + "WHERE e.reading_list_id = :id ORDER BY e.ordering")); + } + if (exportingFolder) { + entries.bindValue(QStringLiteral(":imported_folder_id"), readingListId); + entries.bindValue(QStringLiteral(":regular_folder_id"), readingListId); + } else { + entries.bindValue(QStringLiteral(":id"), readingListId); + } + + if (!entries.exec()) { + exportError = entries.lastError().text(); + } else { + while (entries.next()) { + CblBook book; + book.series = entries.value(0).toString(); + book.number = entries.value(1).toString(); + book.volume = entries.value(2).toString(); + const auto storedDate = entries.value(3).toString(); + book.year = storedDate; + book.format = entries.value(4).toString(); + book.fileName = entries.value(5).toString(); + book.id = entries.value(6).toString(); + book.comicVineSeriesId = entries.value(7).toString(); + book.comicVineIssueId = entries.value(8).toString(); + books.append(book); + if (exportingFolder) + booksByList[entries.value(9).toString()].append(book); + } + } + } + QSqlDatabase::removeDatabase(connectionName); + + if (!exportError.isEmpty()) { + QMessageBox::critical(dialogParent, tr("Unable to export reading list"), exportError); + return; + } + + if (separateFiles) { + const auto directory = QFileDialog::getExistingDirectory(dialogParent, tr("Export reading lists")); + if (directory.isEmpty()) + return; + for (auto it = booksByList.cbegin(); it != booksByList.cend(); ++it) { + QString error; + const auto path = QDir(directory).filePath(safeFileName(it.key()) + QStringLiteral(".cbl")); + if (!writeCblFile(path, it.key(), it.value(), &error)) { + QMessageBox::critical(dialogParent, tr("Unable to export reading lists"), tr("%1\n\n%2").arg(path, error)); + return; + } + } + QMessageBox::information(dialogParent, + tr("Reading lists exported"), + tr("%1 reading lists were exported to:\n%2").arg(booksByList.size()).arg(directory)); + return; + } + + const QString safeName = safeFileName(readingListName); + QString filePath = QFileDialog::getSaveFileName(dialogParent, + tr("Export reading list"), + safeName + QStringLiteral(".cbl"), + tr("Comic Book Reading Lists (*.cbl)")); + if (filePath.isEmpty()) + return; + if (!filePath.endsWith(QStringLiteral(".cbl"), Qt::CaseInsensitive)) + filePath += QStringLiteral(".cbl"); + + QString writeError; + if (!writeCblFile(filePath, readingListName, books, &writeError)) { + QMessageBox::critical(dialogParent, + tr("Unable to export reading list"), + writeError); + return; + } + + QMessageBox::information(dialogParent, + tr("Reading list exported"), + tr("%1 comics were exported to:\n%2").arg(books.size()).arg(filePath)); +} + +void ReadingListManagementCoordinator::showMissingComics() +{ + const auto currentList = currentListProvider(); + if (!currentList.isValid() || !listsModel->isReadingList(currentList)) { + QMessageBox::information(dialogParent, + tr("Missing comics"), + tr("Select a reading list first.")); + return; + } + + const auto readingListId = currentList.data(ReadingListModel::IDRole).toULongLong(); + const auto readingListName = listsModel->name(currentList); + const bool folderReport = listsModel->isReadingListFolder(currentList); + QList missingComics; + QString loadError; + QString connectionName; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + DBHelper::ensureReadingListEntries(db); + ensureCblImportTables(db, &loadError); + + bool importedCbl = false; + QSqlQuery tableCheck(db); + if (tableCheck.exec(QStringLiteral("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cbl_reading_list_meta'")) + && tableCheck.next()) { + QSqlQuery importedCheck(db); + importedCheck.prepare(QStringLiteral("SELECT 1 FROM cbl_reading_list_meta WHERE reading_list_id = :id")); + importedCheck.bindValue(QStringLiteral(":id"), readingListId); + importedCbl = importedCheck.exec() && importedCheck.next(); + } + + QSqlQuery entries(db); + if (folderReport) { + entries.prepare(QStringLiteral( + "SELECT series, number, volume, year, format, file_name, source_id, comicvine_series_id, comicvine_issue_id FROM (" + "SELECT rl.ordering list_order, e.ordering entry_order, e.series, e.number, e.volume, e.year, e.format, e.file_name, " + "e.source_id, e.comicvine_series_id, e.comicvine_issue_id FROM reading_list rl " + "INNER JOIN cbl_reading_list_meta m ON m.reading_list_id = rl.id " + "INNER JOIN cbl_reading_list_entry e ON e.reading_list_id = rl.id " + "WHERE rl.parentId = :imported_folder_id AND e.comic_id IS NULL " + "UNION ALL " + "SELECT rl.ordering, e.ordering, e.series, e.number, e.volume, e.year, e.format, e.file_name, '', '', e.comicvine_issue_id " + "FROM reading_list rl INNER JOIN reading_list_entry e ON e.reading_list_id = rl.id " + "WHERE rl.parentId = :regular_folder_id AND e.comic_id IS NULL " + "AND NOT EXISTS (SELECT 1 FROM cbl_reading_list_meta m WHERE m.reading_list_id = rl.id) " + "ORDER BY list_order, entry_order")); + } else if (importedCbl) { + entries.prepare(QStringLiteral( + "SELECT series, number, volume, year, format, file_name, source_id, " + "comicvine_series_id, comicvine_issue_id " + "FROM cbl_reading_list_entry WHERE reading_list_id = :id AND comic_id IS NULL ORDER BY ordering")); + } else { + DBHelper::ensureReadingListEntries(db); + entries.prepare(QStringLiteral( + "SELECT series, number, volume, year, format, file_name, '', '', comicvine_issue_id " + "FROM reading_list_entry WHERE reading_list_id = :id AND comic_id IS NULL ORDER BY ordering")); + } + if (folderReport) { + entries.bindValue(QStringLiteral(":imported_folder_id"), readingListId); + entries.bindValue(QStringLiteral(":regular_folder_id"), readingListId); + } else { + entries.bindValue(QStringLiteral(":id"), readingListId); + } + + if (!entries.exec()) { + loadError = entries.lastError().text(); + } else { + while (entries.next()) { + MissingComicEntry comic; + comic.series = entries.value(0).toString(); + comic.number = entries.value(1).toString(); + comic.volume = entries.value(2).toString(); + comic.year = entries.value(3).toString(); + comic.format = entries.value(4).toString(); + comic.fileName = entries.value(5).toString(); + comic.sourceId = entries.value(6).toString(); + comic.comicVineSeriesId = entries.value(7).toString(); + comic.comicVineIssueId = entries.value(8).toString(); + missingComics.append(comic); + } + } + } + QSqlDatabase::removeDatabase(connectionName); + + if (!loadError.isEmpty()) { + QMessageBox::critical(dialogParent, tr("Unable to load missing comics"), loadError); + return; + } + + QDialog dialog(dialogParent); + dialog.setWindowTitle(tr("Missing comics — %1").arg(readingListName)); + dialog.resize(1150, 650); + auto *layout = new QVBoxLayout(&dialog); + layout->addWidget(new QLabel(tr("%1 missing comics in %2") + .arg(missingComics.size()) + .arg(readingListName), + &dialog)); + + const QStringList headers { tr("Series"), tr("Issue"), tr("Volume"), tr("Year"), tr("Format"), + tr("File name"), tr("Source ID"), tr("ComicVine series"), tr("ComicVine issue") }; + auto *table = new QTableWidget(missingComics.size(), headers.size(), &dialog); + table->setHorizontalHeaderLabels(headers); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSortingEnabled(false); + for (int row = 0; row < missingComics.size(); ++row) { + const auto &comic = missingComics.at(row); + const QStringList values { comic.series, comic.number, comic.volume, comic.year, comic.format, + comic.fileName, comic.sourceId, comic.comicVineSeriesId, comic.comicVineIssueId }; + for (int column = 0; column < values.size(); ++column) + table->setItem(row, column, new QTableWidgetItem(values.at(column))); + } + table->setSortingEnabled(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + table->horizontalHeader()->setStretchLastSection(true); + layout->addWidget(table); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog); + auto *matchAll = buttons->addButton(tr("Match all again"), QDialogButtonBox::ActionRole); + auto *exportPdf = buttons->addButton(tr("Export PDF..."), QDialogButtonBox::ActionRole); + matchAll->setEnabled(!missingComics.isEmpty()); + exportPdf->setEnabled(!missingComics.isEmpty()); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(matchAll, &QPushButton::clicked, &dialog, [&] { + QString relinkConnection; + int relinked = 0; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + relinkConnection = db.connectionName(); + if (folderReport) { + QSqlQuery children(db); + children.prepare(QStringLiteral("SELECT id FROM reading_list WHERE parentId = :id")); + children.bindValue(QStringLiteral(":id"), readingListId); + if (children.exec()) { + while (children.next()) + relinked += DBHelper::relinkMissingReadingListEntries(db, children.value(0).toULongLong()); + } + } else { + relinked = DBHelper::relinkMissingReadingListEntries(db, readingListId); + } + } + QSqlDatabase::removeDatabase(relinkConnection); + listsModel->setupReadingListsData(listsModel->databasePath()); + emit currentListReselectionRequested(); + QMessageBox::information(&dialog, + tr("Matching complete"), + tr("%1 missing comics were safely relinked.").arg(relinked)); + if (relinked > 0) { + dialog.accept(); + QTimer::singleShot(0, this, &ReadingListManagementCoordinator::showMissingComics); + } + }); + connect(exportPdf, &QPushButton::clicked, &dialog, [&] { + QString safeName = readingListName; + safeName.replace(QRegularExpression(QStringLiteral("[<>:\"/\\|?*]")), QStringLiteral("_")); + QString filePath = QFileDialog::getSaveFileName(&dialog, + tr("Export missing comics report"), + safeName + tr(" - Missing Comics.pdf"), + tr("PDF documents (*.pdf)")); + if (filePath.isEmpty()) + return; + if (!filePath.endsWith(QStringLiteral(".pdf"), Qt::CaseInsensitive)) + filePath += QStringLiteral(".pdf"); + + QString html = QStringLiteral( + "

%1

%2

") + .arg(tr("Missing comics — %1").arg(readingListName).toHtmlEscaped(), + tr("%1 missing comics • Generated %2") + .arg(missingComics.size()) + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm"))) + .toHtmlEscaped()); + for (const auto &header : headers) + html += QStringLiteral("").arg(header.toHtmlEscaped()); + html += QStringLiteral(""); + for (const auto &comic : std::as_const(missingComics)) { + const QStringList values { comic.series, comic.number, comic.volume, comic.year, comic.format, + comic.fileName, comic.sourceId, comic.comicVineSeriesId, comic.comicVineIssueId }; + html += QStringLiteral(""); + for (const auto &value : values) + html += QStringLiteral("").arg(value.toHtmlEscaped()); + html += QStringLiteral(""); + } + html += QStringLiteral("
%1
%1
"); + + QPdfWriter writer(filePath); + writer.setTitle(tr("Missing comics — %1").arg(readingListName)); + writer.setCreator(QStringLiteral("YACReaderLibrary")); + writer.setPageSize(QPageSize(QPageSize::Letter)); + writer.setPageOrientation(QPageLayout::Landscape); + writer.setPageMargins(QMarginsF(10, 10, 10, 10), QPageLayout::Millimeter); + writer.setResolution(144); + + QTextDocument document; + document.setHtml(html); + document.setPageSize(writer.pageLayout().paintRectPixels(writer.resolution()).size()); + document.print(&writer); + + QMessageBox::information(&dialog, + tr("Missing comics report exported"), + tr("The PDF report was saved to:\n%1").arg(filePath)); + }); + layout->addWidget(buttons); + dialog.exec(); +} + +void ReadingListManagementCoordinator::editCblReadingList() +{ + const auto currentList = currentListProvider(); + if (!currentList.isValid() + || currentList.data(ReadingListModel::TypeListsRole).toInt() != ReadingListModel::ReadingList) { + QMessageBox::information(dialogParent, tr("Edit CBL reading list"), tr("Select an imported CBL reading list first.")); + return; + } + + const qulonglong readingListId = currentList.data(ReadingListModel::IDRole).toULongLong(); + QList libraryComics; + QHash comicsById; + QList entries; + CblReadingList readingList; + readingList.name = listsModel->name(currentList); + QString sourceName; + QString sourcePath; + QString sourceHash; + QString loadError; + QString connectionName; + + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + if (!ensureCblImportTables(db, &loadError)) { + // Reported below. + } else { + QSqlQuery meta(db); + meta.prepare(QStringLiteral("SELECT source_name, source_path, source_hash FROM cbl_reading_list_meta WHERE reading_list_id = :id")); + meta.bindValue(QStringLiteral(":id"), readingListId); + if (!meta.exec()) { + loadError = meta.lastError().text(); + } else if (!meta.next()) { + loadError = tr("This is a regular YACReader reading list, not an imported CBL reading list."); + } else { + sourceName = meta.value(0).toString(); + sourcePath = meta.value(1).toString(); + sourceHash = meta.value(2).toString(); + } + } + + if (loadError.isEmpty()) { + QSqlQuery comics(db); + if (!comics.exec(QStringLiteral("SELECT c.id, c.fileName, ci.series, ci.number, ci.volume, ci.comicVineID, ci.date, ci.format, ci.alternateSeries " + "FROM comic c INNER JOIN comic_info ci ON c.comicInfoId = ci.id"))) { + loadError = comics.lastError().text(); + } else { + while (comics.next()) { + LibraryComicMatchData comic; + comic.id = comics.value(0).toULongLong(); + comic.fileName = comics.value(1).toString(); + comic.series = comics.value(2).toString(); + comic.number = comics.value(3).toString(); + comic.volume = comics.value(4).toString(); + comic.comicVineIssueId = comics.value(5).toString(); + comic.year = comics.value(6).toString().right(4); + comic.format = comics.value(7).toString(); + comic.alternateSeries = comics.value(8).toString(); + libraryComics.append(comic); + comicsById.insert(comic.id, comic); + } + } + } + + if (loadError.isEmpty()) { + QSqlQuery storedEntries(db); + storedEntries.prepare(QStringLiteral( + "SELECT ordering, comic_id, series, number, volume, year, format, file_name, source_id, " + "comicvine_series_id, comicvine_issue_id, match_state, match_tier " + "FROM cbl_reading_list_entry WHERE reading_list_id = :id ORDER BY ordering")); + storedEntries.bindValue(QStringLiteral(":id"), readingListId); + if (!storedEntries.exec()) { + loadError = storedEntries.lastError().text(); + } else { + while (storedEntries.next()) { + MatchedCblEntry entry; + entry.book.ordering = storedEntries.value(0).toInt(); + const auto comicId = storedEntries.value(1).toULongLong(); + entry.book.series = storedEntries.value(2).toString(); + entry.book.number = storedEntries.value(3).toString(); + entry.book.volume = storedEntries.value(4).toString(); + entry.book.year = storedEntries.value(5).toString(); + entry.book.format = storedEntries.value(6).toString(); + entry.book.fileName = storedEntries.value(7).toString(); + entry.book.id = storedEntries.value(8).toString(); + entry.book.comicVineSeriesId = storedEntries.value(9).toString(); + entry.book.comicVineIssueId = storedEntries.value(10).toString(); + entry.match.state = static_cast(storedEntries.value(11).toInt()); + entry.match.tier = static_cast(storedEntries.value(12).toInt()); + if (comicId != 0 && comicsById.contains(comicId)) { + entry.match.state = CblMatchState::Matched; + entry.match.candidates = { comicsById.value(comicId) }; + } else if (entry.match.state == CblMatchState::Matched) { + entry.match.state = CblMatchState::Missing; + entry.match.tier = CblMatchTier::None; + } + entries.append(entry); + } + } + } + } + QSqlDatabase::removeDatabase(connectionName); + + if (!loadError.isEmpty()) { + QMessageBox::information(dialogParent, tr("Edit CBL reading list"), loadError); + return; + } + if (!reviewMatches(dialogParent, readingList.name, &entries, libraryComics, tr("Save changes"))) + return; + + QString saveError; + qulonglong savedId = 0; + { + QSqlDatabase db = DataBaseManagement::loadDatabase(listsModel->databasePath()); + connectionName = db.connectionName(); + persistCblReadingList(db, readingList, entries, sourceName, sourcePath, sourceHash, 0, readingListId, false, &savedId, &saveError); + } + QSqlDatabase::removeDatabase(connectionName); + + if (!saveError.isEmpty() || savedId == 0) { + QMessageBox::critical(dialogParent, tr("Unable to save reading list"), saveError); + return; + } + + listsModel->setupReadingListsData(listsModel->databasePath()); + emit currentListReselectionRequested(); +} + void ReadingListManagementCoordinator::deleteCurrentList() { const auto currentList = currentListProvider(); diff --git a/YACReaderLibrary/reading_list_management_coordinator.h b/YACReaderLibrary/reading_list_management_coordinator.h index 0129aa2a3..8de23e835 100644 --- a/YACReaderLibrary/reading_list_management_coordinator.h +++ b/YACReaderLibrary/reading_list_management_coordinator.h @@ -24,7 +24,15 @@ class ReadingListManagementCoordinator : public QObject public slots: void addReadingList(); + void addSmartList(); + void editSmartList(); + void addReadingListFolder(); + void moveReadingListToFolder(); void importCblReadingList(); + void updateCblReadingListFromSource(); + void exportReadingList(); + void showMissingComics(); + void editCblReadingList(); void deleteCurrentList(); void addLabel(); void renameCurrentList(); @@ -33,6 +41,7 @@ public slots: void currentListReselectionRequested(); private: + void importCblReadingListFromPath(const QString &filePath, qulonglong updateReadingListId = 0); QWidget *dialogParent; ReadingListModel *listsModel; CurrentListProvider currentListProvider;