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