From 09fd5c40d1ad8a33efcfc829cf445ed1de9e4ff0 Mon Sep 17 00:00:00 2001 From: Berthold Stoeger Date: Sun, 10 Jun 2018 16:40:23 +0200 Subject: Dive pictures: implement FindMovedImagesDialog Move the find-moved-images functions into a new translation unit and present the user with the identified matches before applying them. Signed-off-by: Berthold Stoeger --- desktop-widgets/CMakeLists.txt | 2 + desktop-widgets/divelistview.h | 2 +- desktop-widgets/findmovedimagesdialog.cpp | 293 ++++++++++++++++++++++++++++++ desktop-widgets/findmovedimagesdialog.h | 49 +++++ desktop-widgets/findmovedimagesdialog.ui | 179 ++++++++++++++++++ desktop-widgets/mainwindow.cpp | 42 +---- desktop-widgets/mainwindow.h | 1 + 7 files changed, 535 insertions(+), 33 deletions(-) create mode 100644 desktop-widgets/findmovedimagesdialog.cpp create mode 100644 desktop-widgets/findmovedimagesdialog.h create mode 100644 desktop-widgets/findmovedimagesdialog.ui (limited to 'desktop-widgets') diff --git a/desktop-widgets/CMakeLists.txt b/desktop-widgets/CMakeLists.txt index b94b840d1..f5da2ae84 100644 --- a/desktop-widgets/CMakeLists.txt +++ b/desktop-widgets/CMakeLists.txt @@ -34,6 +34,7 @@ set (SUBSURFACE_UI diveshareexportdialog.ui downloadfromdivecomputer.ui filterwidget.ui + findmovedimagesdialog.ui listfilter.ui locationInformation.ui mainwindow.ui @@ -66,6 +67,7 @@ set(SUBSURFACE_INTERFACE diveplanner.cpp diveshareexportdialog.cpp downloadfromdivecomputer.cpp + findmovedimagesdialog.cpp kmessagewidget.cpp mainwindow.cpp mapwidget.cpp diff --git a/desktop-widgets/divelistview.h b/desktop-widgets/divelistview.h index 47b9b5aeb..bd339fa00 100644 --- a/desktop-widgets/divelistview.h +++ b/desktop-widgets/divelistview.h @@ -36,6 +36,7 @@ public: void contextMenuEvent(QContextMenuEvent *event); QList selectedTrips(); static QString lastUsedImageDir(); + static void updateLastUsedImageDir(const QString &s); public slots: void toggleColumnVisibilityByIndex(); @@ -80,7 +81,6 @@ private: void restoreExpandedRows(); int lastVisibleColumn(); void selectTrip(dive_trip_t *trip); - void updateLastUsedImageDir(const QString &s); void updateLastImageTimeOffset(int offset); int lastImageTimeOffset(); void addToTrip(int delta); diff --git a/desktop-widgets/findmovedimagesdialog.cpp b/desktop-widgets/findmovedimagesdialog.cpp new file mode 100644 index 000000000..18a9565b1 --- /dev/null +++ b/desktop-widgets/findmovedimagesdialog.cpp @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "findmovedimagesdialog.h" +#include "core/qthelper.h" +#include "desktop-widgets/divelistview.h" // TODO: used for lastUsedImageDir() +#include "qt-models/divepicturemodel.h" + +#include +#include + +FindMovedImagesDialog::FindMovedImagesDialog(QWidget *parent) : QDialog(parent) +{ + ui.setupUi(this); + fontMetrics.reset(new QFontMetrics(ui.scanning->font())); + connect(&watcher, &QFutureWatcher>::finished, this, &FindMovedImagesDialog::searchDone); + connect(ui.buttonBox->button(QDialogButtonBox::Apply), &QAbstractButton::clicked, this, &FindMovedImagesDialog::apply); + ui.buttonBox->button(QDialogButtonBox::Apply)->setEnabled(false); +} + +// Compare two full paths and return the number of matching levels, starting from the filename. +// String comparison is case-insensitive. +static int matchPath(const QString &path1, const QString &path2) +{ + QFileInfo f1(path1); + QFileInfo f2(path2); + + int score = 0; + for (;;) { + QString fn1 = f1.fileName(); + QString fn2 = f2.fileName(); + if (fn1.isEmpty() || fn2.isEmpty()) + break; + if (fn1 == ".") { + f1 = QFileInfo(f1.path()); + continue; + } + if (fn2 == ".") { + f2 = QFileInfo(f2.path()); + continue; + } + if (QString::compare(fn1, fn2, Qt::CaseInsensitive) != 0) + break; + f1 = QFileInfo(f1.path()); + f2 = QFileInfo(f2.path()); + ++score; + } + return score; +} + +FindMovedImagesDialog::ImagePath::ImagePath(const QString &path) : fullPath(path), + filenameUpperCase(QFileInfo(path).fileName().toUpper()) +{ +} + +bool FindMovedImagesDialog::ImagePath::operator<(const ImagePath &path2) const +{ + return filenameUpperCase < path2.filenameUpperCase; +} + +void FindMovedImagesDialog::learnImage(const QString &filename, QMap &matches, const QVector &imagePaths) +{ + QStringList newMatches; + int bestScore = 1; + // Find matching file paths by a binary search of the file name + ImagePath path(filename); + for (auto it = std::lower_bound(imagePaths.begin(), imagePaths.end(), path); + it != imagePaths.end() && it->filenameUpperCase == path.filenameUpperCase; + ++it) { + int score = matchPath(filename, it->fullPath); + if (score < bestScore) + continue; + if (score > bestScore) + newMatches.clear(); + newMatches.append(it->fullPath); + bestScore = score; + } + + // Add the new original filenames to the list of matches, if the score is higher than previously + for (const QString &originalFilename: newMatches) { + auto it = matches.find(originalFilename); + if (it == matches.end()) + matches.insert(originalFilename, { filename, bestScore }); + else if (it->score < bestScore) + *it = { filename, bestScore }; + } +} + +// We use a stack to recurse into directories. Each level of the stack is made up of +// a list of subdirectories to process. For each directory we keep track of the progress +// that is done when processing this directory. In principle the from value is redundant +// (it could be extracted from previous stack entries, but it makes code simpler. +struct Dir { + QString path; + double progressFrom, progressTo; +}; + +QVector FindMovedImagesDialog::learnImages(const QString &dir, int maxRecursions, QVector imagePathsIn) +{ + QMap matches; + + // For divelogs with thousands of images, we don't want to compare the path of every image. + // Therefore, keep an array of image paths sorted by the filename in upper case. + // Thus, we can access all paths ending in the same filename by a binary search. We suppose that + // there aren't many pictures with the same filename but different paths. + QVector imagePaths; + imagePaths.reserve(imagePathsIn.size()); + for (const QString &path: imagePathsIn) + imagePaths.append(ImagePath(path)); // No emplace() in QVector? Sheesh. + std::sort(imagePaths.begin(), imagePaths.end()); + + // Free memory of original path vector - we don't need it any more + imagePathsIn.clear(); + + QVector> stack; // Use a stack to recurse into directories + stack.reserve(maxRecursions + 1); + stack.append({ { dir, 0.0, 1.0 } }); + while (!stack.isEmpty()) { + if (stack.last().isEmpty()) { + stack.removeLast(); + continue; + } + Dir entry = stack.last().takeLast(); + QDir dir(entry.path); + + // Since we're running in a different thread, use invokeMethod to set progress. + QMetaObject::invokeMethod(this, "setProgress", Q_ARG(double, entry.progressFrom), Q_ARG(QString, dir.absolutePath())); + + for (const QString &file: dir.entryList(QDir::Files)) { + if (stopScanning != 0) + goto out; + learnImage(dir.absoluteFilePath(file), matches, imagePaths); + } + if (stack.size() <= maxRecursions) { + stack.append(QVector()); + QVector &newItem = stack.last(); + for (const QString &dirname: dir.entryList(QDir::NoDotAndDotDot | QDir::Dirs)) + stack.last().append({ dir.filePath(dirname), 0.0, 0.0 }); + int num = newItem.size(); + double diff = entry.progressTo - entry.progressFrom; + // We pop from back therefore we fill the progress in reverse + for (int i = 0; i < num; ++i) { + newItem[num - i - 1].progressFrom = (i / (double)num) * diff + entry.progressFrom; + newItem[num - i - 1].progressTo = ((i + 1) / (double)num) * diff + entry.progressFrom; + } + } + } +out: + QMetaObject::invokeMethod(this, "setProgress", Q_ARG(double, 1.0), Q_ARG(QString, QString())); + QVector ret; + for (auto it = matches.begin(); it != matches.end(); ++it) + ret.append({ it.key(), it->localFilename, it->score }); + return ret; +} + +void FindMovedImagesDialog::setProgress(double progress, QString path) +{ + ui.progress->setValue((int)(progress * 100.0)); + + // Elide text to avoid rescaling of the window if path is too long. + // Note that we subtract an arbitrary 10 pixels from the width, because otherwise the label slowly grows. + QString elidedPath = fontMetrics->elidedText(path, Qt::ElideMiddle, ui.scanning->width() - 10); + ui.scanning->setText(elidedPath); +} + +void FindMovedImagesDialog::on_scanButton_clicked() +{ + if (watcher.isRunning()) { + stopScanning = 1; + return; + } + + // TODO: is lastUsedImageDir really well-placed in DiveListView? + QString dirName = QFileDialog::getExistingDirectory(this, + tr("Traverse image directories"), + DiveListView::lastUsedImageDir(), + QFileDialog::ShowDirsOnly); + if (dirName.isEmpty()) + return; + DiveListView::updateLastUsedImageDir(dirName); + ui.scanButton->setText(tr("Stop scanning")); + ui.buttonBox->setEnabled(false); + ui.imagesText->clear(); + // We have to collect the names of the image filenames in the main thread + bool onlySelected = ui.onlySelectedDives->isChecked(); + QVector imagePaths; + int i; + struct dive *dive; + for_each_dive (i, dive) + if (!onlySelected || dive->selected) + FOR_EACH_PICTURE(dive) + imagePaths.append(QString(picture->filename)); + stopScanning = 0; + QFuture> future = QtConcurrent::run( + // Note that we capture everything but "this" by copy to avoid dangling references. + [this, dirName, imagePaths]() + { return learnImages(dirName, 20, imagePaths);} + ); + watcher.setFuture(future); +} + +static QString formatPath(const QString &path, int numBold) +{ + QString res; + QVector boldPaths; + boldPaths.reserve(numBold); + QFileInfo info(path); + for (int i = 0; i < numBold; ++i) { + QString fn = info.fileName(); + if (fn.isEmpty()) + break; + boldPaths.append(fn); + info = QFileInfo(info.path()); + } + QString nonBoldPath = info.filePath(); + QString separator = QDir::separator(); + if (!nonBoldPath.isEmpty()) { + res += nonBoldPath.toHtmlEscaped(); + if (!boldPaths.isEmpty() && nonBoldPath[nonBoldPath.size() - 1] != QDir::separator()) + res += separator; + } + + if (boldPaths.size() > 0) { + res += ""; + for (int i = boldPaths.size() - 1; i >= 0; --i) { + res += boldPaths[i].toHtmlEscaped(); + if (i > 0) + res += separator; + } + res += ""; + } + return res; +} + +static bool sameFile(const QString &f1, const QString &f2) +{ + return QFileInfo(f1) == QFileInfo(f2); +} + +void FindMovedImagesDialog::searchDone() +{ + ui.scanButton->setText(tr("Select folder and scan")); + ui.buttonBox->setEnabled(true); + ui.scanning->clear(); + + matches = watcher.result(); + ui.imagesText->clear(); + + QString text; + int numChanged = 0; + if (stopScanning != 0) { + text += "" + tr("Scanning cancelled - results may be incomplete") + "
"; + stopScanning = 0; + } + if (matches.isEmpty()) { + text += "" + tr("No matching images found") + ""; + } else { + QString matchesText; + for (const Match &match: matches) { + if (!sameFile(match.localFilename, localFilePath(match.originalFilename))) { + ++numChanged; + matchesText += formatPath(match.originalFilename, match.matchingPathItems) + " → " + + formatPath(match.localFilename, match.matchingPathItems) + "
"; + } + } + int numUnchanged = matches.size() - numChanged; + if (numUnchanged > 0) + text += tr("Found %1 images at their current place.").arg(numUnchanged) + "
"; + if (numChanged > 0) { + text += tr("Found %1 images at new locations:").arg(numChanged) + "
"; + text += matchesText; + } + } + ui.imagesText->setHtml(text); + ui.buttonBox->button(QDialogButtonBox::Apply)->setEnabled(numChanged > 0); +} + +void FindMovedImagesDialog::apply() +{ + for (const Match &match: matches) + learnPictureFilename(match.originalFilename, match.localFilename); + write_hashes(); + DivePictureModel::instance()->updateDivePictures(); + + ui.imagesText->clear(); + matches.clear(); + hide(); + close(); +} + +void FindMovedImagesDialog::on_buttonBox_rejected() +{ + ui.imagesText->clear(); + matches.clear(); +} diff --git a/desktop-widgets/findmovedimagesdialog.h b/desktop-widgets/findmovedimagesdialog.h new file mode 100644 index 000000000..9cae6e8fd --- /dev/null +++ b/desktop-widgets/findmovedimagesdialog.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-2.0 +#ifndef FINDMOVEDIMAGES_H +#define FINDMOVEDIMAGES_H + +#include "ui_findmovedimagesdialog.h" +#include +#include +#include +#include + +class FindMovedImagesDialog : public QDialog { + Q_OBJECT +public: + FindMovedImagesDialog(QWidget *parent = 0); +private +slots: + void on_scanButton_clicked(); + void apply(); + void on_buttonBox_rejected(); + void setProgress(double progress, QString path); + void searchDone(); +private: + struct Match { + QString originalFilename; + QString localFilename; + int matchingPathItems; + }; + struct ImageMatch { + QString localFilename; + int score; + }; + struct ImagePath { + QString fullPath; + QString filenameUpperCase; + ImagePath() = default; // For some reason QVector<...>::reserve() needs a default constructor!? + ImagePath(const QString &path); + inline bool operator<(const ImagePath &path2) const; + }; + Ui::FindMovedImagesDialog ui; + QFutureWatcher> watcher; + QVector matches; + QAtomicInt stopScanning; + QScopedPointer fontMetrics; // Needed to format elided paths + + void learnImage(const QString &filename, QMap &matches, const QVector &imagePaths); + QVector learnImages(const QString &dir, int maxRecursions, QVector imagePaths); +}; + +#endif diff --git a/desktop-widgets/findmovedimagesdialog.ui b/desktop-widgets/findmovedimagesdialog.ui new file mode 100644 index 000000000..b558feec8 --- /dev/null +++ b/desktop-widgets/findmovedimagesdialog.ui @@ -0,0 +1,179 @@ + + + FindMovedImagesDialog + + + Qt::WindowModal + + + + 0 + 0 + 693 + 620 + + + + + 0 + 0 + + + + Find moved images + + + + :subsurface-icon + + + + false + + + + + + Found images + + + + + + QTextEdit::NoWrap + + + true + + + + + + + + + + 0 + + + + + + + + + + 0 + 0 + + + + Match only images in selected dive(s) + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + + + + + + + + + Scanning: + + + + + + + + 0 + 0 + + + + + 75 + true + + + + + + + + + + + Select folder and scan + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Apply|QDialogButtonBox::Cancel + + + + + + + + + + + + + + buttonBox + accepted() + FindMovedImagesDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + FindMovedImagesDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/desktop-widgets/mainwindow.cpp b/desktop-widgets/mainwindow.cpp index dcd7aff32..a7cf6d82e 100644 --- a/desktop-widgets/mainwindow.cpp +++ b/desktop-widgets/mainwindow.cpp @@ -42,6 +42,7 @@ #include "desktop-widgets/divelogimportdialog.h" #include "desktop-widgets/divelogexportdialog.h" #include "desktop-widgets/usersurvey.h" +#include "desktop-widgets/findmovedimagesdialog.h" #include "core/divesitehelpers.h" #include "core/windowtitleupdate.h" #include "desktop-widgets/locationinformation.h" @@ -108,7 +109,8 @@ MainWindow::MainWindow() : QMainWindow(), helpView(0), #endif state(VIEWALL), - survey(0) + survey(0), + findMovedImagesDialog(0) { Q_ASSERT_X(m_Instance == NULL, "MainWindow", "MainWindow recreated!"); m_Instance = this; @@ -701,37 +703,6 @@ void MainWindow::on_actionCloudOnline_triggered() updateCloudOnlineStatus(); } -static void learnImageDirs(QStringList dirnames, QVector imageFilenames) -{ - learnImages(dirnames, 10, imageFilenames); - DivePictureModel::instance()->updateDivePictures(); -} - -void MainWindow::on_actionHash_images_triggered() -{ - QFuture future; - QFileDialog dialog(this, tr("Traverse image directories"), lastUsedDir()); - dialog.setFileMode(QFileDialog::Directory); - dialog.setViewMode(QFileDialog::Detail); - dialog.setLabelText(QFileDialog::Accept, tr("Scan")); - dialog.setLabelText(QFileDialog::Reject, tr("Cancel")); - QStringList dirnames; - if (dialog.exec()) - dirnames = dialog.selectedFiles(); - if (dirnames.isEmpty()) - return; - QVector imageFilenames; - int i; - struct dive *dive; - for_each_dive (i, dive) - FOR_EACH_PICTURE(dive) - imageFilenames.append(QString(picture->filename)); - future = QtConcurrent::run(learnImageDirs, dirnames, imageFilenames); - MainWindow::instance()->getNotificationWidget()->showNotification(tr("Scanning images...(this can take a while)"), KMessageWidget::Information); - MainWindow::instance()->getNotificationWidget()->setFuture(future); - -} - ProfileWidget2 *MainWindow::graphics() const { return qobject_cast(applicationState["Default"].topRight->layout()->itemAt(1)->widget()); @@ -1375,6 +1346,13 @@ void MainWindow::on_actionUserSurvey_triggered() survey->show(); } +void MainWindow::on_actionHash_images_triggered() +{ + if(!findMovedImagesDialog) + findMovedImagesDialog = new FindMovedImagesDialog(this); + findMovedImagesDialog->show(); +} + QString MainWindow::filter_open() { QString f; diff --git a/desktop-widgets/mainwindow.h b/desktop-widgets/mainwindow.h index 6c2b7c1f4..ab9ebf316 100644 --- a/desktop-widgets/mainwindow.h +++ b/desktop-widgets/mainwindow.h @@ -224,6 +224,7 @@ private: void configureToolbar(); void setupSocialNetworkMenu(); QDialog *survey; + QDialog *findMovedImagesDialog; struct dive copyPasteDive; struct dive_components what; QList profileToolbarActions; -- cgit v1.2.3-70-g09d2