diff options
author | Berthold Stoeger <bstoeger@mail.tuwien.ac.at> | 2018-06-10 16:40:23 +0200 |
---|---|---|
committer | Dirk Hohndel <dirk@hohndel.org> | 2018-07-04 02:27:36 +0800 |
commit | 09fd5c40d1ad8a33efcfc829cf445ed1de9e4ff0 (patch) | |
tree | 28d216898ee6065f58a23b2a536f085104ddaa7c /desktop-widgets | |
parent | f3ef38ca0d6db6f7e022a0e565f7460ed22f431e (diff) | |
download | subsurface-09fd5c40d1ad8a33efcfc829cf445ed1de9e4ff0.tar.gz |
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 <bstoeger@mail.tuwien.ac.at>
Diffstat (limited to 'desktop-widgets')
-rw-r--r-- | desktop-widgets/CMakeLists.txt | 2 | ||||
-rw-r--r-- | desktop-widgets/divelistview.h | 2 | ||||
-rw-r--r-- | desktop-widgets/findmovedimagesdialog.cpp | 293 | ||||
-rw-r--r-- | desktop-widgets/findmovedimagesdialog.h | 49 | ||||
-rw-r--r-- | desktop-widgets/findmovedimagesdialog.ui | 179 | ||||
-rw-r--r-- | desktop-widgets/mainwindow.cpp | 42 | ||||
-rw-r--r-- | desktop-widgets/mainwindow.h | 1 |
7 files changed, 535 insertions, 33 deletions
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<dive_trip_t *> 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 <QFileDialog> +#include <QtConcurrent> + +FindMovedImagesDialog::FindMovedImagesDialog(QWidget *parent) : QDialog(parent) +{ + ui.setupUi(this); + fontMetrics.reset(new QFontMetrics(ui.scanning->font())); + connect(&watcher, &QFutureWatcher<QVector<Match>>::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<QString, ImageMatch> &matches, const QVector<ImagePath> &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::Match> FindMovedImagesDialog::learnImages(const QString &dir, int maxRecursions, QVector<QString> imagePathsIn) +{ + QMap<QString, ImageMatch> 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<ImagePath> 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<QVector<Dir>> 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<Dir>()); + QVector<Dir> &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<FindMovedImagesDialog::Match> 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<QString> 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<QVector<Match>> 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<QString> 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 += "<b>"; + for (int i = boldPaths.size() - 1; i >= 0; --i) { + res += boldPaths[i].toHtmlEscaped(); + if (i > 0) + res += separator; + } + res += "</b>"; + } + 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 += "<b>" + tr("Scanning cancelled - results may be incomplete") + "</b><br/>"; + stopScanning = 0; + } + if (matches.isEmpty()) { + text += "<i>" + tr("No matching images found") + "</i>"; + } 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) + "<br/>"; + } + } + int numUnchanged = matches.size() - numChanged; + if (numUnchanged > 0) + text += tr("Found <b>%1</b> images at their current place.").arg(numUnchanged) + "<br/>"; + if (numChanged > 0) { + text += tr("Found <b>%1</b> images at new locations:").arg(numChanged) + "<br/>"; + 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 <QFutureWatcher> +#include <QVector> +#include <QMap> +#include <QAtomicInteger> + +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<QVector<Match>> watcher; + QVector<Match> matches; + QAtomicInt stopScanning; + QScopedPointer<QFontMetrics> fontMetrics; // Needed to format elided paths + + void learnImage(const QString &filename, QMap<QString, ImageMatch> &matches, const QVector<ImagePath> &imagePaths); + QVector<Match> learnImages(const QString &dir, int maxRecursions, QVector<QString> 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>FindMovedImagesDialog</class> + <widget class="QDialog" name="FindMovedImagesDialog"> + <property name="windowModality"> + <enum>Qt::WindowModal</enum> + </property> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>693</width> + <height>620</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Minimum" vsizetype="Minimum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="windowTitle"> + <string>Find moved images</string> + </property> + <property name="windowIcon"> + <iconset> + <normalon>:subsurface-icon</normalon> + </iconset> + </property> + <property name="modal"> + <bool>false</bool> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QGroupBox" name="groupBox"> + <property name="title"> + <string>Found images</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QTextEdit" name="imagesText"> + <property name="lineWrapMode"> + <enum>QTextEdit::NoWrap</enum> + </property> + <property name="readOnly"> + <bool>true</bool> + </property> + <property name="text" stdset="0"> + <string/> + </property> + </widget> + </item> + <item> + <widget class="QProgressBar" name="progress"> + <property name="value"> + <number>0</number> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QLabel" name="onlySelectedDiveslabel"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Match only images in selected dive(s)</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QCheckBox" name="onlySelectedDives"> + <property name="text"> + <string/> + </property> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"/> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QLabel" name="scanningLabel"> + <property name="text"> + <string>Scanning:</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="scanning"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="font"> + <font> + <weight>75</weight> + <bold>true</bold> + </font> + </property> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="scanButton"> + <property name="text"> + <string>Select folder and scan</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Apply|QDialogButtonBox::Cancel</set> + </property> + </widget> + </item> + </layout> + </widget> + </item> + </layout> + </widget> + <resources> + <include location="../subsurface.qrc"/> + </resources> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>accepted()</signal> + <receiver>FindMovedImagesDialog</receiver> + <slot>accept()</slot> + <hints> + <hint type="sourcelabel"> + <x>248</x> + <y>254</y> + </hint> + <hint type="destinationlabel"> + <x>157</x> + <y>274</y> + </hint> + </hints> + </connection> + <connection> + <sender>buttonBox</sender> + <signal>rejected()</signal> + <receiver>FindMovedImagesDialog</receiver> + <slot>reject()</slot> + <hints> + <hint type="sourcelabel"> + <x>316</x> + <y>260</y> + </hint> + <hint type="destinationlabel"> + <x>286</x> + <y>274</y> + </hint> + </hints> + </connection> + </connections> +</ui> 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<QString> imageFilenames) -{ - learnImages(dirnames, 10, imageFilenames); - DivePictureModel::instance()->updateDivePictures(); -} - -void MainWindow::on_actionHash_images_triggered() -{ - QFuture<void> 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<QString> 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<ProfileWidget2*>(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<QAction *> profileToolbarActions; |