1 /* SPDX-FileCopyrightText: 2020-2021 Tobias Leupold <tl@l3u.de>
2 
3    SPDX-License-Identifier: GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
4 */
5 
6 // Local includes
7 #include "MainWindow.h"
8 #include "SharedObjects.h"
9 #include "Settings.h"
10 #include "GpxEngine.h"
11 #include "PreviewWidget.h"
12 #include "MapWidget.h"
13 #include "KGeoTag.h"
14 #include "SettingsDialog.h"
15 #include "FixDriftWidget.h"
16 #include "BookmarksList.h"
17 #include "ElevationEngine.h"
18 #include "BookmarksWidget.h"
19 #include "CoordinatesDialog.h"
20 #include "RetrySkipAbortDialog.h"
21 #include "ImagesModel.h"
22 #include "ImagesListView.h"
23 #include "Coordinates.h"
24 #include "AutomaticMatchingWidget.h"
25 #include "MimeHelper.h"
26 #include "MapCenterInfo.h"
27 #include "TracksListView.h"
28 #include "GeoDataModel.h"
29 #include "TrackWalker.h"
30 
31 // KDE includes
32 #include <KActionCollection>
33 #include <KLocalizedString>
34 #include <KStandardAction>
35 #include <KHelpMenu>
36 #include <KExiv2/KExiv2>
37 #include <KXMLGUIFactory>
38 
39 // Qt includes
40 #include <QMenuBar>
41 #include <QAction>
42 #include <QDockWidget>
43 #include <QGuiApplication>
44 #include <QScreen>
45 #include <QApplication>
46 #include <QDebug>
47 #include <QFileDialog>
48 #include <QProgressDialog>
49 #include <QFile>
50 #include <QTimer>
51 #include <QMessageBox>
52 #include <QCloseEvent>
53 #include <QAbstractButton>
54 #include <QVBoxLayout>
55 
56 static const QHash<QString, KExiv2Iface::KExiv2::MetadataWritingMode> s_writeModeMap {
57     { QStringLiteral("WRITETOIMAGEONLY"),
58       KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOIMAGEONLY },
59     { QStringLiteral("WRITETOSIDECARONLY"),
60       KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARONLY },
61     { QStringLiteral("WRITETOSIDECARANDIMAGE"),
62       KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARANDIMAGE }
63 };
64 
MainWindow(SharedObjects * sharedObjects)65 MainWindow::MainWindow(SharedObjects *sharedObjects)
66     : KXmlGuiWindow(),
67       m_sharedObjects(sharedObjects),
68       m_settings(sharedObjects->settings()),
69       m_gpxEngine(sharedObjects->gpxEngine()),
70       m_elevationEngine(sharedObjects->elevationEngine()),
71       m_imagesModel(sharedObjects->imagesModel()),
72       m_geoDataModel(sharedObjects->geoDataModel())
73 {
74     setWindowTitle(i18n("KGeoTag"));
75 
76     connect(m_elevationEngine, &ElevationEngine::elevationProcessed,
77             this, &MainWindow::elevationProcessed);
78 
79     connect(m_geoDataModel, &GeoDataModel::requestAddFiles, this, &MainWindow::addGpx);
80 
81     // Menu setup
82     // ==========
83 
84     // File
85     // ----
86 
87     auto *addFilesAction = actionCollection()->addAction(QStringLiteral("addFiles"));
88     addFilesAction->setText(i18n("Add images and/or GPX tracks"));
89     addFilesAction->setIcon(QIcon::fromTheme(QStringLiteral("document-new")));
90     actionCollection()->setDefaultShortcut(addFilesAction, QKeySequence(tr("Ctrl+F")));
91     connect(addFilesAction, &QAction::triggered, this, &MainWindow::addFiles);
92 
93     auto *addDirectoryAction = actionCollection()->addAction(QStringLiteral("addDirectory"));
94     addDirectoryAction->setText(i18n("Add all images and tracks from directory"));
95     addDirectoryAction->setIcon(QIcon::fromTheme(QStringLiteral("archive-insert-directory")));
96     actionCollection()->setDefaultShortcut(addDirectoryAction, QKeySequence(tr("Ctrl+D")));
97     connect(addDirectoryAction, &QAction::triggered, this, &MainWindow::addDirectory);
98 
99     // "Remove" submenu
100 
101     auto *removeProcessedSavedImagesAction
102         = actionCollection()->addAction(QStringLiteral("removeProcessedSavedImages"));
103     removeProcessedSavedImagesAction->setText(i18n("All processed and saved images"));
104     connect(removeProcessedSavedImagesAction, &QAction::triggered,
105             this, &MainWindow::removeProcessedSavedImages);
106 
107     auto removeImagesLoadedTaggedAction
108         = actionCollection()->addAction(QStringLiteral("removeImagesLoadedTagged"));
109     removeImagesLoadedTaggedAction->setText(i18n("All images that already had coordinates"));
110     connect(removeImagesLoadedTaggedAction, &QAction::triggered,
111             this, &MainWindow::removeImagesLoadedTagged);
112 
113     auto *removeAllImagesAction = actionCollection()->addAction(QStringLiteral("removeAllImages"));
114     removeAllImagesAction->setText(i18n("All images"));
115     connect(removeAllImagesAction, &QAction::triggered, this, &MainWindow::removeAllImages);
116 
117     auto *removeAllTracksAction = actionCollection()->addAction(QStringLiteral("removeAllTracks"));
118     removeAllTracksAction->setText(i18n("All tracks"));
119     connect(removeAllTracksAction, &QAction::triggered, this, &MainWindow::removeAllTracks);
120 
121     auto *removeEverything = actionCollection()->addAction(QStringLiteral("removeEverything"));
122     removeEverything->setText(i18n("All images and tracks (reset)"));
123     connect(removeEverything, &QAction::triggered, this, &MainWindow::removeEverything);
124 
125     // "File" menu again
126 
127     auto *searchMatchesAction = actionCollection()->addAction(QStringLiteral("searchMatches"));
128     searchMatchesAction->setText(i18n("Assign images to GPS data"));
129     searchMatchesAction->setIcon(QIcon::fromTheme(QStringLiteral("crosshairs")));
130     actionCollection()->setDefaultShortcut(searchMatchesAction, QKeySequence(tr("Ctrl+M")));
131     connect(searchMatchesAction, &QAction::triggered,
132             [this]
133             {
134                 triggerCompleteAutomaticMatching(m_settings->defaultMatchingMode());
135             });
136 
137     auto *saveChangesAction = actionCollection()->addAction(QStringLiteral("saveChanges"));
138     saveChangesAction->setText(i18n("Save changed images"));
139     saveChangesAction->setIcon(QIcon::fromTheme(QStringLiteral("document-save-all")));
140     actionCollection()->setDefaultShortcut(saveChangesAction, QKeySequence(tr("Ctrl+S")));
141     connect(saveChangesAction, &QAction::triggered, this, &MainWindow::saveAllChanges);
142 
143     KStandardAction::quit(this, &QWidget::close, actionCollection());
144 
145     // Settings
146     // --------
147 
148     auto *setDefaultDockArrangementAction
149         = actionCollection()->addAction(QStringLiteral("setDefaultDockArrangement"));
150     setDefaultDockArrangementAction->setText(i18n("Set default dock arrangement"));
151     setDefaultDockArrangementAction->setIcon(QIcon::fromTheme(QStringLiteral("refactor")));
152     connect(setDefaultDockArrangementAction, &QAction::triggered,
153             this, &MainWindow::setDefaultDockArrangement);
154 
155     KStandardAction::preferences(this, &MainWindow::showSettings, actionCollection());
156 
157     setupGUI(Keys | Save | Create);
158 
159     // Elicit a pointer from the "remove" menu from the XmlGui ;-)
160     // setupGUI() has to be called before this works
161     auto *removeMenu = qobject_cast<QMenu *>(guiFactory()->container(QStringLiteral("remove"),
162                                                                      this));
163     removeMenu->menuAction()->setIcon(QIcon::fromTheme(QStringLiteral("document-close")));
164 
165     // Dock setup
166     // ==========
167 
168     setDockNestingEnabled(true);
169 
170     // Bookmarks
171     m_bookmarksWidget = new BookmarksWidget(m_sharedObjects);
172     m_sharedObjects->setBookmarks(m_bookmarksWidget->bookmarks());
173     m_bookmarksDock = createDockWidget(i18n("Bookmarks"), m_bookmarksWidget,
174                                            QStringLiteral("bookmarksDock"));
175 
176     // Preview
177     m_previewWidget = new PreviewWidget(m_sharedObjects);
178     m_previewDock = createDockWidget(i18n("Preview"), m_previewWidget,
179                                          QStringLiteral("previewDock"));
180 
181     // Automatic matching
182     m_automaticMatchingWidget = new AutomaticMatchingWidget(m_settings);
183     m_automaticMatchingDock = createDockWidget(i18n("Automatic matching"),
184                                                m_automaticMatchingWidget,
185                                                QStringLiteral("automaticMatchingDock"));
186     connect(m_automaticMatchingWidget, &AutomaticMatchingWidget::requestReassignment,
187             this, &MainWindow::triggerCompleteAutomaticMatching);
188 
189     // Fix drift
190     m_fixDriftWidget = new FixDriftWidget;
191     m_fixDriftDock = createDockWidget(i18n("Fix time drift"), m_fixDriftWidget,
192                                           QStringLiteral("fixDriftDock"));
193     connect(m_fixDriftWidget, &FixDriftWidget::imagesTimeZoneChanged,
194             this, &MainWindow::imagesTimeZoneChanged);
195     connect(m_fixDriftWidget, &FixDriftWidget::cameraDriftSettingsChanged,
196             this, &MainWindow::cameraDriftSettingsChanged);
197 
198     // Map
199 
200     m_mapWidget = m_sharedObjects->mapWidget();
201     m_mapCenterInfo = new MapCenterInfo(m_sharedObjects);
202     connect(m_mapWidget, &MapWidget::mapMoved, m_mapCenterInfo, &MapCenterInfo::mapMoved);
203 
204     auto *mapWrapper = new QWidget;
205     auto *mapWrapperLayout = new QVBoxLayout(mapWrapper);
206     mapWrapperLayout->addWidget(m_mapWidget);
207     mapWrapperLayout->addWidget(m_mapCenterInfo);
208 
209     m_mapDock = createDockWidget(i18n("Map"), mapWrapper, QStringLiteral("mapDock"));
210 
211     connect(m_mapWidget, &MapWidget::imagesDropped, this, &MainWindow::imagesDropped);
212     connect(m_mapWidget, &MapWidget::requestLoadGpx, this, &MainWindow::addGpx);
213 
214     // Images lists
215 
216     m_unAssignedImagesDock = createImagesDock(KGeoTag::UnAssignedImages, i18n("Unassigned images"),
217                                               QStringLiteral("unAssignedImagesDock"));
218     m_assignedOrAllImagesDock = createImagesDock(KGeoTag::AssignedImages, QString(),
219                                                  QStringLiteral("assignedOrAllImagesDock"));
220     updateImagesListsMode();
221 
222     // Tracks
223 
224     m_tracksView = new TracksListView(m_geoDataModel);
225     connect(m_tracksView, &TracksListView::trackSelected, m_mapWidget, &MapWidget::zoomToTrack);
226     connect(m_tracksView, &TracksListView::removeTracks, this, &MainWindow::removeTracks);
227 
228     auto *trackWalker = new TrackWalker(m_geoDataModel);
229     connect(m_tracksView, &TracksListView::updateTrackWalker,
230             trackWalker, &TrackWalker::setToTrack);
231     connect(trackWalker, &TrackWalker::trackPointSelected, this, &MainWindow::centerTrackPoint);
232 
233     auto *tracksWrapper = new QWidget;
234     auto *tracksWrapperLayout = new QVBoxLayout(tracksWrapper);
235     tracksWrapperLayout->setContentsMargins(0, 0, 0, 0);
236     tracksWrapperLayout->addWidget(m_tracksView);
237     tracksWrapperLayout->addWidget(trackWalker);
238 
239     m_tracksDock = createDockWidget(i18n("Tracks"), tracksWrapper, QStringLiteral("tracksDock"));
240 
241     // Initialize/Restore the dock widget arrangement
242     if (! restoreState(m_settings->mainWindowState())) {
243         setDefaultDockArrangement();
244     } else {
245         m_unAssignedImagesDock->setVisible(m_settings->splitImagesList());
246     }
247 
248     // Restore the map's settings
249     m_mapWidget->restoreSettings();
250 
251     // Handle failed elevation lookups and missing locations
252     connect(m_sharedObjects->elevationEngine(), &ElevationEngine::lookupFailed,
253             this, &MainWindow::elevationLookupFailed);
254     connect(m_sharedObjects->elevationEngine(), &ElevationEngine::notAllPresent,
255             this, &MainWindow::notAllElevationsPresent);
256 
257     // Check if we could setup the timezone detection properly
258     QTimer::singleShot(0, [this]
259     {
260         // We do this in a QTimer singleShot so that the main window
261         // will be already visible if this warning should be displayed
262         if (! m_gpxEngine->timeZoneDataLoaded()) {
263             QMessageBox::warning(this, i18n("Loading timezone data"),
264                 i18n("<p>Could not load or parse the timezone data files "
265                      "<kbd>timezones.json</kbd> and/or <kbd>timezones.png</kbd>. Automatic "
266                      "timezone detection won't work.</p>"
267                      "<p>Please check your installation!</p>"
268                      "<p>If you run manually compiled sources without having installed them, "
269                      "please refer to <a href=\"https://community.kde.org/KGeoTag"
270                      "#Running_the_compiled_sources\">KDE's community wiki</a> on how to make "
271                      "the respective files accessible.</p>"));
272         }
273     });
274 }
275 
createImagesDock(KGeoTag::ImagesListType type,const QString & title,const QString & dockId)276 QDockWidget *MainWindow::createImagesDock(KGeoTag::ImagesListType type, const QString &title,
277                                           const QString &dockId)
278 {
279     auto *list = new ImagesListView(type, m_sharedObjects);
280 
281     connect(list, &ImagesListView::imageSelected, m_previewWidget, &PreviewWidget::setImage);
282     connect(list, &ImagesListView::centerImage, m_mapWidget, &MapWidget::centerImage);
283     connect(m_bookmarksWidget, &BookmarksWidget::bookmarksChanged,
284             list, &ImagesListView::updateBookmarks);
285     connect(list, &ImagesListView::requestAutomaticMatching,
286             this, &MainWindow::triggerAutomaticMatching);
287     connect(list, &ImagesListView::assignToMapCenter, this, &MainWindow::assignToMapCenter);
288     connect(list, &ImagesListView::assignManually, this, &MainWindow::assignManually);
289     connect(list, &ImagesListView::editCoordinates, this, &MainWindow::editCoordinates);
290     connect(list, QOverload<ImagesListView *>::of(&ImagesListView::removeCoordinates),
291             this, QOverload<ImagesListView *>::of(&MainWindow::removeCoordinates));
292     connect(list, QOverload<const QVector<QString> &>::of(&ImagesListView::removeCoordinates),
293             this, QOverload<const QVector<QString> &>::of(&MainWindow::removeCoordinates));
294     connect(list, &ImagesListView::discardChanges, this, &MainWindow::discardChanges);
295     connect(list, &ImagesListView::lookupElevation,
296             this, QOverload<ImagesListView *>::of(&MainWindow::lookupElevation));
297     connect(list, &ImagesListView::assignTo, this, &MainWindow::assignTo);
298     connect(list, &ImagesListView::requestAddingImages, this, &MainWindow::addImages);
299     connect(list, &ImagesListView::removeImages, this, &MainWindow::removeImages);
300     connect(list, &ImagesListView::requestSaving, this, &MainWindow::saveSelection);
301 
302     return createDockWidget(title, list, dockId);
303 }
304 
updateImagesListsMode()305 void MainWindow::updateImagesListsMode()
306 {
307     if (m_settings->splitImagesList()) {
308         m_assignedOrAllImagesDock->setWindowTitle(i18n("Assigned images"));
309         qobject_cast<ImagesListView *>(
310             m_assignedOrAllImagesDock->widget())->setListType(KGeoTag::AssignedImages);
311         m_unAssignedImagesDock->show();
312         qobject_cast<ImagesListView *>(
313             m_unAssignedImagesDock->widget())->setListType(KGeoTag::UnAssignedImages);
314         m_imagesModel->setSplitImagesList(true);
315     } else {
316         m_assignedOrAllImagesDock->setWindowTitle(i18n("Images"));
317         qobject_cast<ImagesListView *>(
318             m_assignedOrAllImagesDock->widget())->setListType(KGeoTag::AllImages);
319         m_unAssignedImagesDock->hide();
320         m_imagesModel->setSplitImagesList(false);
321     }
322 }
323 
setDefaultDockArrangement()324 void MainWindow::setDefaultDockArrangement()
325 {
326     const QVector<QDockWidget *> allDocks = {
327         m_assignedOrAllImagesDock,
328         m_unAssignedImagesDock,
329         m_previewDock,
330         m_fixDriftDock,
331         m_automaticMatchingDock,
332         m_bookmarksDock,
333         m_mapDock
334     };
335 
336     for (auto *dock : allDocks) {
337         dock->setFloating(false);
338         addDockWidget(Qt::TopDockWidgetArea, dock);
339     }
340 
341     for (int i = 1; i < allDocks.count(); i++) {
342         splitDockWidget(allDocks.at(i - 1), allDocks.at(i), Qt::Horizontal);
343     }
344 
345     splitDockWidget(m_assignedOrAllImagesDock, m_previewDock, Qt::Vertical);
346     splitDockWidget(m_assignedOrAllImagesDock, m_unAssignedImagesDock, Qt::Horizontal);
347 
348     const QVector<QDockWidget *> toTabify = {
349         m_previewDock,
350         m_fixDriftDock,
351         m_automaticMatchingDock,
352         m_bookmarksDock
353     };
354 
355     for (int i = 0; i < toTabify.count() - 1; i++) {
356         tabifyDockWidget(toTabify.at(i), toTabify.at(i + 1));
357     }
358     toTabify.first()->raise();
359 
360     tabifyDockWidget(m_assignedOrAllImagesDock, m_tracksDock);
361     m_assignedOrAllImagesDock->raise();
362 
363     const double windowWidth = double(width());
364     resizeDocks({ m_previewDock, m_mapDock },
365                 { int(windowWidth * 0.4), int(windowWidth * 0.6) },
366                 Qt::Horizontal);
367 }
368 
createDockWidget(const QString & title,QWidget * widget,const QString & objectName)369 QDockWidget *MainWindow::createDockWidget(const QString &title, QWidget *widget,
370                                           const QString &objectName)
371 {
372     auto *dock = new QDockWidget(title, this);
373     dock->setObjectName(objectName);
374     dock->setContextMenuPolicy(Qt::PreventContextMenu);
375     dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable);
376     dock->setWidget(widget);
377     addDockWidget(Qt::TopDockWidgetArea, dock);
378     return dock;
379 }
380 
closeEvent(QCloseEvent * event)381 void MainWindow::closeEvent(QCloseEvent *event)
382 {
383     if (! m_imagesModel->imagesWithPendingChanges().isEmpty()) {
384         if (QMessageBox::question(this, i18n("Close KGeoTag"),
385             i18n("<p>There are pending changes to images that haven't been saved yet. All changes "
386                  "will be discarded if KGeoTag is closed now.</p>"
387                  "<p>Do you want to close the program anyway?</p>"),
388             QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) {
389 
390             event->ignore();
391             return;
392         }
393     }
394 
395     m_settings->saveMainWindowState(saveState());
396 
397     m_mapWidget->saveSettings();
398 
399     m_settings->saveBookmarks(m_bookmarksWidget->bookmarks());
400 
401     QApplication::quit();
402 }
403 
addFiles()404 void MainWindow::addFiles()
405 {
406     const auto selection = QFileDialog::getOpenFileNames(this,
407                                 i18n("Please select the images and/or GPX tracks to add"),
408                                 m_settings->lastOpenPath(),
409                                 i18n("All supported files ("
410                                     "*.jpg *.jpeg "
411                                     "*.png "
412                                     "*.webp "
413                                     "*.tif *.tiff "
414                                     "*.ora "
415                                     "*.kra "
416                                     "*.gpx "
417                                     ");; All files (*)"));
418 
419     if (selection.isEmpty()) {
420         return;
421     }
422 
423     // Check the MIME type of all selected files
424     QHash<KGeoTag::FileType, QVector<QString>> classified;
425     for (const auto &path : selection) {
426         classified[MimeHelper::classifyFile(path)].append(path);
427     }
428 
429     // Inform the user if some unsupported files have been selected
430 
431     if (classified.value(KGeoTag::UnsupportedFile).count() > 0) {
432         QString text;
433 
434         if (classified.value(KGeoTag::ImageFile).count() == 0
435             && classified.value(KGeoTag::GeoDataFile).count() == 0) {
436 
437             text = i18n("<p>The selection did not contain any supported files!</p>");
438 
439         } else {
440             QString skippedList;
441             for (const auto &path : classified.value(KGeoTag::UnsupportedFile)) {
442                 QFileInfo info(path);
443                 skippedList.append(i18nc(
444                     "A filename with a MIME type in braces and a HTML line break",
445                     "%1 (%2)<br/>",
446                     info.fileName(),
447                     MimeHelper::mimeType(path)));
448             }
449 
450             text = i18np("<p>The following file will be skipped due to an unsupported MIME type:"
451                          "</p>"
452                          "<p>%2</p>",
453                          "<p>The following files will be skipped due to unsupported MIME types:</p>"
454                          "<p>%2</p>",
455                          classified.value(KGeoTag::UnsupportedFile).count(),
456                          skippedList);
457         }
458 
459         QMessageBox::warning(this, i18n("Add images and/or GPX tracks"), text);
460     }
461 
462     // Add the geodata files
463     if (classified.value(KGeoTag::GeoDataFile).count() > 0) {
464         addGpx(classified.value(KGeoTag::GeoDataFile));
465     }
466 
467     // Add the images
468     if (classified.value(KGeoTag::ImageFile).count() > 0) {
469         addImages(classified.value(KGeoTag::ImageFile));
470     }
471 }
472 
addDirectory()473 void MainWindow::addDirectory()
474 {
475     const auto directory = QFileDialog::getExistingDirectory(this,
476                                i18n("Please select a directory"),
477                                m_settings->lastOpenPath());
478 
479     if (directory.isEmpty()) {
480         return;
481     }
482 
483     QDir dir(directory);
484     const auto files = dir.entryList({ QStringLiteral("*") }, QDir::Files);
485 
486     QVector<QString> geoDataFiles;
487     QVector<QString> images;
488     for (const auto &file : files) {
489         const auto path = directory + QStringLiteral("/") + file;
490         switch (MimeHelper::classifyFile(path)) {
491         case KGeoTag::GeoDataFile:
492             geoDataFiles.append(path);
493             break;
494         case KGeoTag::ImageFile:
495             images.append(path);
496             break;
497         case KGeoTag::UnsupportedFile:
498             break;
499         }
500     }
501 
502     if (geoDataFiles.isEmpty() && images.isEmpty()) {
503         QMessageBox::warning(this, i18n("Add all images and tracks from directory"),
504                              i18n("Could not find any supported files in <kbd>%1</kbd>",
505                                   directory));
506         return;
507     }
508 
509     // Add the geodata files
510     if (geoDataFiles.count() > 0) {
511         addGpx(geoDataFiles);
512     }
513 
514     // Add the images
515     if (images.count() > 0) {
516         addImages(images);
517     }
518 }
519 
addGpx(const QVector<QString> & paths)520 void MainWindow::addGpx(const QVector<QString> &paths)
521 {
522     const int filesCount = paths.count();
523     int processed = 0;
524     int failed = 0;
525     int allFiles = 0;
526     int allTracks = 0;
527     int allSegments = 0;
528     int allPoints = 0;
529     int alreadyLoaded = 0;
530     QVector<QString> loadedPaths;
531 
532     QApplication::setOverrideCursor(Qt::WaitCursor);
533 
534     for (const auto &path : paths) {
535         processed++;
536 
537         const QFileInfo info(path);
538         m_settings->saveLastOpenPath(info.dir().absolutePath());
539 
540         const auto [ result, tracks, segments, points ]
541             = m_gpxEngine->load(info.canonicalFilePath());
542 
543         QString errorString;
544 
545         switch (result) {
546         case GpxEngine::Okay:
547             allFiles++;
548             allTracks += tracks;
549             allSegments += segments;
550             allPoints += points;
551             loadedPaths.append(path);
552             break;
553 
554         case GpxEngine::AlreadyLoaded:
555             alreadyLoaded++;
556             break;
557 
558         case GpxEngine::OpenFailed:
559             errorString = i18n("<p>Opening <kbd>%1</kbd> failed. Please be sure to have read "
560                                "access to this file.</p>", path);
561             break;
562 
563         case GpxEngine::NoGpxElement:
564             errorString = i18n("<p>Could not read geodata from <kbd>%1</kbd>: Could not find the "
565                                "<kbd>gpx</kbd> root element. Apparently, this is not a GPX file!"
566                                "</p>", path);
567             break;
568 
569         case GpxEngine::NoGeoData:
570             errorString = i18n("<p><kbd>%1</kbd> seems to be a GPX file, but it contains no "
571                                "geodata!</p>", path);
572             break;
573 
574         case GpxEngine::XmlError:
575             errorString = i18n("<p>XML parsing failed for <kbd>%1</kbd>. Either, no data could be "
576                                "loaded at all, or only a part of it.</p>", path);
577             break;
578         }
579 
580         if (! errorString.isEmpty()) {
581             failed++;
582 
583             QString text;
584             if (filesCount == 1) {
585                 text = i18n("<p><b>Loading GPX file failed</b></p>");
586             } else {
587                 text = i18nc("Fraction of processed files are added inside the round braces",
588                              "<p><b>Loading GPX file failed (%1/%2)</b></p>",
589                              processed, filesCount);
590             }
591 
592             text.append(errorString);
593 
594             if (processed < filesCount) {
595                 text.append(i18n("<p>The next GPX file will be loaded now.</p>"));
596             }
597 
598             QApplication::restoreOverrideCursor();
599             QMessageBox::warning(this, i18n("Load GPX data"), text);
600             QApplication::setOverrideCursor(Qt::WaitCursor);
601         }
602     }
603 
604     m_mapWidget->zoomToTracks(loadedPaths);
605 
606     QString text;
607 
608     if (failed == 0 && alreadyLoaded == 0) {
609         text = i18np("<p>Processed one file</p>", "<p>Processed %1 files</p>", processed);
610     } else if (failed > 0 && alreadyLoaded == 0) {
611         text = i18ncp("Processed x files message with some files that failed to load. The "
612                       "pluralized string for the failed files counter (%2) is provided by the "
613                       "following i18np call.",
614                       "<p>Processed one file; %2</p>", "<p>Processed %1 files; %2</p>",
615                       processed,
616                       i18np("one file failed to load", "%1 files failed to load", failed));
617     } else if (failed == 0 && alreadyLoaded > 0) {
618         text = i18ncp("Processed x files message with some files that have been skipped. The "
619                       "pluralized string for the skipped files counter (%2) is provided by the "
620                       "following i18np call.",
621                       "<p>Processed one file; %2</p>", "<p>Processed %1 files; %2</p>",
622                       processed,
623                       i18np("one already loaded file has been skipped",
624                             "%1 already loaded files have been skipped", alreadyLoaded));
625     } else if (failed > 0 && alreadyLoaded == 0) {
626         text = i18ncp("Processed x files message with some files that failed to load and some that "
627                       "have been skipped. The pluralized strings for the failed (%2) and skipped "
628                       "files counter (%3) are provided by the following i18np call.",
629                       "<p>Processed one file; %2, %3</p>", "<p>Processed %1 files; %2, %3</p>",
630                       processed,
631                       i18np("one file failed to load", "%1 files failed to load", failed),
632                       i18np("one already loaded file has been skipped",
633                             "%1 already loaded files have been skipped", alreadyLoaded));
634     }
635 
636     if (allPoints == 0) {
637         text.append(i18n("<p>No waypoints could be loaded.</p>"));
638     } else {
639         if (allTracks > 0 && allSegments == allTracks) {
640             text.append(i18ncp(
641                 "Loaded x waypoints message with the number of tracks that have been read. The "
642                 "pluralized string for the tracks (%2) is provided by the following i18np call.",
643                 "<p>Loaded one waypoint from %2</p>",
644                 "<p>Loaded %1 waypoints from %2</p>",
645                 allPoints,
646                 i18np("one track", "%1 tracks", allTracks)));
647         } else if (allTracks > 0 && allSegments != allTracks) {
648             text.append(i18ncp(
649                 "Loaded x waypoints message with the number of tracks and the number of segments "
650                 "that have been read. The pluralized string for the tracks (%2) and the one for "
651                 "the segments (%3) are provided by the following i18np calls.",
652                 "<p>Loaded one waypoint from %2 and %3</p>",
653                 "<p>Loaded %1 waypoints from %2 and %3</p>",
654                 allPoints,
655                 i18np("one track", "%1 tracks", allTracks),
656                 i18np("one segment", "%1 segments", allSegments)));
657         }
658     }
659 
660     QApplication::restoreOverrideCursor();
661 
662     // Display the load result
663     QMessageBox::information(this, i18n("Load GPX data"), text);
664 
665     // Adopt the detected timezone
666 
667     const QByteArray &id = m_gpxEngine->lastDetectedTimeZoneId();
668 
669     if (allTracks > 0 && id != m_fixDriftWidget->imagesTimeZoneId()) {
670         if (id.isEmpty()) {
671             QMessageBox::warning(this, i18n("Timezone detection failed"),
672                 i18n("<p>The presumably correct timezone for images associated with the loaded GPX "
673                      "file could not be detected.</p>"
674                      "<p>Please set the correct timezone manually on the \"Fix time drift\" page."
675                      "</p>"));
676         } else {
677             if (! m_fixDriftWidget->setImagesTimeZone(id)) {
678                 QMessageBox::warning(this, i18n("Setting the detected timezone failed"),
679                     i18n("<p>The presumably correct timezone \"%1\" has been detected from the "
680                          "loaded GPX file, but could not be set. This should not happen!</p>"
681                          "<p>Please file a bug report about this, including the used version of "
682                          "KGeoTag and Qt!</p>"
683                          "<p>You can adjust the timezone setting manually on the \"Fix time "
684                          "drift\" page.</p>",
685                          QString::fromLatin1(id)));
686             } else {
687                 QMessageBox::information(this, i18n("Timezone adjusted"),
688                     i18n("<p>The loaded GPX file was presumably recorded in the timezone \"%1\", "
689                          "as well as the photos to associate with it. This timezone has been "
690                          "selected now.</p>"
691                          "<p>You can adjust the timezone setting manually on the \"Fix time "
692                          "drift\" page.</p>",
693                          QString::fromLatin1(id)));
694             }
695         }
696     }
697 }
698 
addImages(const QVector<QString> & paths)699 void MainWindow::addImages(const QVector<QString> &paths)
700 {
701     QApplication::setOverrideCursor(Qt::WaitCursor);
702 
703     const QFileInfo info(paths.at(0));
704     m_settings->saveLastOpenPath(info.dir().absolutePath());
705 
706     const int requested = paths.count();
707     const bool isSingleFile = requested == 1;
708     int processed = 0;
709     int loaded = 0;
710     int alreadyLoaded = 0;
711     bool skipImage = false;
712     bool abortLoad = false;
713 
714     QProgressDialog progress(i18n("Loading images ..."), i18n("Cancel"), 0, requested, this);
715     progress.setWindowModality(Qt::WindowModal);
716 
717     for (const auto &path : paths) {
718         progress.setValue(processed++);
719         if (progress.wasCanceled()) {
720             break;
721         }
722 
723         const QFileInfo info(path);
724         while (true) {
725             QString errorString;
726             bool exitLoop = false;
727 
728             switch (m_imagesModel->addImage(info.canonicalFilePath())) {
729             case ImagesModel::LoadingSucceeded:
730                 exitLoop = true;
731                 break;
732 
733             case ImagesModel::AlreadyLoaded:
734                 alreadyLoaded++;
735                 exitLoop = true;
736                 break;
737 
738             case ImagesModel::LoadingImageFailed:
739                 if (isSingleFile) {
740                     errorString = i18n("<p><b>Loading image failed</b></p>"
741                                        "<p>Could not read <kbd>%1</kbd>.</p>",
742                                        path);
743                 } else {
744                     errorString = i18nc(
745                         "Message with a fraction of processed files added in round braces",
746                         "<p><b>Loading image failed (%1/%2)</b></p>"
747                         "<p>Could not read <kbd>%3</kbd>.</p>",
748                         processed, requested, path);
749                 }
750                 break;
751 
752             case ImagesModel::LoadingMetadataFailed:
753                 if (isSingleFile) {
754                     errorString = i18n(
755                         "<p><b>Loading image's Exif header or XMP sidecar file failed</b></p>"
756                         "<p>Could not read <kbd>%1</kbd>.</p>",
757                         path);
758                 } else {
759                     errorString = i18nc(
760                         "Message with a fraction of processed files added in round braces",
761                         "<p><b>Loading image's Exif header or XMP sidecar file failed</b></p>"
762                         "<p>Could not read <kbd>%2</kbd>.</p>",
763                         processed, requested, path);
764                 }
765                 break;
766 
767             }
768 
769             if (exitLoop || errorString.isEmpty()) {
770                 break;
771             }
772 
773             errorString.append(i18n("<p>Please check if this file is actually a supported image "
774                                     "and if you have read access to it.</p>"));
775 
776             if (isSingleFile) {
777                 errorString.append(i18n("<p>You can retry to load this file or cancel the loading "
778                                         "process.</p>"));
779             } else {
780                 errorString.append(i18n("<p>You can retry to load this file, skip it or cancel the "
781                                         "loading process.</p>"));
782             }
783 
784             progress.reset();
785             QApplication::restoreOverrideCursor();
786 
787             RetrySkipAbortDialog dialog(this, i18n("Add images"), errorString, isSingleFile);
788             const auto reply = dialog.exec();
789             if (reply == RetrySkipAbortDialog::Skip) {
790                 skipImage = true;
791                 break;
792             } else if (reply == RetrySkipAbortDialog::Abort) {
793                 abortLoad = true;
794                 break;
795             }
796 
797             QApplication::setOverrideCursor(Qt::WaitCursor);
798         }
799 
800         if (skipImage) {
801             skipImage = false;
802             continue;
803         }
804         if (abortLoad) {
805             break;
806         }
807 
808         loaded++;
809     }
810 
811     progress.reset();
812     m_mapWidget->reloadMap();
813     QApplication::restoreOverrideCursor();
814 
815     const int failed = requested - loaded;
816     loaded -= alreadyLoaded;
817 
818     if (loaded == requested) {
819         QMessageBox::information(this, i18n("Add images"),
820                                  i18np("Successfully added one image!",
821                                        "Successfully added %1 images!",
822                                        loaded));
823 
824     } else if (failed == requested) {
825         QMessageBox::warning(this, i18n("Add images"), i18n(
826             "Could not add any new images, all requested images failed to load!"));
827 
828     } else if (alreadyLoaded == requested) {
829         QMessageBox::warning(this, i18n("Add images"), i18n(
830             "Could not add any new images, all requested images have already been loaded!"));
831 
832     } else if (alreadyLoaded + failed == requested) {
833         QMessageBox::warning(this, i18n("Add images"), i18n(
834             "Could not add any new images, all requested images failed to load or have already "
835             "been loaded!"));
836 
837     } else {
838         QString message = i18np("<p>Successfully added image!</p>",
839                                 "<p>Successfully added %1 images!</p>",
840                                 loaded);
841 
842         if (failed > 0 && alreadyLoaded == 0) {
843             message.append(i18np("<p>One image failed to load.</p>",
844                                  "<p>%1 images failed to load.</p>",
845                                  failed));
846         } else if (failed == 0 && alreadyLoaded > 0) {
847             message.append(i18np("<p>One image has already been loaded.</p>",
848                                  "<p>%1 images have already been loaded.</p>",
849                                  alreadyLoaded));
850         } else {
851             message.append(i18nc(
852                 "Message string for some images that failed to load and some that were skipped "
853                 "because they already have been loaded. The pluralized strings for the failed "
854                 "images (%1) and the skipped images (%2) are provided by the following i18np "
855                 "calls.",
856                 "<p>%1 and %2.</p>",
857                 i18np("One image failed to load",
858                       "%1 images failed to load",
859                       failed),
860                 i18np("one image has already been loaded",
861                       "%1 images have already been loaded",
862                       alreadyLoaded)));
863         }
864 
865         QMessageBox::warning(this, i18n("Add images"), message);
866     }
867 }
868 
imagesDropped(const QVector<QString> & paths)869 void MainWindow::imagesDropped(const QVector<QString> &paths)
870 {
871     m_previewWidget->setImage(m_imagesModel->indexFor(paths.last()));
872     if (m_settings->lookupElevationAutomatically()) {
873         lookupElevation(paths);
874     }
875 }
876 
assignToMapCenter(ImagesListView * list)877 void MainWindow::assignToMapCenter(ImagesListView *list)
878 {
879     assignTo(list->selectedPaths(), m_mapWidget->currentCenter());
880 }
881 
assignManually(ImagesListView * list)882 void MainWindow::assignManually(ImagesListView *list)
883 {
884     const auto paths = list->selectedPaths();
885 
886     QString label;
887     if (paths.count() == 1) {
888         QFileInfo info(paths.first());
889         label = i18nc("A quoted filename", "\"%1\"", info.fileName());
890     } else {
891         // We don't need this for English, but possibly for languages with other plural forms
892         label = i18np("1 image", "%1 images", paths.count());
893     }
894 
895     CoordinatesDialog dialog(CoordinatesDialog::Mode::EditCoordinates,
896                              m_settings->lookupElevationAutomatically(), Coordinates(), label);
897     if (! dialog.exec()) {
898         return;
899     }
900 
901     assignTo(paths, dialog.coordinates());
902 }
903 
editCoordinates(ImagesListView * list)904 void MainWindow::editCoordinates(ImagesListView *list)
905 {
906     const auto paths = list->selectedPaths();
907     auto coordinates = m_imagesModel->coordinates(paths.first());
908     bool identicalCoordinates = true;
909     for (int i = 1; i < paths.count(); i++) {
910         if (m_imagesModel->coordinates(paths.at(i)) != coordinates) {
911             identicalCoordinates = false;
912             break;
913         }
914     }
915 
916     QString label;
917     if (paths.count() == 1) {
918         QFileInfo info(paths.first());
919         label = i18nc("A quoted filename", "\"%1\"", info.fileName());
920     } else {
921         // We don't need this for English, but possibly for languages with other plural forms
922         label = i18np("1 image (%2)", "%1 images (%2)", paths.count(),
923                       identicalCoordinates ? i18n("all images have the same coordinates")
924                                            : i18n("coordinates differ across the images"));
925     }
926 
927     CoordinatesDialog dialog(CoordinatesDialog::Mode::EditCoordinates, false,
928                              identicalCoordinates ? coordinates : Coordinates(),
929                              label);
930     if (! dialog.exec()) {
931         return;
932     }
933 
934     assignTo(paths, dialog.coordinates());
935 }
936 
assignTo(const QVector<QString> & paths,const Coordinates & coordinates)937 void MainWindow::assignTo(const QVector<QString> &paths, const Coordinates &coordinates)
938 {
939     for (const auto &path : paths) {
940         m_imagesModel->setCoordinates(path, coordinates, KGeoTag::ManuallySet);
941     }
942 
943     m_mapWidget->centerCoordinates(coordinates);
944     m_mapWidget->reloadMap();
945 
946     if (m_settings->lookupElevationAutomatically()) {
947         lookupElevation(paths);
948     }
949 }
950 
triggerAutomaticMatching(ImagesListView * list,KGeoTag::SearchType searchType)951 void MainWindow::triggerAutomaticMatching(ImagesListView *list, KGeoTag::SearchType searchType)
952 {
953     const auto paths = list->selectedPaths();
954     matchAutomatically(paths, searchType);
955 }
956 
triggerCompleteAutomaticMatching(KGeoTag::SearchType searchType)957 void MainWindow::triggerCompleteAutomaticMatching(KGeoTag::SearchType searchType)
958 {
959     if (m_imagesModel->allImages().isEmpty()) {
960         QMessageBox::information(this, i18n("(Re)Assign all images"),
961                                  i18n("Can't search for matches:\n"
962                                       "No images have been loaded yet."));
963         return;
964     }
965 
966     QVector<QString> paths;
967     const bool excludeManuallyTagged = m_automaticMatchingWidget->excludeManuallyTagged();
968     for (const auto &path : m_imagesModel->allImages()) {
969         if (excludeManuallyTagged && m_imagesModel->matchType(path) == KGeoTag::ManuallySet) {
970             continue;
971         }
972         paths.append(path);
973     }
974     matchAutomatically(paths, searchType);
975 }
976 
matchAutomatically(const QVector<QString> & paths,KGeoTag::SearchType searchType)977 void MainWindow::matchAutomatically(const QVector<QString> &paths, KGeoTag::SearchType searchType)
978 {
979     if (m_geoDataModel->rowCount() == 0) {
980         QMessageBox::information(this, i18n("Automatic matching"),
981                                  i18n("Can't search for matches:\n"
982                                       "No GPS tracks have been loaded yet."));
983         return;
984     }
985 
986     QApplication::setOverrideCursor(Qt::WaitCursor);
987 
988     m_gpxEngine->setMatchParameters(m_automaticMatchingWidget->exactMatchTolerance(),
989                                     m_automaticMatchingWidget->maximumInterpolationInterval(),
990                                     m_automaticMatchingWidget->maximumInterpolationDistance());
991 
992     int exactMatches = 0;
993     int interpolatedMatches = 0;
994     QString lastMatchedPath;
995 
996     QProgressDialog progress(i18n("Assigning images ..."), i18n("Cancel"), 0, paths.count(), this);
997     progress.setWindowModality(Qt::WindowModal);
998 
999     int processed = 0;
1000     int notMatched = 0;
1001     int notMatchedButHaveCoordinates = 0;
1002 
1003     for (const auto &path : paths) {
1004         progress.setValue(processed++);
1005         if (progress.wasCanceled()) {
1006             break;
1007         }
1008 
1009         Coordinates coordinates;
1010 
1011         // Search for exact matches if requested
1012 
1013         if (searchType == KGeoTag::CombinedMatchSearch
1014             || searchType == KGeoTag::ExactMatchSearch) {
1015 
1016             coordinates = m_gpxEngine->findExactCoordinates(
1017                 m_imagesModel->date(path), m_fixDriftWidget->cameraClockDeviation());
1018         }
1019 
1020         if (coordinates.isSet()) {
1021             m_imagesModel->setCoordinates(path, coordinates, KGeoTag::ExactMatch);
1022             exactMatches++;
1023             lastMatchedPath = path;
1024             continue;
1025         }
1026 
1027         // Search for interpolated matches if requested
1028 
1029         if (searchType == KGeoTag::CombinedMatchSearch
1030             || searchType == KGeoTag::InterpolatedMatchSearch) {
1031 
1032             coordinates = m_gpxEngine->findInterpolatedCoordinates(
1033                 m_imagesModel->date(path), m_fixDriftWidget->cameraClockDeviation());
1034         }
1035 
1036         if (coordinates.isSet()) {
1037             m_imagesModel->setCoordinates(path, coordinates, KGeoTag::InterpolatedMatch);
1038             interpolatedMatches++;
1039             lastMatchedPath = path;
1040         } else {
1041             notMatched++;
1042             if (m_imagesModel->coordinates(path).isSet()) {
1043                 notMatchedButHaveCoordinates++;
1044             }
1045         }
1046     }
1047 
1048     progress.reset();
1049 
1050     QString title;
1051     QString text;
1052 
1053     switch (searchType) {
1054     case KGeoTag::CombinedMatchSearch:
1055         title = i18n("Combined match search");
1056         if (exactMatches > 0 || interpolatedMatches > 0) {
1057             text = i18nc("Message for the number of matches found. The pluralized string for the "
1058                          "exact matches (%1) and the interpolated matches (%2) are provided by the "
1059                          "following i18np calls.",
1060                          "<p>Found %1 and %2!</p>",
1061                          i18np("one exact match",
1062                                "%1 exact matches",
1063                                exactMatches),
1064                          i18np("one interpolated match",
1065                                "%1 interpolated matches",
1066                                interpolatedMatches));
1067             if (notMatched > 0) {
1068                 text.append(i18ncp("Message for the number of unmatched images. The number of "
1069                                    "images that could not be matched but already have coordinates "
1070                                    "assigned (%2) is provided by the following i18np call",
1071                                    "<p>One image could not be matched (%2).</p>",
1072                                    "<p>%1 images could not be matched (%2).</p>",
1073                                    notMatched,
1074                                    i18np("of which one image already has coordinates assigned",
1075                                          "of which %1 images already have coordinates assigned",
1076                                          notMatchedButHaveCoordinates)));
1077             }
1078         } else {
1079             text = i18n("Could neither find any exact, nor any interpolated matches!");
1080         }
1081         break;
1082 
1083     case KGeoTag::ExactMatchSearch:
1084         title = i18n("Exact matches search");
1085         if (exactMatches > 0) {
1086             text = i18np("<p>Found one exact match!</p>",
1087                          "<p>Found %1 exact matches!</p>",
1088                          exactMatches);
1089             if (notMatched > 0) {
1090                 text.append(i18ncp("Message for the number of unmatched images. The number of "
1091                                    "images that could not be matched but already have coordinates "
1092                                    "assigned (%2) is provided by the following i18np call",
1093                                    "<p>One image had no exact match (%2).</p>",
1094                                    "<p>%1 images had no exact match (%2).</p>",
1095                                    notMatched,
1096                                    i18np("of which one image already has coordinates assigned",
1097                                          "of which %1 images already have coordinates assigned",
1098                                          notMatchedButHaveCoordinates)));
1099             }
1100         } else {
1101             text = i18n("Could not find any exact matches!");
1102         }
1103         break;
1104 
1105     case KGeoTag::InterpolatedMatchSearch:
1106         title = i18n("Interpolated matches search");
1107         if (interpolatedMatches > 0) {
1108             text = i18np("<p>Found one interpolated match!</p>",
1109                          "<p>Found %1 interpolated matches!</p>",
1110                          interpolatedMatches);
1111             if (notMatched > 0) {
1112                 text.append(i18ncp("Message for the number of unmatched images. The number of "
1113                                    "images that could not be matched but already have coordinates "
1114                                    "assigned (%2) is provided by the following i18np call",
1115                                    "<p>One image had no interpolated match (%2).</p>",
1116                                    "<p>%1 images had no interpolated match (%2).</p>",
1117                                    notMatched,
1118                                    i18np("of which one image already has coordinates assigned",
1119                                          "of which %1 images already have coordinates assigned",
1120                                          notMatchedButHaveCoordinates)));
1121             }
1122         } else {
1123             text = i18n("Could not find any interpolated matches!");
1124         }
1125         break;
1126     }
1127 
1128     QApplication::restoreOverrideCursor();
1129 
1130     if (exactMatches > 0 || interpolatedMatches > 0) {
1131         m_mapWidget->reloadMap();
1132         const auto index = m_imagesModel->indexFor(lastMatchedPath);
1133         m_mapWidget->centerImage(index);
1134         m_previewWidget->setImage(index);
1135         QMessageBox::information(this, title, text);
1136     } else {
1137         QMessageBox::warning(this, title, text);
1138     }
1139 }
1140 
saveFailedHeader(int processed,int allImages) const1141 QString MainWindow::saveFailedHeader(int processed, int allImages) const
1142 {
1143     if (allImages == 1) {
1144         return i18n("<p><b>Saving changes failed</b></p>");
1145     } else {
1146         return i18nc("Saving failed message with the fraction of processed files given in the "
1147                      "round braces",
1148                      "<p><b>Saving changes failed (%1/%2)</b></p>",
1149                      processed, allImages);
1150     }
1151 }
1152 
skipRetryCancelText(int processed,int allImages) const1153 QString MainWindow::skipRetryCancelText(int processed, int allImages) const
1154 {
1155     if (allImages == 1 || processed == allImages) {
1156         return i18n("<p>You can retry to process the file or cancel the saving process.</p>");
1157     } else {
1158         return i18n("<p>You can retry to process the file, skip it or cancel the saving process."
1159                     "</p>");
1160     }
1161 }
1162 
saveSelection(ImagesListView * list)1163 void MainWindow::saveSelection(ImagesListView *list)
1164 {
1165     QVector<QString> files;
1166     auto selected = list->selectedPaths();
1167     for (const auto &path : selected) {
1168         if (m_imagesModel->hasPendingChanges(path)) {
1169             files.append(path);
1170         }
1171     }
1172     saveChanges(files);
1173 }
1174 
saveAllChanges()1175 void MainWindow::saveAllChanges()
1176 {
1177     saveChanges(m_imagesModel->imagesWithPendingChanges());
1178 }
1179 
saveChanges(const QVector<QString> & files)1180 void MainWindow::saveChanges(const QVector<QString> &files)
1181 {
1182     if (files.isEmpty()) {
1183         QMessageBox::information(this, i18n("Save changes"), i18n("Nothing to do"));
1184         return;
1185     }
1186 
1187     QApplication::setOverrideCursor(Qt::WaitCursor);
1188 
1189     const auto writeMode = s_writeModeMap.value(m_settings->writeMode());
1190     const bool createBackups =
1191         writeMode != KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARONLY
1192         && m_settings->createBackups();
1193     const int cameraClockDeviation = m_fixDriftWidget->cameraClockDeviation();
1194     const bool fixDrift = m_fixDriftWidget->save() && cameraClockDeviation != 0;
1195 
1196     bool skipImage = false;
1197     bool abortWrite = false;
1198     int savedImages = 0;
1199     const int allImages = files.count();
1200     int processed = 0;
1201     const bool isSingleFile = allImages == 1;
1202 
1203     QProgressDialog progress(i18n("Saving changes ..."), i18n("Cancel"), 0, allImages, this);
1204     progress.setWindowModality(Qt::WindowModal);
1205 
1206     for (const QString &path : files) {
1207         progress.setValue(processed++);
1208         if (progress.wasCanceled()) {
1209             break;
1210         }
1211 
1212         // Create a backup of the file if requested
1213         if (createBackups) {
1214             bool backupOkay = false;
1215             const QString backupPath = path + QStringLiteral(".") + KGeoTag::backupSuffix;
1216 
1217             while (! backupOkay) {
1218                 backupOkay = QFile::copy(path, backupPath);
1219 
1220                 if (! backupOkay) {
1221                     QFileInfo info(path);
1222                     QString message = saveFailedHeader(processed, allImages);
1223 
1224                     message.append(i18n(
1225                         "<p>Could not save changes to <kbd>%1</kbd>: The backup file <kbd>%2</kbd> "
1226                         "could not be created.</p>"
1227                         "<p>Please check if this file doesn't exist yet and be sure to have write "
1228                         "access to <kbd>%3</kbd>.</p>",
1229                         info.fileName(), backupPath, info.dir().path()));
1230 
1231                     message.append(skipRetryCancelText(processed, allImages));
1232 
1233                     progress.reset();
1234                     QApplication::restoreOverrideCursor();
1235 
1236                     RetrySkipAbortDialog dialog(this, i18n("Save changes"), message,
1237                                                 isSingleFile || processed == allImages);
1238 
1239                     const auto reply = dialog.exec();
1240                     if (reply == RetrySkipAbortDialog::Skip) {
1241                         skipImage = true;
1242                         break;
1243                     } else if (reply == RetrySkipAbortDialog::Abort) {
1244                         abortWrite = true;
1245                         break;
1246                     }
1247 
1248                     QApplication::setOverrideCursor(Qt::WaitCursor);
1249                 }
1250             }
1251         }
1252 
1253         if (skipImage) {
1254             skipImage = false;
1255             continue;
1256         }
1257         if (abortWrite) {
1258             break;
1259         }
1260 
1261         // Write the GPS information
1262 
1263         // Read the Exif header
1264 
1265         auto exif = KExiv2Iface::KExiv2();
1266         exif.setUseXMPSidecar4Reading(true);
1267 
1268         while (! exif.load(path)) {
1269             QString message = saveFailedHeader(processed, allImages);
1270 
1271             message.append(i18n(
1272                 "<p>Could not read metadata from <kbd>%1</kbd>.</p>"
1273                 "<p>Please check if this file still exists and if you have read access to it (and "
1274                 "possibly also to an existing XMP sidecar file).</p>",
1275                 path));
1276 
1277             message.append(skipRetryCancelText(processed, allImages));
1278 
1279             progress.reset();
1280             QApplication::restoreOverrideCursor();
1281 
1282             RetrySkipAbortDialog dialog(this, i18n("Save changes"), message,
1283                                         isSingleFile || processed == allImages);
1284 
1285             const auto reply = dialog.exec();
1286             if (reply == RetrySkipAbortDialog::Skip) {
1287                 skipImage = true;
1288                 break;
1289             } else if (reply == RetrySkipAbortDialog::Abort) {
1290                 abortWrite = true;
1291                 break;
1292             }
1293 
1294             QApplication::setOverrideCursor(Qt::WaitCursor);
1295         }
1296 
1297         if (skipImage) {
1298             skipImage = false;
1299             continue;
1300         }
1301         if (abortWrite) {
1302             break;
1303         }
1304 
1305         // Set or remove the coordinates
1306         const auto coordinates = m_imagesModel->coordinates(path);
1307         if (coordinates.isSet()) {
1308             exif.setGPSInfo(coordinates.alt(), coordinates.lat(), coordinates.lon());
1309         } else {
1310             exif.removeGPSInfo();
1311         }
1312 
1313         // Fix the time drift if requested
1314         if (fixDrift) {
1315             const QDateTime originalTime = m_imagesModel->date(path);
1316             const QDateTime fixedTime = originalTime.addSecs(cameraClockDeviation);
1317             // If the Digitization time is equal to the original time, update it as well.
1318             // Otherwise, only update the image's timestamp.
1319             exif.setImageDateTime(fixedTime, exif.getDigitizationDateTime() == originalTime);
1320         }
1321 
1322         // Save the changes
1323 
1324         exif.setMetadataWritingMode(writeMode);
1325 
1326         while (! exif.applyChanges()) {
1327             QString message = saveFailedHeader(processed, allImages);
1328 
1329             message.append(i18n("<p>Could not save metadata for <kbd>%1</kbd>.</p>", path));
1330 
1331             if (writeMode == KExiv2Iface::KExiv2::MetadataWritingMode::WRITETOSIDECARONLY) {
1332                 QFileInfo info(path);
1333                 message.append(i18n("<p>Please check if you have write access to <kbd>%1</kbd>!",
1334                                     info.dir().path()));
1335             } else {
1336                 message.append(i18n("<p>Please check if this file still exists and if you have "
1337                                     "write access to it!</p>"));
1338             }
1339 
1340             message.append(skipRetryCancelText(processed, allImages));
1341 
1342             progress.reset();
1343             QApplication::restoreOverrideCursor();
1344 
1345             RetrySkipAbortDialog dialog(this, i18n("Save changes"), message, isSingleFile);
1346 
1347             const auto reply = dialog.exec();
1348             if (reply == RetrySkipAbortDialog::Skip) {
1349                 skipImage = true;
1350                 break;
1351             } else if (reply == RetrySkipAbortDialog::Abort) {
1352                 abortWrite = true;
1353                 break;
1354             }
1355 
1356             QApplication::setOverrideCursor(Qt::WaitCursor);
1357         }
1358 
1359         if (skipImage) {
1360             skipImage = false;
1361             continue;
1362         }
1363         if (abortWrite) {
1364             break;
1365         }
1366 
1367         m_imagesModel->setSaved(path);
1368 
1369         savedImages++;
1370     }
1371 
1372     progress.reset();
1373     QApplication::restoreOverrideCursor();
1374 
1375     if (savedImages == 0) {
1376         QMessageBox::warning(this, i18n("Save changes"),
1377                              i18n("No changes could be saved!"));
1378     } else if (savedImages < allImages) {
1379         QMessageBox::warning(this, i18n("Save changes"),
1380                              i18n("<p>Some changes could not be saved!</p>"
1381                                   "<p>Successfully saved %1 of %2 images.</p>",
1382                                   savedImages, allImages));
1383     } else {
1384         QMessageBox::information(this, i18n("Save changes"),
1385                                  i18n("All changes have been successfully saved!"));
1386     }
1387 }
1388 
showSettings()1389 void MainWindow::showSettings()
1390 {
1391     auto *dialog = new SettingsDialog(m_settings, this);
1392     connect(dialog, &SettingsDialog::imagesListsModeChanged,
1393             this, &MainWindow::updateImagesListsMode);
1394 
1395     if (! dialog->exec()) {
1396         return;
1397     }
1398 
1399     m_mapWidget->updateSettings();
1400 }
1401 
removeCoordinates(ImagesListView * list)1402 void MainWindow::removeCoordinates(ImagesListView *list)
1403 {
1404     removeCoordinates(list->selectedPaths());
1405 }
1406 
removeCoordinates(const QVector<QString> & paths)1407 void MainWindow::removeCoordinates(const QVector<QString> &paths)
1408 {
1409     for (const QString &path : paths) {
1410         m_imagesModel->setCoordinates(path, Coordinates(), KGeoTag::NotMatched);
1411     }
1412 
1413     m_mapWidget->reloadMap();
1414     m_previewWidget->setImage();
1415 }
1416 
discardChanges(ImagesListView * list)1417 void MainWindow::discardChanges(ImagesListView *list)
1418 {
1419     const auto paths = list->selectedPaths();
1420     for (const auto &path : paths) {
1421         m_imagesModel->resetChanges(path);
1422     }
1423 
1424     m_mapWidget->reloadMap();
1425     m_previewWidget->setImage();
1426 }
1427 
checkUpdatePreview(const QVector<QString> & paths)1428 void MainWindow::checkUpdatePreview(const QVector<QString> &paths)
1429 {
1430     for (const QString &path : paths) {
1431         if (m_previewWidget->currentImage() == path) {
1432             m_previewWidget->setImage(m_imagesModel->indexFor(path));
1433             break;
1434         }
1435     }
1436 }
1437 
elevationLookupFailed(const QString & errorMessage)1438 void MainWindow::elevationLookupFailed(const QString &errorMessage)
1439 {
1440     QApplication::restoreOverrideCursor();
1441 
1442     QMessageBox::warning(this, i18n("Elevation lookup"),
1443         i18n("<p>Fetching elevation data from opentopodata.org failed.</p>"
1444              "<p>The error message was: %1</p>", errorMessage));
1445 }
1446 
notAllElevationsPresent(int locationsCount,int elevationsCount)1447 void MainWindow::notAllElevationsPresent(int locationsCount, int elevationsCount)
1448 {
1449     QString message;
1450     if (locationsCount == 1) {
1451         message = i18n("Fetching elevation data failed: The requested location is not present in "
1452                        "the currently chosen elevation dataset.");
1453     } else {
1454         if (elevationsCount == 0) {
1455             message = i18n("Fetching elevation data failed: None of the requested locations are "
1456                            "present in the currently chosen elevation dataset.");
1457         } else {
1458             message = i18n("Fetching elevation data is incomplete: Some of the requested locations "
1459                            "are not present in the currently chosen elevation dataset.");
1460         }
1461     }
1462 
1463     QMessageBox::warning(this, i18n("Elevation lookup"), message);
1464 }
1465 
lookupElevation(ImagesListView * list)1466 void MainWindow::lookupElevation(ImagesListView *list)
1467 {
1468     lookupElevation(list->selectedPaths());
1469 }
1470 
lookupElevation(const QVector<QString> & paths)1471 void MainWindow::lookupElevation(const QVector<QString> &paths)
1472 {
1473     QApplication::setOverrideCursor(Qt::BusyCursor);
1474 
1475     QVector<Coordinates> coordinates;
1476     for (const auto &path : paths) {
1477         coordinates.append(m_imagesModel->coordinates(path));
1478     }
1479 
1480     m_elevationEngine->request(ElevationEngine::Target::Image, paths, coordinates);
1481 }
1482 
elevationProcessed(ElevationEngine::Target target,const QVector<QString> & paths,const QVector<double> & elevations)1483 void MainWindow::elevationProcessed(ElevationEngine::Target target, const QVector<QString> &paths,
1484                                     const QVector<double> &elevations)
1485 {
1486     if (target != ElevationEngine::Target::Image) {
1487         return;
1488     }
1489 
1490     for (int i = 0; i < paths.count(); i++) {
1491         const auto &path = paths.at(i);
1492         const auto &elevation = elevations.at(i);
1493         m_imagesModel->setElevation(path, elevation);
1494     }
1495 
1496     emit checkUpdatePreview(paths);
1497     QApplication::restoreOverrideCursor();
1498 }
1499 
imagesTimeZoneChanged()1500 void MainWindow::imagesTimeZoneChanged()
1501 {
1502     QApplication::setOverrideCursor(Qt::WaitCursor);
1503     m_imagesModel->setImagesTimeZone(m_fixDriftWidget->imagesTimeZoneId());
1504     m_previewWidget->reload();
1505     QApplication::restoreOverrideCursor();
1506 }
1507 
cameraDriftSettingsChanged()1508 void MainWindow::cameraDriftSettingsChanged()
1509 {
1510     m_previewWidget->setCameraClockDeviation(
1511         m_fixDriftWidget->displayFixed() ? m_fixDriftWidget->cameraClockDeviation() : 0);
1512 }
1513 
removeImages(ImagesListView * list)1514 void MainWindow::removeImages(ImagesListView *list)
1515 {
1516     const auto paths = list->selectedPaths();
1517     const auto count = paths.count();
1518 
1519     bool pendingChanges = false;
1520     for (const auto &path : paths) {
1521         if (m_imagesModel->hasPendingChanges(path)) {
1522             pendingChanges = true;
1523             break;
1524         }
1525     }
1526 
1527     if (pendingChanges) {
1528         if (QMessageBox::question(this, i18np("Remove image", "Remove images", count),
1529                 i18np("The image has pending changes! Do you really want to remove it and discard "
1530                       "the changes?",
1531                       "At least one of the images has pending changes. Do you really want to "
1532                       "remove them and discard all changes?",
1533                       count),
1534                 QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) {
1535 
1536             return;
1537         }
1538     }
1539 
1540     m_imagesModel->removeImages(paths);
1541     m_mapWidget->reloadMap();
1542     m_previewWidget->setImage();
1543 }
1544 
removeProcessedSavedImages()1545 void MainWindow::removeProcessedSavedImages()
1546 {
1547     const auto paths = m_imagesModel->processedSavedImages();
1548     if (paths.isEmpty()) {
1549         QMessageBox::information(this, i18n("Remove all processed and saved images"),
1550             i18n("Nothing to do"));
1551         return;
1552     }
1553 
1554     m_imagesModel->removeImages(paths);
1555     m_mapWidget->reloadMap();
1556     m_previewWidget->setImage();
1557     QMessageBox::information(this, i18n("Remove all processed and saved images"),
1558         i18np("Removed one image", "Removed %1 images", paths.count()));
1559 }
1560 
removeImagesLoadedTagged()1561 void MainWindow::removeImagesLoadedTagged()
1562 {
1563     const auto paths = m_imagesModel->imagesLoadedTagged();
1564     if (paths.isEmpty()) {
1565         QMessageBox::information(this, i18n("Remove images that already had coordinates"),
1566             i18n("Nothing to do"));
1567         return;
1568     }
1569 
1570     m_imagesModel->removeImages(paths);
1571     m_mapWidget->reloadMap();
1572     m_previewWidget->setImage();
1573     QMessageBox::information(this, i18n("Remove images that already had coordinates"),
1574         i18np("Removed one image", "Removed %1 images", paths.count()));
1575 }
1576 
checkForPendingChanges()1577 bool MainWindow::checkForPendingChanges()
1578 {
1579     if (! m_imagesModel->imagesWithPendingChanges().isEmpty()
1580         && QMessageBox::question(this, i18n("Remove all images"),
1581                i18n("<p>There are pending changes to images that haven't been saved yet. All "
1582                     "changes will be discarded if all images are removed now.</p>"
1583                     "<p>Do you really want to remove all images anyway?</p>"),
1584                QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) {
1585 
1586         return false;
1587     }
1588 
1589     return true;
1590 }
1591 
removeAllImages()1592 void MainWindow::removeAllImages()
1593 {
1594     if (m_imagesModel->rowCount() == 0) {
1595         QMessageBox::information(this, i18n("Remove all images"), i18n("Nothing to do"));
1596         return;
1597     }
1598 
1599     if (! checkForPendingChanges()) {
1600         return;
1601     }
1602 
1603     m_imagesModel->removeAllImages();
1604     m_mapWidget->reloadMap();
1605     m_previewWidget->setImage();
1606 }
1607 
removeTracks()1608 void MainWindow::removeTracks()
1609 {
1610     m_tracksView->blockSignals(true);
1611     const auto allRows = m_tracksView->selectedTracks();
1612     for (int row : allRows) {
1613         m_geoDataModel->removeTrack(row);
1614     }
1615     m_tracksView->blockSignals(false);
1616     m_mapWidget->reloadMap();
1617 }
1618 
removeAllTracks()1619 void MainWindow::removeAllTracks()
1620 {
1621     m_tracksView->blockSignals(true);
1622     m_geoDataModel->removeAllTracks();
1623     m_tracksView->blockSignals(false);
1624     m_mapWidget->reloadMap();
1625 }
1626 
removeEverything()1627 void MainWindow::removeEverything()
1628 {
1629     if (m_imagesModel->rowCount() == 0 && m_geoDataModel->rowCount() == 0) {
1630         QMessageBox::information(this, i18n("Remove all images and tracks (reset)"),
1631                                  i18n("Nothing to do"));
1632         return;
1633     }
1634 
1635     if (! checkForPendingChanges()) {
1636         return;
1637     }
1638 
1639     m_imagesModel->removeAllImages();
1640     m_previewWidget->setImage();
1641     removeAllTracks();
1642 }
1643 
centerTrackPoint(int trackIndex,int trackPointIndex)1644 void MainWindow::centerTrackPoint(int trackIndex, int trackPointIndex)
1645 {
1646     const auto dateTime = m_geoDataModel->dateTimes().at(trackIndex).at(trackPointIndex)
1647                               .toTimeZone(m_fixDriftWidget->imagesTimeZone());
1648     const auto coordinates = m_geoDataModel->trackPoints().at(trackIndex).value(dateTime);
1649     m_mapWidget->blockSignals(true);
1650     m_mapWidget->centerCoordinates(coordinates);
1651     m_mapCenterInfo->trackPointCentered(coordinates, dateTime);
1652     m_mapWidget->blockSignals(false);
1653 }
1654