File indexing completed on 2024-04-28 15:39:07

0001 // SPDX-FileCopyrightText: 2020-2024 Tobias Leupold <tl at stonemx dot de>
0002 //
0003 // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0004 
0005 // Local includes
0006 #include "MainWindow.h"
0007 #include "SharedObjects.h"
0008 #include "Settings.h"
0009 #include "GpxEngine.h"
0010 #include "PreviewWidget.h"
0011 #include "MapWidget.h"
0012 #include "KGeoTag.h"
0013 #include "SettingsDialog.h"
0014 #include "FixDriftWidget.h"
0015 #include "BookmarksList.h"
0016 #include "ElevationEngine.h"
0017 #include "BookmarksWidget.h"
0018 #include "CoordinatesDialog.h"
0019 #include "RetrySkipAbortDialog.h"
0020 #include "ImagesModel.h"
0021 #include "ImagesListView.h"
0022 #include "Coordinates.h"
0023 #include "AutomaticMatchingWidget.h"
0024 #include "MimeHelper.h"
0025 #include "MapCenterInfo.h"
0026 #include "TracksListView.h"
0027 #include "GeoDataModel.h"
0028 #include "TrackWalker.h"
0029 #include "Logging.h"
0030 
0031 // KDE includes
0032 #include <KActionCollection>
0033 #include <KLocalizedString>
0034 #include <KStandardAction>
0035 #include <KHelpMenu>
0036 #include <KExiv2/KExiv2>
0037 #include <KXMLGUIFactory>
0038 
0039 // Qt includes
0040 #include <QMenuBar>
0041 #include <QAction>
0042 #include <QDockWidget>
0043 #include <QGuiApplication>
0044 #include <QScreen>
0045 #include <QApplication>
0046 #include <QDebug>
0047 #include <QFileDialog>
0048 #include <QProgressDialog>
0049 #include <QFile>
0050 #include <QTimer>
0051 #include <QMessageBox>
0052 #include <QCloseEvent>
0053 #include <QAbstractButton>
0054 #include <QVBoxLayout>
0055 #include <QLoggingCategory>
0056 
0057 // C++ includes
0058 #include <functional>
0059 #include <algorithm>
0060 
0061 static const QHash<QString, KExiv2Iface::KExiv2::MetadataWritingMode> s_writeModeMap {
0062     { QStringLiteral("WRITETOIMAGEONLY"),
0063       KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOIMAGEONLY },
0064     { QStringLiteral("WRITETOSIDECARONLY"),
0065       KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARONLY },
0066     { QStringLiteral("WRITETOSIDECARANDIMAGE"),
0067       KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARANDIMAGE }
0068 };
0069 
0070 MainWindow::MainWindow(SharedObjects *sharedObjects)
0071     : KXmlGuiWindow(),
0072       m_sharedObjects(sharedObjects),
0073       m_settings(sharedObjects->settings()),
0074       m_gpxEngine(sharedObjects->gpxEngine()),
0075       m_elevationEngine(sharedObjects->elevationEngine()),
0076       m_imagesModel(sharedObjects->imagesModel()),
0077       m_geoDataModel(sharedObjects->geoDataModel())
0078 {
0079     setWindowTitle(i18n("KGeoTag"));
0080     setWindowIcon(QIcon::fromTheme(QStringLiteral("kgeotag")));
0081 
0082     connect(m_elevationEngine, &ElevationEngine::elevationProcessed,
0083             this, &MainWindow::elevationProcessed);
0084 
0085     connect(m_geoDataModel, &GeoDataModel::requestAddFiles, this, &MainWindow::addGpx);
0086 
0087     // Menu setup
0088     // ==========
0089 
0090     // File
0091     // ----
0092 
0093     auto *addFilesAction = actionCollection()->addAction(QStringLiteral("addFiles"));
0094     addFilesAction->setText(i18n("Add images and/or GPX tracks"));
0095     addFilesAction->setIcon(QIcon::fromTheme(QStringLiteral("document-new")));
0096     actionCollection()->setDefaultShortcut(addFilesAction, QKeySequence(tr("Ctrl+F")));
0097     connect(addFilesAction, &QAction::triggered,
0098             this, std::bind(&MainWindow::addFiles, this, QStringList()));
0099 
0100     auto *addDirectoryAction = actionCollection()->addAction(QStringLiteral("addDirectory"));
0101     addDirectoryAction->setText(i18n("Add all images and tracks from a folder"));
0102     addDirectoryAction->setIcon(QIcon::fromTheme(QStringLiteral("archive-insert-directory")));
0103     actionCollection()->setDefaultShortcut(addDirectoryAction, QKeySequence(tr("Ctrl+D")));
0104     connect(addDirectoryAction, &QAction::triggered,
0105             this, std::bind(&MainWindow::addDirectory, this, QString()));
0106 
0107     // "Remove" submenu
0108 
0109     auto *removeProcessedSavedImagesAction
0110         = actionCollection()->addAction(QStringLiteral("removeProcessedSavedImages"));
0111     removeProcessedSavedImagesAction->setText(i18n("All processed and saved images"));
0112     connect(removeProcessedSavedImagesAction, &QAction::triggered,
0113             this, &MainWindow::removeProcessedSavedImages);
0114 
0115     auto removeImagesLoadedTaggedAction
0116         = actionCollection()->addAction(QStringLiteral("removeImagesLoadedTagged"));
0117     removeImagesLoadedTaggedAction->setText(i18n("All images that already had coordinates"));
0118     connect(removeImagesLoadedTaggedAction, &QAction::triggered,
0119             this, &MainWindow::removeImagesLoadedTagged);
0120 
0121     auto *removeAllImagesAction = actionCollection()->addAction(QStringLiteral("removeAllImages"));
0122     removeAllImagesAction->setText(i18n("All images"));
0123     connect(removeAllImagesAction, &QAction::triggered, this, &MainWindow::removeAllImages);
0124 
0125     auto *removeAllTracksAction = actionCollection()->addAction(QStringLiteral("removeAllTracks"));
0126     removeAllTracksAction->setText(i18n("All tracks"));
0127     connect(removeAllTracksAction, &QAction::triggered, this, &MainWindow::removeAllTracks);
0128 
0129     auto *removeEverything = actionCollection()->addAction(QStringLiteral("removeEverything"));
0130     removeEverything->setText(i18n("All images and tracks (reset)"));
0131     connect(removeEverything, &QAction::triggered, this, &MainWindow::removeEverything);
0132 
0133     // "File" menu again
0134 
0135     auto *searchMatchesAction = actionCollection()->addAction(QStringLiteral("searchMatches"));
0136     searchMatchesAction->setText(i18n("Assign images to GPS data"));
0137     searchMatchesAction->setIcon(QIcon::fromTheme(QStringLiteral("crosshairs")));
0138     actionCollection()->setDefaultShortcut(searchMatchesAction, QKeySequence(tr("Ctrl+M")));
0139     connect(searchMatchesAction, &QAction::triggered,
0140             this, [this]
0141             {
0142                 triggerCompleteAutomaticMatching(m_settings->defaultMatchingMode());
0143             });
0144 
0145     auto *saveChangesAction = actionCollection()->addAction(QStringLiteral("saveChanges"));
0146     saveChangesAction->setText(i18n("Save changed images"));
0147     saveChangesAction->setIcon(QIcon::fromTheme(QStringLiteral("document-save-all")));
0148     actionCollection()->setDefaultShortcut(saveChangesAction, QKeySequence(tr("Ctrl+S")));
0149     connect(saveChangesAction, &QAction::triggered, this, &MainWindow::saveAllChanges);
0150 
0151     KStandardAction::quit(this, &QWidget::close, actionCollection());
0152 
0153     // Settings
0154     // --------
0155 
0156     auto *setDefaultDockArrangementAction
0157         = actionCollection()->addAction(QStringLiteral("setDefaultDockArrangement"));
0158     setDefaultDockArrangementAction->setText(i18n("Set default dock arrangement"));
0159     setDefaultDockArrangementAction->setIcon(QIcon::fromTheme(QStringLiteral("refactor")));
0160     connect(setDefaultDockArrangementAction, &QAction::triggered,
0161             this, &MainWindow::setDefaultDockArrangement);
0162 
0163     KStandardAction::preferences(this, &MainWindow::showSettings, actionCollection());
0164 
0165     setupGUI(Keys | Save | Create);
0166 
0167     // Elicit a pointer from the "remove" menu from the XmlGui ;-)
0168     // setupGUI() has to be called before this works
0169     auto *removeMenu = qobject_cast<QMenu *>(guiFactory()->container(QStringLiteral("remove"),
0170                                                                      this));
0171     removeMenu->menuAction()->setIcon(QIcon::fromTheme(QStringLiteral("document-close")));
0172 
0173     // Dock setup
0174     // ==========
0175 
0176     setDockNestingEnabled(true);
0177 
0178     // Bookmarks
0179     m_bookmarksWidget = new BookmarksWidget(m_sharedObjects);
0180     m_sharedObjects->setBookmarks(m_bookmarksWidget->bookmarks());
0181     m_bookmarksDock = createDockWidget(i18n("Bookmarks"), m_bookmarksWidget,
0182                                            QStringLiteral("bookmarksDock"));
0183 
0184     // Preview
0185     m_previewWidget = new PreviewWidget(m_sharedObjects);
0186     m_previewDock = createDockWidget(i18n("Preview"), m_previewWidget,
0187                                          QStringLiteral("previewDock"));
0188 
0189     // Automatic matching
0190     m_automaticMatchingWidget = new AutomaticMatchingWidget(m_settings);
0191     m_automaticMatchingDock = createDockWidget(i18n("Automatic matching"),
0192                                                m_automaticMatchingWidget,
0193                                                QStringLiteral("automaticMatchingDock"));
0194     connect(m_automaticMatchingWidget, &AutomaticMatchingWidget::requestReassignment,
0195             this, &MainWindow::triggerCompleteAutomaticMatching);
0196 
0197     // Fix drift
0198     m_fixDriftWidget = new FixDriftWidget;
0199     m_fixDriftDock = createDockWidget(i18n("Fix time drift"), m_fixDriftWidget,
0200                                           QStringLiteral("fixDriftDock"));
0201     connect(m_fixDriftWidget, &FixDriftWidget::imagesTimeZoneChanged,
0202             this, &MainWindow::imagesTimeZoneChanged);
0203     connect(m_fixDriftWidget, &FixDriftWidget::cameraDriftSettingsChanged,
0204             this, &MainWindow::cameraDriftSettingsChanged);
0205 
0206     // Map
0207 
0208     m_mapWidget = m_sharedObjects->mapWidget();
0209     m_mapCenterInfo = new MapCenterInfo(m_sharedObjects);
0210     connect(m_mapWidget, &MapWidget::mapMoved, m_mapCenterInfo, &MapCenterInfo::mapMoved);
0211 
0212     auto *mapWrapper = new QWidget;
0213     auto *mapWrapperLayout = new QVBoxLayout(mapWrapper);
0214     mapWrapperLayout->addWidget(m_mapWidget);
0215     mapWrapperLayout->addWidget(m_mapCenterInfo);
0216 
0217     m_mapDock = createDockWidget(i18n("Map"), mapWrapper, QStringLiteral("mapDock"));
0218 
0219     connect(m_mapWidget, &MapWidget::imagesDropped, this, &MainWindow::imagesDropped);
0220     connect(m_mapWidget, &MapWidget::requestLoadGpx, this, &MainWindow::addGpx);
0221 
0222     // Images lists
0223 
0224     m_unAssignedImagesDock = createImagesDock(KGeoTag::UnAssignedImages, i18n("Unassigned images"),
0225                                               QStringLiteral("unAssignedImagesDock"));
0226     m_assignedOrAllImagesDock = createImagesDock(KGeoTag::AssignedImages, QString(),
0227                                                  QStringLiteral("assignedOrAllImagesDock"));
0228     updateImagesListsMode();
0229 
0230     // Tracks
0231 
0232     m_tracksView = new TracksListView(m_geoDataModel);
0233     connect(m_tracksView, &TracksListView::trackSelected, m_mapWidget, &MapWidget::zoomToTrack);
0234     connect(m_tracksView, &TracksListView::removeTracks, this, &MainWindow::removeTracks);
0235 
0236     auto *trackWalker = new TrackWalker(m_geoDataModel);
0237     connect(m_tracksView, &TracksListView::updateTrackWalker,
0238             trackWalker, &TrackWalker::setToTrack);
0239     connect(trackWalker, &TrackWalker::trackPointSelected, this, &MainWindow::centerTrackPoint);
0240 
0241     auto *tracksWrapper = new QWidget;
0242     auto *tracksWrapperLayout = new QVBoxLayout(tracksWrapper);
0243     tracksWrapperLayout->setContentsMargins(0, 0, 0, 0);
0244     tracksWrapperLayout->addWidget(m_tracksView);
0245     tracksWrapperLayout->addWidget(trackWalker);
0246 
0247     m_tracksDock = createDockWidget(i18n("Tracks"), tracksWrapper, QStringLiteral("tracksDock"));
0248 
0249     // Initialize/Restore the dock widget arrangement
0250     if (! restoreState(m_settings->mainWindowState())) {
0251         setDefaultDockArrangement();
0252     } else {
0253         m_unAssignedImagesDock->setVisible(m_settings->splitImagesList());
0254     }
0255 
0256     // Restore the map's settings
0257     m_mapWidget->restoreSettings();
0258 
0259     // Handle failed elevation lookups and missing locations
0260     connect(m_sharedObjects->elevationEngine(), &ElevationEngine::lookupFailed,
0261             this, &MainWindow::elevationLookupFailed);
0262     connect(m_sharedObjects->elevationEngine(), &ElevationEngine::notAllPresent,
0263             this, &MainWindow::notAllElevationsPresent);
0264 
0265     // Check if we could setup the timezone detection properly
0266     QTimer::singleShot(0, this, [this]
0267     {
0268         // We do this in a QTimer singleShot so that the main window
0269         // will be already visible if this warning should be displayed
0270         if (! m_gpxEngine->timeZoneDataLoaded()) {
0271             QMessageBox::warning(this, i18n("Loading timezone data"),
0272                 i18n("<p>Could not load or parse the timezone data files "
0273                      "<kbd>timezones.json</kbd> and/or <kbd>timezones.png</kbd>. Automatic "
0274                      "timezone detection won't work.</p>"
0275                      "<p>Please check your installation!</p>"
0276                      "<p>If you run manually compiled sources without having installed them, "
0277                      "please refer to <a href=\"https://community.kde.org/KGeoTag"
0278                      "#Running_the_compiled_sources\">KDE's community wiki</a> on how to make "
0279                      "the respective files accessible.</p>"));
0280         }
0281     });
0282 }
0283 
0284 QDockWidget *MainWindow::createImagesDock(KGeoTag::ImagesListType type, const QString &title,
0285                                           const QString &dockId)
0286 {
0287     auto *list = new ImagesListView(type, m_sharedObjects);
0288 
0289     connect(list, &ImagesListView::imageSelected, m_previewWidget, &PreviewWidget::setImage);
0290     connect(list, &ImagesListView::centerImage, m_mapWidget, &MapWidget::centerImage);
0291     connect(m_bookmarksWidget, &BookmarksWidget::bookmarksChanged,
0292             list, &ImagesListView::updateBookmarks);
0293     connect(list, &ImagesListView::requestAutomaticMatching,
0294             this, &MainWindow::triggerAutomaticMatching);
0295     connect(list, &ImagesListView::assignToMapCenter, this, &MainWindow::assignToMapCenter);
0296     connect(list, &ImagesListView::assignManually, this, &MainWindow::assignManually);
0297     connect(list, &ImagesListView::findClosestTrackPoint, this, &MainWindow::findClosestTrackPoint);
0298     connect(list, &ImagesListView::editCoordinates, this, &MainWindow::editCoordinates);
0299     connect(list, QOverload<ImagesListView *>::of(&ImagesListView::removeCoordinates),
0300             this, QOverload<ImagesListView *>::of(&MainWindow::removeCoordinates));
0301     connect(list, QOverload<const QVector<QString> &>::of(&ImagesListView::removeCoordinates),
0302             this, QOverload<const QVector<QString> &>::of(&MainWindow::removeCoordinates));
0303     connect(list, &ImagesListView::discardChanges, this, &MainWindow::discardChanges);
0304     connect(list, &ImagesListView::lookupElevation,
0305             this, QOverload<ImagesListView *>::of(&MainWindow::lookupElevation));
0306     connect(list, &ImagesListView::assignTo, this, &MainWindow::assignTo);
0307     connect(list, &ImagesListView::requestAddingImages, this, &MainWindow::addImages);
0308     connect(list, &ImagesListView::removeImages, this, &MainWindow::removeImages);
0309     connect(list, &ImagesListView::requestSaving, this, &MainWindow::saveSelection);
0310     connect(list, &ImagesListView::failedToParseClipboard,
0311             this, &MainWindow::failedToParseClipboard);
0312 
0313     return createDockWidget(title, list, dockId);
0314 }
0315 
0316 void MainWindow::updateImagesListsMode()
0317 {
0318     if (m_settings->splitImagesList()) {
0319         m_assignedOrAllImagesDock->setWindowTitle(i18n("Assigned images"));
0320         qobject_cast<ImagesListView *>(
0321             m_assignedOrAllImagesDock->widget())->setListType(KGeoTag::AssignedImages);
0322         m_unAssignedImagesDock->show();
0323         qobject_cast<ImagesListView *>(
0324             m_unAssignedImagesDock->widget())->setListType(KGeoTag::UnAssignedImages);
0325         m_imagesModel->setSplitImagesList(true);
0326     } else {
0327         m_assignedOrAllImagesDock->setWindowTitle(i18n("Images"));
0328         qobject_cast<ImagesListView *>(
0329             m_assignedOrAllImagesDock->widget())->setListType(KGeoTag::AllImages);
0330         m_unAssignedImagesDock->hide();
0331         m_imagesModel->setSplitImagesList(false);
0332     }
0333 }
0334 
0335 void MainWindow::setDefaultDockArrangement()
0336 {
0337     const QVector<QDockWidget *> allDocks = {
0338         m_assignedOrAllImagesDock,
0339         m_unAssignedImagesDock,
0340         m_previewDock,
0341         m_fixDriftDock,
0342         m_automaticMatchingDock,
0343         m_bookmarksDock,
0344         m_mapDock
0345     };
0346 
0347     for (auto *dock : allDocks) {
0348         dock->setFloating(false);
0349         addDockWidget(Qt::TopDockWidgetArea, dock);
0350     }
0351 
0352     for (int i = 1; i < allDocks.count(); i++) {
0353         splitDockWidget(allDocks.at(i - 1), allDocks.at(i), Qt::Horizontal);
0354     }
0355 
0356     splitDockWidget(m_assignedOrAllImagesDock, m_previewDock, Qt::Vertical);
0357     splitDockWidget(m_assignedOrAllImagesDock, m_unAssignedImagesDock, Qt::Horizontal);
0358 
0359     const QVector<QDockWidget *> toTabify = {
0360         m_previewDock,
0361         m_fixDriftDock,
0362         m_automaticMatchingDock,
0363         m_bookmarksDock
0364     };
0365 
0366     for (int i = 0; i < toTabify.count() - 1; i++) {
0367         tabifyDockWidget(toTabify.at(i), toTabify.at(i + 1));
0368     }
0369     toTabify.first()->raise();
0370 
0371     tabifyDockWidget(m_assignedOrAllImagesDock, m_tracksDock);
0372     m_assignedOrAllImagesDock->raise();
0373 
0374     const double windowWidth = double(width());
0375     resizeDocks({ m_previewDock, m_mapDock },
0376                 { int(windowWidth * 0.4), int(windowWidth * 0.6) },
0377                 Qt::Horizontal);
0378 }
0379 
0380 QDockWidget *MainWindow::createDockWidget(const QString &title, QWidget *widget,
0381                                           const QString &objectName)
0382 {
0383     auto *dock = new QDockWidget(title, this);
0384     dock->setObjectName(objectName);
0385     dock->setContextMenuPolicy(Qt::PreventContextMenu);
0386     dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable);
0387     dock->setWidget(widget);
0388     addDockWidget(Qt::TopDockWidgetArea, dock);
0389     return dock;
0390 }
0391 
0392 void MainWindow::closeEvent(QCloseEvent *event)
0393 {
0394     if (! m_imagesModel->imagesWithPendingChanges().isEmpty()) {
0395         if (QMessageBox::question(this, i18n("Close KGeoTag"),
0396             i18n("<p>There are pending changes to images that haven't been saved yet. All changes "
0397                  "will be discarded if KGeoTag is closed now.</p>"
0398                  "<p>Do you want to close the program anyway?</p>"),
0399             QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) {
0400 
0401             event->ignore();
0402             return;
0403         }
0404     }
0405 
0406     m_settings->saveMainWindowState(saveState());
0407 
0408     m_mapWidget->saveSettings();
0409 
0410     m_settings->saveBookmarks(m_bookmarksWidget->bookmarks());
0411 
0412     QApplication::quit();
0413 }
0414 
0415 void MainWindow::addPathsFromCommandLine(QStringList &paths)
0416 {
0417     QVector<QString> directories;
0418     QStringList files;
0419 
0420     paths.removeDuplicates();
0421     for (const auto &path : std::as_const(paths)) {
0422         const QFileInfo info(path);
0423         if (info.isDir()) {
0424             directories.append(path);
0425         } else {
0426             files.append(path);
0427         }
0428     }
0429 
0430     QApplication::processEvents();
0431 
0432     if (! directories.isEmpty()) {
0433         for (const auto &directory : std::as_const(directories)) {
0434             addDirectory(directory);
0435         }
0436     }
0437 
0438     if (! files.isEmpty()) {
0439         addFiles(files);
0440     }
0441 }
0442 
0443 void MainWindow::addFiles(const QStringList &files)
0444 {
0445     QStringList selection;
0446 
0447     if (files.isEmpty()) {
0448         selection = QFileDialog::getOpenFileNames(this,
0449                         i18n("Please select the images and/or GPX tracks to add"),
0450                         m_settings->lastOpenPath(),
0451                         i18n("All supported files ("
0452                                  "*.jpg *.jpeg "
0453                                  "*.png "
0454                                  "*.webp "
0455                                  "*.tif *.tiff "
0456                                  "*.ora "
0457                                  "*.kra "
0458                                  "*.cr2 "
0459                                  "*.nef "
0460                                  "*.dng "
0461                                  "*.gpx "
0462                              ");; All files (*)"));
0463     } else {
0464         selection = files;
0465     }
0466 
0467     if (selection.isEmpty()) {
0468         return;
0469     }
0470 
0471     // Check the MIME type of all selected files
0472     QHash<KGeoTag::FileType, QVector<QString>> classified;
0473     for (const auto &path : selection) {
0474         classified[MimeHelper::classifyFile(path)].append(path);
0475     }
0476 
0477     // Inform the user if some unsupported files have been selected
0478 
0479     if (classified.value(KGeoTag::UnsupportedFile).count() > 0) {
0480         QString text;
0481 
0482         if (classified.value(KGeoTag::ImageFile).count() == 0
0483             && classified.value(KGeoTag::GeoDataFile).count() == 0) {
0484 
0485             text = i18n("<p>The selection did not contain any supported files!</p>");
0486 
0487         } else {
0488             QString skippedList;
0489             for (const auto &path : classified.value(KGeoTag::UnsupportedFile)) {
0490                 QFileInfo info(path);
0491                 skippedList.append(i18nc(
0492                     "A filename with a MIME type in braces and a HTML line break",
0493                     "%1 (%2)<br/>",
0494                     info.fileName(),
0495                     MimeHelper::mimeType(path)));
0496             }
0497 
0498             text = i18np("<p>The following file will be skipped due to an unsupported MIME type:"
0499                          "</p>"
0500                          "<p>%2</p>",
0501                          "<p>The following files will be skipped due to unsupported MIME types:</p>"
0502                          "<p>%2</p>",
0503                          classified.value(KGeoTag::UnsupportedFile).count(),
0504                          skippedList);
0505         }
0506 
0507         QMessageBox::warning(this, i18n("Add images and/or GPX tracks"), text);
0508     }
0509 
0510     // Add the geodata files
0511     if (classified.value(KGeoTag::GeoDataFile).count() > 0) {
0512         addGpx(classified.value(KGeoTag::GeoDataFile));
0513     }
0514 
0515     // Add the images
0516     if (classified.value(KGeoTag::ImageFile).count() > 0) {
0517         addImages(classified.value(KGeoTag::ImageFile));
0518     }
0519 }
0520 
0521 void MainWindow::addDirectory(const QString &path)
0522 {
0523     QString directory;
0524 
0525     if (path.isEmpty()) {
0526         directory = QFileDialog::getExistingDirectory(this, i18n("Please select a folder"),
0527                                                       m_settings->lastOpenPath());
0528     } else {
0529         directory = path;
0530     }
0531 
0532     if (directory.isEmpty()) {
0533         return;
0534     }
0535 
0536     QDir dir(directory);
0537     const auto files = dir.entryList({ QStringLiteral("*") }, QDir::Files);
0538 
0539     QVector<QString> geoDataFiles;
0540     QVector<QString> images;
0541     for (const auto &file : files) {
0542         const auto path = directory + QStringLiteral("/") + file;
0543         switch (MimeHelper::classifyFile(path)) {
0544         case KGeoTag::GeoDataFile:
0545             geoDataFiles.append(path);
0546             break;
0547         case KGeoTag::ImageFile:
0548             images.append(path);
0549             break;
0550         case KGeoTag::UnsupportedFile:
0551             break;
0552         }
0553     }
0554 
0555     if (geoDataFiles.isEmpty() && images.isEmpty()) {
0556         QMessageBox::warning(this, i18n("Add all images and tracks from a folder"),
0557                              i18n("Could not find any supported files in <kbd>%1</kbd>",
0558                                   directory));
0559         return;
0560     }
0561 
0562     // Add the geodata files
0563     if (geoDataFiles.count() > 0) {
0564         addGpx(geoDataFiles);
0565     }
0566 
0567     // Add the images
0568     if (images.count() > 0) {
0569         addImages(images);
0570     }
0571 }
0572 
0573 void MainWindow::addGpx(const QVector<QString> &paths)
0574 {
0575     const int filesCount = paths.count();
0576     int processed = 0;
0577     int failed = 0;
0578     int allFiles = 0;
0579     int allTracks = 0;
0580     int allSegments = 0;
0581     int allPoints = 0;
0582     int alreadyLoaded = 0;
0583     QVector<QString> loadedPaths;
0584 
0585     QApplication::setOverrideCursor(Qt::WaitCursor);
0586 
0587     for (const auto &path : paths) {
0588         processed++;
0589 
0590         const QFileInfo info(path);
0591         m_settings->saveLastOpenPath(info.dir().absolutePath());
0592 
0593         const auto [ result, tracks, segments, points ]
0594             = m_gpxEngine->load(info.canonicalFilePath());
0595 
0596         QString errorString;
0597 
0598         switch (result) {
0599         case GpxEngine::Okay:
0600             allFiles++;
0601             allTracks += tracks;
0602             allSegments += segments;
0603             allPoints += points;
0604             loadedPaths.append(path);
0605             break;
0606 
0607         case GpxEngine::AlreadyLoaded:
0608             alreadyLoaded++;
0609             break;
0610 
0611         case GpxEngine::OpenFailed:
0612             errorString = i18n("<p>Opening <kbd>%1</kbd> failed. Please be sure to have read "
0613                                "access to this file.</p>", path);
0614             break;
0615 
0616         case GpxEngine::NoGpxElement:
0617             errorString = i18n("<p>Could not read geodata from <kbd>%1</kbd>: Could not find the "
0618                                "<kbd>gpx</kbd> root element. Apparently, this is not a GPX file!"
0619                                "</p>", path);
0620             break;
0621 
0622         case GpxEngine::NoGeoData:
0623             errorString = i18n("<p><kbd>%1</kbd> seems to be a GPX file, but it contains no "
0624                                "geodata!</p>", path);
0625             break;
0626 
0627         case GpxEngine::XmlError:
0628             errorString = i18n("<p>XML parsing failed for <kbd>%1</kbd>. Either, no data could be "
0629                                "loaded at all, or only a part of it.</p>", path);
0630             break;
0631         }
0632 
0633         if (! errorString.isEmpty()) {
0634             failed++;
0635 
0636             QString text;
0637             if (filesCount == 1) {
0638                 text = i18n("<p><b>Loading GPX file failed</b></p>");
0639             } else {
0640                 text = i18nc("Fraction of processed files are added inside the round braces",
0641                              "<p><b>Loading GPX file failed (%1/%2)</b></p>",
0642                              processed, filesCount);
0643             }
0644 
0645             text.append(errorString);
0646 
0647             if (processed < filesCount) {
0648                 text.append(i18n("<p>The next GPX file will be loaded now.</p>"));
0649             }
0650 
0651             QApplication::restoreOverrideCursor();
0652             QMessageBox::warning(this, i18n("Load GPX data"), text);
0653             QApplication::setOverrideCursor(Qt::WaitCursor);
0654         }
0655     }
0656 
0657     m_mapWidget->zoomToTracks(loadedPaths);
0658 
0659     QString text;
0660 
0661     if (failed == 0 && alreadyLoaded == 0) {
0662         text = i18np("<p>Processed one file</p>", "<p>Processed %1 files</p>", processed);
0663     } else if (failed > 0 && alreadyLoaded == 0) {
0664         text = i18ncp("Processed x files message with some files that failed to load. The "
0665                       "pluralized string for the failed files counter (%2) is provided by the "
0666                       "following i18np call.",
0667                       "<p>Processed one file; %2</p>", "<p>Processed %1 files; %2</p>",
0668                       processed,
0669                       i18np("one file failed to load", "%1 files failed to load", failed));
0670     } else if (failed == 0 && alreadyLoaded > 0) {
0671         text = i18ncp("Processed x files message with some files that have been skipped. The "
0672                       "pluralized string for the skipped files counter (%2) is provided by the "
0673                       "following i18np call.",
0674                       "<p>Processed one file; %2</p>", "<p>Processed %1 files; %2</p>",
0675                       processed,
0676                       i18np("one already loaded file has been skipped",
0677                             "%1 already loaded files have been skipped", alreadyLoaded));
0678     } else if (failed > 0 && alreadyLoaded == 0) {
0679         text = i18ncp("Processed x files message with some files that failed to load and some that "
0680                       "have been skipped. The pluralized strings for the failed (%2) and skipped "
0681                       "files counter (%3) are provided by the following i18np call.",
0682                       "<p>Processed one file; %2, %3</p>", "<p>Processed %1 files; %2, %3</p>",
0683                       processed,
0684                       i18np("one file failed to load", "%1 files failed to load", failed),
0685                       i18np("one already loaded file has been skipped",
0686                             "%1 already loaded files have been skipped", alreadyLoaded));
0687     }
0688 
0689     if (allPoints == 0) {
0690         text.append(i18n("<p>No waypoints could be loaded.</p>"));
0691     } else {
0692         if (allTracks > 0 && allSegments == allTracks) {
0693             text.append(i18ncp(
0694                 "Loaded x waypoints message with the number of tracks that have been read. The "
0695                 "pluralized string for the tracks (%2) is provided by the following i18np call.",
0696                 "<p>Loaded one waypoint from %2</p>",
0697                 "<p>Loaded %1 waypoints from %2</p>",
0698                 allPoints,
0699                 i18np("one track", "%1 tracks", allTracks)));
0700         } else if (allTracks > 0 && allSegments != allTracks) {
0701             text.append(i18ncp(
0702                 "Loaded x waypoints message with the number of tracks and the number of segments "
0703                 "that have been read. The pluralized string for the tracks (%2) and the one for "
0704                 "the segments (%3) are provided by the following i18np calls.",
0705                 "<p>Loaded one waypoint from %2 and %3</p>",
0706                 "<p>Loaded %1 waypoints from %2 and %3</p>",
0707                 allPoints,
0708                 i18np("one track", "%1 tracks", allTracks),
0709                 i18np("one segment", "%1 segments", allSegments)));
0710         }
0711     }
0712 
0713     QApplication::restoreOverrideCursor();
0714 
0715     // Display the load result
0716     QMessageBox::information(this, i18n("Load GPX data"), text);
0717 
0718     // Adopt the detected timezone
0719 
0720     const QByteArray &id = m_gpxEngine->lastDetectedTimeZoneId();
0721 
0722     if (allTracks > 0 && id != m_fixDriftWidget->imagesTimeZoneId()) {
0723         if (id.isEmpty()) {
0724             QMessageBox::warning(this, i18n("Timezone detection failed"),
0725                 i18n("<p>The presumably correct timezone for images associated with the loaded GPX "
0726                      "file could not be detected.</p>"
0727                      "<p>Please set the correct timezone manually on the \"Fix time drift\" page."
0728                      "</p>"));
0729         } else {
0730             if (! m_fixDriftWidget->setImagesTimeZone(id)) {
0731                 QMessageBox::warning(this, i18n("Setting the detected timezone failed"),
0732                     i18n("<p>The presumably correct timezone \"%1\" has been detected from the "
0733                          "loaded GPX file, but could not be set. This should not happen!</p>"
0734                          "<p>Please file a bug report about this, including the installed versions "
0735                          "of KGeoTag and your system's timezone data (the package owning "
0736                          "<kbd>/usr/share/zoneinfo</kbd>).</p>"
0737                          "<p>You can adjust the timezone setting manually on the \"Fix time "
0738                          "drift\" page.</p>",
0739                          QString::fromLatin1(id)));
0740             } else {
0741                 QMessageBox::information(this, i18n("Timezone adjusted"),
0742                     i18n("<p>The loaded GPX file was presumably recorded in the timezone \"%1\", "
0743                          "as well as the photos to associate with it. This timezone has been "
0744                          "selected now.</p>"
0745                          "<p>You can adjust the timezone setting manually on the \"Fix time "
0746                          "drift\" page.</p>",
0747                          QString::fromLatin1(id)));
0748             }
0749         }
0750     }
0751 }
0752 
0753 void MainWindow::addImages(const QVector<QString> &paths)
0754 {
0755     QApplication::setOverrideCursor(Qt::WaitCursor);
0756 
0757     const QFileInfo info(paths.at(0));
0758     m_settings->saveLastOpenPath(info.dir().absolutePath());
0759 
0760     const int requested = paths.count();
0761     const bool isSingleFile = requested == 1;
0762     int processed = 0;
0763     int loaded = 0;
0764     int alreadyLoaded = 0;
0765     bool skipImage = false;
0766     bool abortLoad = false;
0767 
0768     QProgressDialog progress(i18n("Loading images ..."), i18n("Cancel"), 0, requested, this);
0769     progress.setWindowModality(Qt::WindowModal);
0770 
0771     for (const auto &path : paths) {
0772         progress.setValue(processed++);
0773         if (progress.wasCanceled()) {
0774             break;
0775         }
0776 
0777         const QFileInfo info(path);
0778         while (true) {
0779             QString errorString;
0780             bool exitLoop = false;
0781 
0782             switch (m_imagesModel->addImage(info.canonicalFilePath())) {
0783             case ImagesModel::LoadingSucceeded:
0784                 exitLoop = true;
0785                 break;
0786 
0787             case ImagesModel::AlreadyLoaded:
0788                 alreadyLoaded++;
0789                 exitLoop = true;
0790                 break;
0791 
0792             case ImagesModel::LoadingImageFailed:
0793                 if (isSingleFile) {
0794                     errorString = i18n("<p><b>Loading image failed</b></p>"
0795                                        "<p>Could not read <kbd>%1</kbd>.</p>",
0796                                        path);
0797                 } else {
0798                     errorString = i18nc(
0799                         "Message with a fraction of processed files added in round braces",
0800                         "<p><b>Loading image failed (%1/%2)</b></p>"
0801                         "<p>Could not read <kbd>%3</kbd>.</p>",
0802                         processed, requested, path);
0803                 }
0804                 break;
0805 
0806             case ImagesModel::LoadingMetadataFailed:
0807                 if (isSingleFile) {
0808                     errorString = i18n(
0809                         "<p><b>Loading image's Exif header or XMP sidecar file failed</b></p>"
0810                         "<p>Could not read <kbd>%1</kbd>.</p>",
0811                         path);
0812                 } else {
0813                     errorString = i18nc(
0814                         "Message with a fraction of processed files added in round braces",
0815                         "<p><b>Loading image's Exif header or XMP sidecar file failed</b></p>"
0816                         "<p>Could not read <kbd>%2</kbd>.</p>",
0817                         processed, requested, path);
0818                 }
0819                 break;
0820 
0821             }
0822 
0823             if (exitLoop || errorString.isEmpty()) {
0824                 break;
0825             }
0826 
0827             errorString.append(i18n("<p>Please check if this file is actually a supported image "
0828                                     "and if you have read access to it.</p>"));
0829 
0830             if (isSingleFile) {
0831                 errorString.append(i18n("<p>You can retry to load this file or cancel the loading "
0832                                         "process.</p>"));
0833             } else {
0834                 errorString.append(i18n("<p>You can retry to load this file, skip it or cancel the "
0835                                         "loading process.</p>"));
0836             }
0837 
0838             progress.reset();
0839             QApplication::restoreOverrideCursor();
0840 
0841             RetrySkipAbortDialog dialog(this, i18n("Add images"), errorString, isSingleFile);
0842             const auto reply = dialog.exec();
0843             if (reply == RetrySkipAbortDialog::Skip) {
0844                 skipImage = true;
0845                 break;
0846             } else if (reply == RetrySkipAbortDialog::Abort) {
0847                 abortLoad = true;
0848                 break;
0849             }
0850 
0851             QApplication::setOverrideCursor(Qt::WaitCursor);
0852         }
0853 
0854         if (skipImage) {
0855             skipImage = false;
0856             continue;
0857         }
0858         if (abortLoad) {
0859             break;
0860         }
0861 
0862         loaded++;
0863     }
0864 
0865     progress.reset();
0866     m_mapWidget->reloadMap();
0867     QApplication::restoreOverrideCursor();
0868 
0869     const int failed = requested - loaded;
0870     loaded -= alreadyLoaded;
0871 
0872     if (loaded == requested) {
0873         QMessageBox::information(this, i18n("Add images"),
0874                                  i18np("Successfully added one image!",
0875                                        "Successfully added %1 images!",
0876                                        loaded));
0877 
0878     } else if (failed == requested) {
0879         QMessageBox::warning(this, i18n("Add images"), i18n(
0880             "Could not add any new images, all requested images failed to load!"));
0881 
0882     } else if (alreadyLoaded == requested) {
0883         QMessageBox::warning(this, i18n("Add images"), i18n(
0884             "Could not add any new images, all requested images have already been loaded!"));
0885 
0886     } else if (alreadyLoaded + failed == requested) {
0887         QMessageBox::warning(this, i18n("Add images"), i18n(
0888             "Could not add any new images, all requested images failed to load or have already "
0889             "been loaded!"));
0890 
0891     } else {
0892         QString message = i18np("<p>Successfully added image!</p>",
0893                                 "<p>Successfully added %1 images!</p>",
0894                                 loaded);
0895 
0896         if (failed > 0 && alreadyLoaded == 0) {
0897             message.append(i18np("<p>One image failed to load.</p>",
0898                                  "<p>%1 images failed to load.</p>",
0899                                  failed));
0900         } else if (failed == 0 && alreadyLoaded > 0) {
0901             message.append(i18np("<p>One image has already been loaded.</p>",
0902                                  "<p>%1 images have already been loaded.</p>",
0903                                  alreadyLoaded));
0904         } else {
0905             message.append(i18nc(
0906                 "Message string for some images that failed to load and some that were skipped "
0907                 "because they already have been loaded. The pluralized strings for the failed "
0908                 "images (%1) and the skipped images (%2) are provided by the following i18np "
0909                 "calls.",
0910                 "<p>%1 and %2.</p>",
0911                 i18np("One image failed to load",
0912                       "%1 images failed to load",
0913                       failed),
0914                 i18np("one image has already been loaded",
0915                       "%1 images have already been loaded",
0916                       alreadyLoaded)));
0917         }
0918 
0919         QMessageBox::warning(this, i18n("Add images"), message);
0920     }
0921 }
0922 
0923 void MainWindow::imagesDropped(const QVector<QString> &paths)
0924 {
0925     const auto index = m_imagesModel->indexFor(paths.last());
0926 
0927     m_previewWidget->setImage(index);
0928     if (m_settings->lookupElevationAutomatically()) {
0929         lookupElevation(paths);
0930     }
0931 
0932     if (m_settings->splitImagesList()) {
0933         qobject_cast<ImagesListView *>(m_assignedOrAllImagesDock->widget())->highlightImage(index);
0934     }
0935 }
0936 
0937 void MainWindow::assignToMapCenter(ImagesListView *list)
0938 {
0939     assignTo(list->selectedPaths(), m_mapWidget->currentCenter());
0940 }
0941 
0942 void MainWindow::assignManually(ImagesListView *list)
0943 {
0944     const auto paths = list->selectedPaths();
0945 
0946     QString label;
0947     if (paths.count() == 1) {
0948         QFileInfo info(paths.first());
0949         label = i18nc("A quoted filename", "\"%1\"", info.fileName());
0950     } else {
0951         // We don't need this for English, but possibly for languages with other plural forms
0952         label = i18np("1 image", "%1 images", paths.count());
0953     }
0954 
0955     CoordinatesDialog dialog(CoordinatesDialog::Mode::EditCoordinates,
0956                              m_settings->lookupElevationAutomatically(), Coordinates(), label);
0957     if (! dialog.exec()) {
0958         return;
0959     }
0960 
0961     assignTo(paths, dialog.coordinates());
0962 }
0963 
0964 void MainWindow::editCoordinates(ImagesListView *list)
0965 {
0966     const auto paths = list->selectedPaths();
0967     auto coordinates = m_imagesModel->coordinates(paths.first());
0968     bool identicalCoordinates = true;
0969     for (int i = 1; i < paths.count(); i++) {
0970         if (m_imagesModel->coordinates(paths.at(i)) != coordinates) {
0971             identicalCoordinates = false;
0972             break;
0973         }
0974     }
0975 
0976     QString label;
0977     if (paths.count() == 1) {
0978         QFileInfo info(paths.first());
0979         label = i18nc("A quoted filename", "\"%1\"", info.fileName());
0980     } else {
0981         // We don't need this for English, but possibly for languages with other plural forms
0982         label = i18np("1 image (%2)", "%1 images (%2)", paths.count(),
0983                       identicalCoordinates ? i18n("all images have the same coordinates")
0984                                            : i18n("coordinates differ across the images"));
0985     }
0986 
0987     CoordinatesDialog dialog(CoordinatesDialog::Mode::EditCoordinates, false,
0988                              identicalCoordinates ? coordinates : Coordinates(),
0989                              label);
0990     if (! dialog.exec()) {
0991         return;
0992     }
0993 
0994     assignTo(paths, dialog.coordinates());
0995 }
0996 
0997 void MainWindow::assignTo(const QVector<QString> &paths, const Coordinates &coordinates)
0998 {
0999     for (const auto &path : paths) {
1000         m_imagesModel->setCoordinates(path, coordinates, KGeoTag::ManuallySet);
1001     }
1002 
1003     m_mapWidget->centerCoordinates(coordinates);
1004     m_mapWidget->reloadMap();
1005 
1006     if (m_settings->lookupElevationAutomatically()) {
1007         lookupElevation(paths);
1008     }
1009 
1010     if (m_settings->splitImagesList()) {
1011         qobject_cast<ImagesListView *>(m_assignedOrAllImagesDock->widget())->highlightImage(
1012             m_imagesModel->indexFor(paths.last()));
1013     }
1014 }
1015 
1016 void MainWindow::triggerAutomaticMatching(ImagesListView *list, KGeoTag::SearchType searchType)
1017 {
1018     const auto paths = list->selectedPaths();
1019     matchAutomatically(paths, searchType);
1020 }
1021 
1022 void MainWindow::triggerCompleteAutomaticMatching(KGeoTag::SearchType searchType)
1023 {
1024     if (m_imagesModel->allImages().isEmpty()) {
1025         QMessageBox::information(this, i18n("(Re)Assign all images"),
1026                                  i18n("Can't search for matches:\n"
1027                                       "No images have been loaded yet."));
1028         return;
1029     }
1030 
1031     QVector<QString> paths;
1032     const bool excludeManuallyTagged = m_automaticMatchingWidget->excludeManuallyTagged();
1033     for (const auto &path : m_imagesModel->allImages()) {
1034         if (excludeManuallyTagged && m_imagesModel->matchType(path) == KGeoTag::ManuallySet) {
1035             continue;
1036         }
1037         paths.append(path);
1038     }
1039     matchAutomatically(paths, searchType);
1040 }
1041 
1042 void MainWindow::matchAutomatically(const QVector<QString> &paths, KGeoTag::SearchType searchType)
1043 {
1044     if (m_geoDataModel->rowCount() == 0) {
1045         QMessageBox::information(this, i18n("Automatic matching"),
1046                                  i18n("Can't search for matches:\n"
1047                                       "No GPS tracks have been loaded yet."));
1048         return;
1049     }
1050 
1051     QApplication::setOverrideCursor(Qt::WaitCursor);
1052 
1053     m_gpxEngine->setMatchParameters(m_automaticMatchingWidget->exactMatchTolerance(),
1054                                     m_automaticMatchingWidget->maximumInterpolationInterval(),
1055                                     m_automaticMatchingWidget->maximumInterpolationDistance());
1056 
1057     int exactMatches = 0;
1058     int interpolatedMatches = 0;
1059     QString lastMatchedPath;
1060 
1061     QProgressDialog progress(i18n("Assigning images ..."), i18n("Cancel"), 0, paths.count(), this);
1062     progress.setWindowModality(Qt::WindowModal);
1063 
1064     int processed = 0;
1065     int notMatched = 0;
1066     int notMatchedButHaveCoordinates = 0;
1067 
1068     for (const auto &path : paths) {
1069         progress.setValue(processed++);
1070         if (progress.wasCanceled()) {
1071             break;
1072         }
1073 
1074         Coordinates coordinates;
1075 
1076         // Search for exact matches if requested
1077 
1078         if (searchType == KGeoTag::CombinedMatchSearch
1079             || searchType == KGeoTag::ExactMatchSearch) {
1080 
1081             coordinates = m_gpxEngine->findExactCoordinates(
1082                 m_imagesModel->date(path), m_fixDriftWidget->cameraClockDeviation());
1083         }
1084 
1085         if (coordinates.isSet()) {
1086             m_imagesModel->setCoordinates(path, coordinates, KGeoTag::ExactMatch);
1087             exactMatches++;
1088             lastMatchedPath = path;
1089             continue;
1090         }
1091 
1092         // Search for interpolated matches if requested
1093 
1094         if (searchType == KGeoTag::CombinedMatchSearch
1095             || searchType == KGeoTag::InterpolatedMatchSearch) {
1096 
1097             coordinates = m_gpxEngine->findInterpolatedCoordinates(
1098                 m_imagesModel->date(path), m_fixDriftWidget->cameraClockDeviation());
1099         }
1100 
1101         if (coordinates.isSet()) {
1102             m_imagesModel->setCoordinates(path, coordinates, KGeoTag::InterpolatedMatch);
1103             interpolatedMatches++;
1104             lastMatchedPath = path;
1105         } else {
1106             notMatched++;
1107             if (m_imagesModel->coordinates(path).isSet()) {
1108                 notMatchedButHaveCoordinates++;
1109             }
1110         }
1111     }
1112 
1113     progress.reset();
1114 
1115     QString title;
1116     QString text;
1117 
1118     switch (searchType) {
1119     case KGeoTag::CombinedMatchSearch:
1120         title = i18n("Combined match search");
1121         if (exactMatches > 0 || interpolatedMatches > 0) {
1122             text = i18nc("Message for the number of matches found. The pluralized string for the "
1123                          "exact matches (%1) and the interpolated matches (%2) are provided by the "
1124                          "following i18np calls.",
1125                          "<p>Found %1 and %2!</p>",
1126                          i18np("one exact match",
1127                                "%1 exact matches",
1128                                exactMatches),
1129                          i18np("one interpolated match",
1130                                "%1 interpolated matches",
1131                                interpolatedMatches));
1132             if (notMatched > 0) {
1133                 text.append(i18ncp("Message for the number of unmatched images. The number of "
1134                                    "images that could not be matched but already have coordinates "
1135                                    "assigned (%2) is provided by the following i18np call",
1136                                    "<p>One image could not be matched (%2).</p>",
1137                                    "<p>%1 images could not be matched (%2).</p>",
1138                                    notMatched,
1139                                    i18np("of which one image already has coordinates assigned",
1140                                          "of which %1 images already have coordinates assigned",
1141                                          notMatchedButHaveCoordinates)));
1142             }
1143         } else {
1144             text = i18n("Could neither find any exact, nor any interpolated matches!");
1145         }
1146         break;
1147 
1148     case KGeoTag::ExactMatchSearch:
1149         title = i18n("Exact matches search");
1150         if (exactMatches > 0) {
1151             text = i18np("<p>Found one exact match!</p>",
1152                          "<p>Found %1 exact matches!</p>",
1153                          exactMatches);
1154             if (notMatched > 0) {
1155                 text.append(i18ncp("Message for the number of unmatched images. The number of "
1156                                    "images that could not be matched but already have coordinates "
1157                                    "assigned (%2) is provided by the following i18np call",
1158                                    "<p>One image had no exact match (%2).</p>",
1159                                    "<p>%1 images had no exact match (%2).</p>",
1160                                    notMatched,
1161                                    i18np("of which one image already has coordinates assigned",
1162                                          "of which %1 images already have coordinates assigned",
1163                                          notMatchedButHaveCoordinates)));
1164             }
1165         } else {
1166             text = i18n("Could not find any exact matches!");
1167         }
1168         break;
1169 
1170     case KGeoTag::InterpolatedMatchSearch:
1171         title = i18n("Interpolated matches search");
1172         if (interpolatedMatches > 0) {
1173             text = i18np("<p>Found one interpolated match!</p>",
1174                          "<p>Found %1 interpolated matches!</p>",
1175                          interpolatedMatches);
1176             if (notMatched > 0) {
1177                 text.append(i18ncp("Message for the number of unmatched images. The number of "
1178                                    "images that could not be matched but already have coordinates "
1179                                    "assigned (%2) is provided by the following i18np call",
1180                                    "<p>One image had no interpolated match (%2).</p>",
1181                                    "<p>%1 images had no interpolated match (%2).</p>",
1182                                    notMatched,
1183                                    i18np("of which one image already has coordinates assigned",
1184                                          "of which %1 images already have coordinates assigned",
1185                                          notMatchedButHaveCoordinates)));
1186             }
1187         } else {
1188             text = i18n("Could not find any interpolated matches!");
1189         }
1190         break;
1191     }
1192 
1193     QApplication::restoreOverrideCursor();
1194 
1195     if (exactMatches > 0 || interpolatedMatches > 0) {
1196         m_mapWidget->reloadMap();
1197         const auto index = m_imagesModel->indexFor(lastMatchedPath);
1198         m_mapWidget->centerImage(index);
1199         m_previewWidget->setImage(index);
1200         QMessageBox::information(this, title, text);
1201     } else {
1202         QMessageBox::warning(this, title, text);
1203     }
1204 }
1205 
1206 QString MainWindow::saveFailedHeader(int processed, int allImages) const
1207 {
1208     if (allImages == 1) {
1209         return i18n("<p><b>Saving changes failed</b></p>");
1210     } else {
1211         return i18nc("Saving failed message with the fraction of processed files given in the "
1212                      "round braces",
1213                      "<p><b>Saving changes failed (%1/%2)</b></p>",
1214                      processed, allImages);
1215     }
1216 }
1217 
1218 QString MainWindow::skipRetryCancelText(int processed, int allImages) const
1219 {
1220     if (allImages == 1 || processed == allImages) {
1221         return i18n("<p>You can retry to process the file or cancel the saving process.</p>");
1222     } else {
1223         return i18n("<p>You can retry to process the file, skip it or cancel the saving process."
1224                     "</p>");
1225     }
1226 }
1227 
1228 void MainWindow::saveSelection(ImagesListView *list)
1229 {
1230     QVector<QString> files;
1231     auto selected = list->selectedPaths();
1232     for (const auto &path : selected) {
1233         if (m_imagesModel->hasPendingChanges(path)) {
1234             files.append(path);
1235         }
1236     }
1237     saveChanges(files);
1238 }
1239 
1240 void MainWindow::saveAllChanges()
1241 {
1242     saveChanges(m_imagesModel->imagesWithPendingChanges());
1243 }
1244 
1245 void MainWindow::saveChanges(const QVector<QString> &files)
1246 {
1247     if (files.isEmpty()) {
1248         QMessageBox::information(this, i18n("Save changes"), i18n("Nothing to do"));
1249         return;
1250     }
1251 
1252     QApplication::setOverrideCursor(Qt::WaitCursor);
1253 
1254     auto writeMode = s_writeModeMap.value(m_settings->writeMode());
1255     const bool createBackups =
1256         writeMode != KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARONLY
1257         && m_settings->createBackups();
1258     const int cameraClockDeviation = m_fixDriftWidget->cameraClockDeviation();
1259     const bool fixDrift = m_fixDriftWidget->save() && cameraClockDeviation != 0;
1260 
1261     bool skipImage = false;
1262     bool abortWrite = false;
1263     int savedImages = 0;
1264     const int allImages = files.count();
1265     int processed = 0;
1266     const bool isSingleFile = allImages == 1;
1267 
1268     QProgressDialog progress(i18n("Saving changes ..."), i18n("Cancel"), 0, allImages, this);
1269     progress.setWindowModality(Qt::WindowModal);
1270 
1271     for (const QString &path : files) {
1272         progress.setValue(processed++);
1273         if (progress.wasCanceled()) {
1274             break;
1275         }
1276 
1277         // Create a backup of the file if requested
1278         if (createBackups) {
1279             bool backupOkay = false;
1280             const QString backupPath = path + QStringLiteral(".") + KGeoTag::backupSuffix;
1281 
1282             while (! backupOkay) {
1283                 backupOkay = QFile::copy(path, backupPath);
1284 
1285                 if (! backupOkay) {
1286                     QFileInfo info(path);
1287                     QString message = saveFailedHeader(processed, allImages);
1288 
1289                     message.append(i18n(
1290                         "<p>Could not save changes to <kbd>%1</kbd>: The backup file <kbd>%2</kbd> "
1291                         "could not be created.</p>"
1292                         "<p>Please check if this file doesn't exist yet and be sure to have write "
1293                         "access to <kbd>%3</kbd>.</p>",
1294                         info.fileName(), backupPath, info.dir().path()));
1295 
1296                     message.append(skipRetryCancelText(processed, allImages));
1297 
1298                     progress.reset();
1299                     QApplication::restoreOverrideCursor();
1300 
1301                     RetrySkipAbortDialog dialog(this, i18n("Save changes"), message,
1302                                                 isSingleFile || processed == allImages);
1303 
1304                     const auto reply = dialog.exec();
1305                     if (reply == RetrySkipAbortDialog::Skip) {
1306                         skipImage = true;
1307                         break;
1308                     } else if (reply == RetrySkipAbortDialog::Abort) {
1309                         abortWrite = true;
1310                         break;
1311                     }
1312 
1313                     QApplication::setOverrideCursor(Qt::WaitCursor);
1314                 }
1315             }
1316         }
1317 
1318         if (skipImage) {
1319             skipImage = false;
1320             continue;
1321         }
1322         if (abortWrite) {
1323             break;
1324         }
1325 
1326         // Write the GPS information
1327 
1328         // Read the Exif header
1329 
1330         auto exif = KExiv2Iface::KExiv2();
1331         exif.setUseXMPSidecar4Reading(true);
1332 
1333         while (! exif.load(path)) {
1334             QString message = saveFailedHeader(processed, allImages);
1335 
1336             message.append(i18n(
1337                 "<p>Could not read metadata from <kbd>%1</kbd>.</p>"
1338                 "<p>Please check if this file still exists and if you have read access to it (and "
1339                 "possibly also to an existing XMP sidecar file).</p>",
1340                 path));
1341 
1342             message.append(skipRetryCancelText(processed, allImages));
1343 
1344             progress.reset();
1345             QApplication::restoreOverrideCursor();
1346 
1347             RetrySkipAbortDialog dialog(this, i18n("Save changes"), message,
1348                                         isSingleFile || processed == allImages);
1349 
1350             const auto reply = dialog.exec();
1351             if (reply == RetrySkipAbortDialog::Skip) {
1352                 skipImage = true;
1353                 break;
1354             } else if (reply == RetrySkipAbortDialog::Abort) {
1355                 abortWrite = true;
1356                 break;
1357             }
1358 
1359             QApplication::setOverrideCursor(Qt::WaitCursor);
1360         }
1361 
1362         if (skipImage) {
1363             skipImage = false;
1364             continue;
1365         }
1366         if (abortWrite) {
1367             break;
1368         }
1369 
1370         // Set or remove the coordinates
1371         const auto coordinates = m_imagesModel->coordinates(path);
1372         if (coordinates.isSet()) {
1373             exif.setGPSInfo(coordinates.alt(), coordinates.lat(), coordinates.lon());
1374         } else {
1375             exif.removeGPSInfo();
1376         }
1377 
1378         // Fix the time drift if requested
1379         if (fixDrift) {
1380             const QDateTime originalTime = m_imagesModel->date(path);
1381             const QDateTime fixedTime = originalTime.addSecs(cameraClockDeviation);
1382             // If the Digitization time is equal to the original time, update it as well.
1383             // Otherwise, only update the image's timestamp.
1384             exif.setImageDateTime(fixedTime, exif.getDigitizationDateTime() == originalTime);
1385         }
1386 
1387         // Save the changes
1388 
1389         if (MimeHelper::isRawImage(path)
1390             && writeMode != KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARONLY) {
1391 
1392             if (m_settings->allowWriteRawFiles()) {
1393                 exif.setWriteRawFiles(true);
1394             } else {
1395                 qCDebug(KGeoTagLog) << "Falling back to write XMP sidecar file for" << path;
1396                 writeMode = KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARONLY;
1397             }
1398         }
1399 
1400         exif.setMetadataWritingMode(writeMode);
1401 
1402         while (! exif.applyChanges()) {
1403             QString message = saveFailedHeader(processed, allImages);
1404 
1405             message.append(i18n("<p>Could not save metadata for <kbd>%1</kbd>.</p>", path));
1406 
1407             if (writeMode == KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARONLY) {
1408                 QFileInfo info(path);
1409                 message.append(i18n("<p>Please check if you have write access to <kbd>%1</kbd>!",
1410                                     info.dir().path()));
1411             } else {
1412                 message.append(i18n("<p>Please check if this file still exists and if you have "
1413                                     "write access to it!</p>"));
1414             }
1415 
1416             message.append(skipRetryCancelText(processed, allImages));
1417 
1418             progress.reset();
1419             QApplication::restoreOverrideCursor();
1420 
1421             RetrySkipAbortDialog dialog(this, i18n("Save changes"), message, isSingleFile);
1422 
1423             const auto reply = dialog.exec();
1424             if (reply == RetrySkipAbortDialog::Skip) {
1425                 skipImage = true;
1426                 break;
1427             } else if (reply == RetrySkipAbortDialog::Abort) {
1428                 abortWrite = true;
1429                 break;
1430             }
1431 
1432             QApplication::setOverrideCursor(Qt::WaitCursor);
1433         }
1434 
1435         if (skipImage) {
1436             skipImage = false;
1437             continue;
1438         }
1439         if (abortWrite) {
1440             break;
1441         }
1442 
1443         m_imagesModel->setSaved(path);
1444 
1445         savedImages++;
1446     }
1447 
1448     progress.reset();
1449     QApplication::restoreOverrideCursor();
1450 
1451     if (savedImages == 0) {
1452         QMessageBox::warning(this, i18n("Save changes"),
1453                              i18n("No changes could be saved!"));
1454     } else if (savedImages < allImages) {
1455         QMessageBox::warning(this, i18n("Save changes"),
1456                              i18n("<p>Some changes could not be saved!</p>"
1457                                   "<p>Successfully saved %1 of %2 images.</p>",
1458                                   savedImages, allImages));
1459     } else {
1460         QMessageBox::information(this, i18n("Save changes"),
1461                                  i18n("All changes have been successfully saved!"));
1462     }
1463 }
1464 
1465 void MainWindow::showSettings()
1466 {
1467     auto *dialog = new SettingsDialog(m_settings, this);
1468     connect(dialog, &SettingsDialog::imagesListsModeChanged,
1469             this, &MainWindow::updateImagesListsMode);
1470 
1471     if (! dialog->exec()) {
1472         return;
1473     }
1474 
1475     m_mapWidget->updateSettings();
1476 }
1477 
1478 void MainWindow::removeCoordinates(ImagesListView *list)
1479 {
1480     removeCoordinates(list->selectedPaths());
1481 }
1482 
1483 void MainWindow::removeCoordinates(const QVector<QString> &paths)
1484 {
1485     for (const QString &path : paths) {
1486         m_imagesModel->setCoordinates(path, Coordinates(), KGeoTag::NotMatched);
1487     }
1488 
1489     m_mapWidget->reloadMap();
1490     m_previewWidget->setImage();
1491 }
1492 
1493 void MainWindow::discardChanges(ImagesListView *list)
1494 {
1495     const auto paths = list->selectedPaths();
1496     for (const auto &path : paths) {
1497         m_imagesModel->resetChanges(path);
1498     }
1499 
1500     m_mapWidget->reloadMap();
1501     m_previewWidget->setImage();
1502 }
1503 
1504 void MainWindow::checkUpdatePreview(const QVector<QString> &paths)
1505 {
1506     for (const QString &path : paths) {
1507         if (m_previewWidget->currentImage() == path) {
1508             m_previewWidget->setImage(m_imagesModel->indexFor(path));
1509             break;
1510         }
1511     }
1512 }
1513 
1514 void MainWindow::elevationLookupFailed(const QString &errorMessage)
1515 {
1516     QApplication::restoreOverrideCursor();
1517 
1518     QMessageBox::warning(this, i18n("Elevation lookup"),
1519         i18n("<p>Fetching elevation data from opentopodata.org failed.</p>"
1520              "<p>The error message was: %1</p>", errorMessage));
1521 }
1522 
1523 void MainWindow::notAllElevationsPresent(int locationsCount, int elevationsCount)
1524 {
1525     QString message;
1526     if (locationsCount == 1) {
1527         message = i18n("Fetching elevation data failed: The requested location is not present in "
1528                        "the currently chosen elevation dataset.");
1529     } else {
1530         if (elevationsCount == 0) {
1531             message = i18n("Fetching elevation data failed: None of the requested locations are "
1532                            "present in the currently chosen elevation dataset.");
1533         } else {
1534             message = i18n("Fetching elevation data is incomplete: Some of the requested locations "
1535                            "are not present in the currently chosen elevation dataset.");
1536         }
1537     }
1538 
1539     QMessageBox::warning(this, i18n("Elevation lookup"), message);
1540 }
1541 
1542 void MainWindow::lookupElevation(ImagesListView *list)
1543 {
1544     lookupElevation(list->selectedPaths());
1545 }
1546 
1547 void MainWindow::lookupElevation(const QVector<QString> &paths)
1548 {
1549     QApplication::setOverrideCursor(Qt::BusyCursor);
1550 
1551     QVector<Coordinates> coordinates;
1552     for (const auto &path : paths) {
1553         coordinates.append(m_imagesModel->coordinates(path));
1554     }
1555 
1556     m_elevationEngine->request(ElevationEngine::Target::Image, paths, coordinates);
1557 }
1558 
1559 void MainWindow::elevationProcessed(ElevationEngine::Target target, const QVector<QString> &paths,
1560                                     const QVector<double> &elevations)
1561 {
1562     if (target != ElevationEngine::Target::Image) {
1563         return;
1564     }
1565 
1566     for (int i = 0; i < paths.count(); i++) {
1567         const auto &path = paths.at(i);
1568         const auto &elevation = elevations.at(i);
1569         m_imagesModel->setElevation(path, elevation);
1570     }
1571 
1572     Q_EMIT checkUpdatePreview(paths);
1573     QApplication::restoreOverrideCursor();
1574 }
1575 
1576 void MainWindow::imagesTimeZoneChanged()
1577 {
1578     QApplication::setOverrideCursor(Qt::WaitCursor);
1579     m_imagesModel->setImagesTimeZone(m_fixDriftWidget->imagesTimeZoneId());
1580     m_previewWidget->reload();
1581     QApplication::restoreOverrideCursor();
1582 }
1583 
1584 void MainWindow::cameraDriftSettingsChanged()
1585 {
1586     m_previewWidget->setCameraClockDeviation(
1587         m_fixDriftWidget->displayFixed() ? m_fixDriftWidget->cameraClockDeviation() : 0);
1588 }
1589 
1590 void MainWindow::removeImages(ImagesListView *list)
1591 {
1592     const auto paths = list->selectedPaths();
1593     const auto count = paths.count();
1594 
1595     bool pendingChanges = false;
1596     for (const auto &path : paths) {
1597         if (m_imagesModel->hasPendingChanges(path)) {
1598             pendingChanges = true;
1599             break;
1600         }
1601     }
1602 
1603     if (pendingChanges) {
1604         if (QMessageBox::question(this, i18np("Remove image", "Remove images", count),
1605                 i18np("The image has pending changes! Do you really want to remove it and discard "
1606                       "the changes?",
1607                       "At least one of the images has pending changes. Do you really want to "
1608                       "remove them and discard all changes?",
1609                       count),
1610                 QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) {
1611 
1612             return;
1613         }
1614     }
1615 
1616     m_imagesModel->removeImages(paths);
1617     m_mapWidget->reloadMap();
1618     m_previewWidget->setImage();
1619 }
1620 
1621 void MainWindow::removeProcessedSavedImages()
1622 {
1623     const auto paths = m_imagesModel->processedSavedImages();
1624     if (paths.isEmpty()) {
1625         QMessageBox::information(this, i18n("Remove all processed and saved images"),
1626             i18n("Nothing to do"));
1627         return;
1628     }
1629 
1630     m_imagesModel->removeImages(paths);
1631     m_mapWidget->reloadMap();
1632     m_previewWidget->setImage();
1633     QMessageBox::information(this, i18n("Remove all processed and saved images"),
1634         i18np("Removed one image", "Removed %1 images", paths.count()));
1635 }
1636 
1637 void MainWindow::removeImagesLoadedTagged()
1638 {
1639     const auto paths = m_imagesModel->imagesLoadedTagged();
1640     if (paths.isEmpty()) {
1641         QMessageBox::information(this, i18n("Remove images that already had coordinates"),
1642             i18n("Nothing to do"));
1643         return;
1644     }
1645 
1646     m_imagesModel->removeImages(paths);
1647     m_mapWidget->reloadMap();
1648     m_previewWidget->setImage();
1649     QMessageBox::information(this, i18n("Remove images that already had coordinates"),
1650         i18np("Removed one image", "Removed %1 images", paths.count()));
1651 }
1652 
1653 bool MainWindow::checkForPendingChanges()
1654 {
1655     if (! m_imagesModel->imagesWithPendingChanges().isEmpty()
1656         && QMessageBox::question(this, i18n("Remove all images"),
1657                i18n("<p>There are pending changes to images that haven't been saved yet. All "
1658                     "changes will be discarded if all images are removed now.</p>"
1659                     "<p>Do you really want to remove all images anyway?</p>"),
1660                QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) {
1661 
1662         return false;
1663     }
1664 
1665     return true;
1666 }
1667 
1668 void MainWindow::removeAllImages()
1669 {
1670     if (m_imagesModel->rowCount() == 0) {
1671         QMessageBox::information(this, i18n("Remove all images"), i18n("Nothing to do"));
1672         return;
1673     }
1674 
1675     if (! checkForPendingChanges()) {
1676         return;
1677     }
1678 
1679     m_imagesModel->removeAllImages();
1680     m_mapWidget->reloadMap();
1681     m_previewWidget->setImage();
1682 }
1683 
1684 void MainWindow::removeTracks()
1685 {
1686     m_tracksView->blockSignals(true);
1687     const auto allRows = m_tracksView->selectedTracks();
1688     for (int row : allRows) {
1689         m_geoDataModel->removeTrack(row);
1690     }
1691     m_tracksView->blockSignals(false);
1692     m_mapWidget->reloadMap();
1693 }
1694 
1695 void MainWindow::removeAllTracks()
1696 {
1697     m_tracksView->blockSignals(true);
1698     m_geoDataModel->removeAllTracks();
1699     m_tracksView->blockSignals(false);
1700     m_mapWidget->reloadMap();
1701 }
1702 
1703 void MainWindow::removeEverything()
1704 {
1705     if (m_imagesModel->rowCount() == 0 && m_geoDataModel->rowCount() == 0) {
1706         QMessageBox::information(this, i18n("Remove all images and tracks (reset)"),
1707                                  i18n("Nothing to do"));
1708         return;
1709     }
1710 
1711     if (! checkForPendingChanges()) {
1712         return;
1713     }
1714 
1715     m_imagesModel->removeAllImages();
1716     m_previewWidget->setImage();
1717     removeAllTracks();
1718 }
1719 
1720 void MainWindow::centerTrackPoint(int trackIndex, int trackPointIndex)
1721 {
1722     const auto dateTime = m_geoDataModel->dateTimes().at(trackIndex).at(trackPointIndex)
1723                               .toTimeZone(m_fixDriftWidget->imagesTimeZone());
1724     const auto coordinates = m_geoDataModel->trackPoints().at(trackIndex).value(dateTime);
1725     m_mapWidget->blockSignals(true);
1726     m_mapWidget->centerCoordinates(coordinates);
1727     m_mapCenterInfo->trackPointCentered(coordinates, dateTime);
1728     m_mapWidget->blockSignals(false);
1729 }
1730 
1731 void MainWindow::failedToParseClipboard()
1732 {
1733     QMessageBox::warning(this,
1734                          i18n("Failed to parse clipboard data"),
1735                          i18n("Could not parse the clipboard's text to valid coordinates"));
1736 }
1737 
1738 void MainWindow::findClosestTrackPoint(const QString &path)
1739 {
1740     const auto point = m_gpxEngine->findClosestTrackPoint(
1741         m_imagesModel->date(path), m_fixDriftWidget->cameraClockDeviation());
1742 
1743     if (! point.first.isSet()) {
1744         QMessageBox::warning(this, i18n("Find closest trackpoint"),
1745                              i18n("No geodata has been loaded yet!"));
1746         return;
1747     }
1748 
1749     m_mapWidget->blockSignals(true);
1750     m_mapWidget->centerCoordinates(point.first);
1751     m_mapCenterInfo->trackPointCentered(point.first,
1752         point.second.toTimeZone(m_fixDriftWidget->imagesTimeZone()));
1753     m_mapWidget->blockSignals(false);
1754 }