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