diff --git a/AnnotationDialog/Dialog.cpp b/AnnotationDialog/Dialog.cpp index 2ba7903b..3dc1bc25 100644 --- a/AnnotationDialog/Dialog.cpp +++ b/AnnotationDialog/Dialog.cpp @@ -1,1753 +1,1751 @@ -/* Copyright (C) 2003-2018 Jesper K. Pedersen +/* Copyright (C) 2003-2019 The KPhotoAlbum Development Team This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; see the file COPYING. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "Dialog.h" #include "DescriptionEdit.h" #include "enums.h" #include "ImagePreviewWidget.h" #include "DateEdit.h" #include "ListSelect.h" #include "Logging.h" #include "ResizableFrame.h" #include "ShortCutManager.h" #include "ShowSelectionOnlyManager.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_KGEOMAP #include #include #include #endif #include #include #include #include #include using Utilities::StringSet; /** * \class AnnotationDialog::Dialog * \brief QDialog subclass used for tagging images */ AnnotationDialog::Dialog::Dialog( QWidget* parent ) : QDialog( parent ) , m_ratingChanged( false ) , m_conflictText( i18n("(You have differing descriptions on individual images, setting text here will override them all)" ) ) { Utilities::ShowBusyCursor dummy; ShortCutManager shortCutManager; // The widget stack QWidget *mainWidget = new QWidget(this); QVBoxLayout* layout = new QVBoxLayout(mainWidget); setLayout(layout); layout->addWidget(mainWidget); m_stack = new QStackedWidget(mainWidget); layout->addWidget( m_stack ); // The Viewer m_fullScreenPreview = new Viewer::ViewerWidget( Viewer::ViewerWidget::InlineViewer ); m_stack->addWidget( m_fullScreenPreview ); // The dock widget m_dockWindow = new QMainWindow; m_stack->addWidget( m_dockWindow ); m_dockWindow->setDockNestingEnabled( true ); // -------------------------------------------------- Dock widgets m_generalDock = createDock( i18n("Label and Dates"), QString::fromLatin1("Label and Dates"), Qt::TopDockWidgetArea, createDateWidget(shortCutManager) ); m_previewDock = createDock( i18n("Image Preview"), QString::fromLatin1("Image Preview"), Qt::TopDockWidgetArea, createPreviewWidget() ); m_description = new DescriptionEdit(this); m_description->setProperty( "WantsFocus", true ); m_description->setObjectName( i18n("Description") ); m_description->setCheckSpellingEnabled( true ); m_description->setTabChangesFocus( true ); // this allows tabbing to the next item in the tab order. m_description->setWhatsThis( i18nc( "@info:whatsthis", "A descriptive text of the image." "If Use Exif description is enabled under " "Settings|Configure KPhotoAlbum...|General, a description " "embedded in the image Exif information is imported to this field if available." )); m_descriptionDock = createDock( i18n("Description"), QString::fromLatin1("description"), Qt::LeftDockWidgetArea, m_description ); shortCutManager.addDock( m_descriptionDock, m_description ); connect( m_description, SIGNAL(pageUpDownPressed(QKeyEvent*)), this, SLOT(descriptionPageUpDownPressed(QKeyEvent*)) ); #ifdef HAVE_KGEOMAP // -------------------------------------------------- Map representation m_annotationMapContainer = new QWidget(this); QVBoxLayout *annotationMapContainerLayout = new QVBoxLayout(m_annotationMapContainer); m_annotationMap = new Map::MapView(this); annotationMapContainerLayout->addWidget(m_annotationMap); QHBoxLayout *mapLoadingProgressLayout = new QHBoxLayout(); annotationMapContainerLayout->addLayout(mapLoadingProgressLayout); m_mapLoadingProgress = new QProgressBar(this); mapLoadingProgressLayout->addWidget(m_mapLoadingProgress); m_mapLoadingProgress->hide(); m_cancelMapLoadingButton = new QPushButton(i18n("Cancel")); mapLoadingProgressLayout->addWidget(m_cancelMapLoadingButton); m_cancelMapLoadingButton->hide(); connect(m_cancelMapLoadingButton, SIGNAL(clicked()), this, SLOT(setCancelMapLoading())); m_annotationMapContainer->setObjectName(i18n("Map")); m_mapDock = createDock( i18n("Map"), QString::fromLatin1("map"), Qt::LeftDockWidgetArea, m_annotationMapContainer ); shortCutManager.addDock(m_mapDock, m_annotationMapContainer); connect(m_mapDock, SIGNAL(visibilityChanged(bool)), this, SLOT(annotationMapVisibilityChanged(bool))); m_mapDock->setWhatsThis( i18nc( "@info:whatsthis", "The map widget allows you to view the location of images if GPS coordinates are found in the Exif information." )); #endif // -------------------------------------------------- Categories QList categories = DB::ImageDB::instance()->categoryCollection()->categories(); // Let's first assume we don't have positionable categories m_positionableCategories = false; for( QList::ConstIterator categoryIt = categories.constBegin(); categoryIt != categories.constEnd(); ++categoryIt ) { ListSelect* sel = createListSel( *categoryIt ); // Create a QMap of all ListSelect instances, so that we can easily // check if a specific (positioned) tag is (still) selected later m_listSelectList[(*categoryIt)->name()] = sel; QDockWidget* dock = createDock((*categoryIt)->name(), (*categoryIt)->name(), Qt::BottomDockWidgetArea, sel); shortCutManager.addDock( dock, sel->lineEdit() ); if ( (*categoryIt)->isSpecialCategory() ) dock->hide(); // Pass the positionable selection to the object sel->setPositionable( (*categoryIt)->positionable() ); if ( sel->positionable() ) { connect( sel, SIGNAL(positionableTagSelected(QString,QString)), this, SLOT(positionableTagSelected(QString,QString)) ); connect( sel, SIGNAL(positionableTagDeselected(QString,QString)), this, SLOT(positionableTagDeselected(QString,QString)) ); connect( sel, SIGNAL(positionableTagRenamed(QString,QString,QString)), this, SLOT(positionableTagRenamed(QString,QString,QString)) ); connect(m_preview->preview(), SIGNAL(proposedTagSelected(QString,QString)), sel, SLOT(ensureTagIsSelected(QString,QString))); // We have at least one positionable category m_positionableCategories = true; } } // -------------------------------------------------- The buttons. // don't use default buttons (Ok, Cancel): QDialogButtonBox *buttonBox = new QDialogButtonBox(QDialogButtonBox::NoButton); connect(buttonBox, SIGNAL(accepted()), this, SLOT(accept())); connect(buttonBox, SIGNAL(rejected()), this, SLOT(reject())); QHBoxLayout* lay1 = new QHBoxLayout; layout->addLayout( lay1 ); m_revertBut = new QPushButton( i18n("Revert This Item") ); KAcceleratorManager::setNoAccel(m_revertBut); lay1->addWidget( m_revertBut ); m_clearBut = new QPushButton(); KGuiItem::assign(m_clearBut, KGuiItem( i18n("Clear Form"),QApplication::isRightToLeft() ? QString::fromLatin1("clear_left") : QString::fromLatin1("locationbar_erase")) ) ; KAcceleratorManager::setNoAccel(m_clearBut); lay1->addWidget( m_clearBut ); QPushButton* optionsBut = new QPushButton( i18n("Options..." ) ); KAcceleratorManager::setNoAccel(optionsBut); lay1->addWidget( optionsBut ); lay1->addStretch(1); m_okBut = new QPushButton( i18n("&Done") ); lay1->addWidget( m_okBut ); m_continueLaterBut = new QPushButton( i18n("Continue &Later") ); lay1->addWidget( m_continueLaterBut ); QPushButton* cancelBut = new QPushButton(); KGuiItem::assign( cancelBut, KStandardGuiItem::cancel() ); lay1->addWidget( cancelBut ); // It is unfortunately not possible to ask KAcceleratorManager not to setup the OK and cancel keys. shortCutManager.addTaken( i18nc("@action:button","&Search") ); shortCutManager.addTaken( m_okBut->text() ); shortCutManager.addTaken( m_continueLaterBut->text()); shortCutManager.addTaken( cancelBut->text() ); connect( m_revertBut, SIGNAL(clicked()), this, SLOT(slotRevert()) ); connect( m_okBut, SIGNAL(clicked()), this, SLOT(doneTagging()) ); connect( m_continueLaterBut, SIGNAL(clicked()), this, SLOT(continueLater()) ); connect( cancelBut, SIGNAL(clicked()), this, SLOT(reject()) ); connect( m_clearBut, SIGNAL(clicked()), this, SLOT(slotClear()) ); connect( optionsBut, SIGNAL(clicked()), this, SLOT(slotOptions()) ); connect( m_preview, SIGNAL(imageRotated(int)), this, SLOT(rotate(int)) ); connect( m_preview, SIGNAL(indexChanged(int)), this, SLOT(slotIndexChanged(int)) ); connect( m_preview, SIGNAL(imageDeleted(DB::ImageInfo)), this, SLOT(slotDeleteImage()) ); connect( m_preview, SIGNAL(copyPrevClicked()), this, SLOT(slotCopyPrevious()) ); connect( m_preview, SIGNAL(areaVisibilityChanged(bool)), this, SLOT(slotShowAreas(bool)) ); connect( m_preview->preview(), SIGNAL(areaCreated(ResizableFrame*)), this, SLOT(slotNewArea(ResizableFrame*)) ); // Disable so no button accept return (which would break with the line edits) m_revertBut->setAutoDefault( false ); m_okBut->setAutoDefault( false ); m_continueLaterBut->setAutoDefault( false ); cancelBut->setAutoDefault( false ); m_clearBut->setAutoDefault( false ); optionsBut->setAutoDefault( false ); m_dockWindowCleanState = m_dockWindow->saveState(); loadWindowLayout(); m_current = -1; setGeometry( Settings::SettingsData::instance()->windowGeometry( Settings::AnnotationDialog ) ); setupActions(); shortCutManager.setupShortCuts(); // WARNING layout->addWidget(buttonBox) must be last item in layout layout->addWidget(buttonBox); } QDockWidget* AnnotationDialog::Dialog::createDock( const QString& title, const QString& name, Qt::DockWidgetArea location, QWidget* widget ) { QDockWidget* dock = new QDockWidget( title ); KAcceleratorManager::setNoAccel(dock); dock->setObjectName( name ); dock->setAllowedAreas( Qt::AllDockWidgetAreas ); dock->setWidget( widget ); m_dockWindow->addDockWidget( location, dock ); m_dockWidgets.append( dock ); return dock; } QWidget* AnnotationDialog::Dialog::createDateWidget(ShortCutManager& shortCutManager) { QWidget* top = new QWidget; QVBoxLayout* lay2 = new QVBoxLayout( top ); // Image Label QHBoxLayout* lay3 = new QHBoxLayout; lay2->addLayout( lay3 ); QLabel* label = new QLabel( i18n("Label: " ) ); lay3->addWidget( label ); m_imageLabel = new KLineEdit; m_imageLabel->setProperty( "WantsFocus", true ); m_imageLabel->setObjectName( i18n("Label") ); lay3->addWidget( m_imageLabel ); shortCutManager.addLabel( label ); label->setBuddy( m_imageLabel ); // Date QHBoxLayout* lay4 = new QHBoxLayout; lay2->addLayout( lay4 ); label = new QLabel( i18n("Date: ") ); lay4->addWidget( label ); m_startDate = new ::AnnotationDialog::DateEdit( true ); lay4->addWidget( m_startDate, 1 ); connect( m_startDate, SIGNAL(dateChanged(DB::ImageDate)), this, SLOT(slotStartDateChanged(DB::ImageDate)) ); shortCutManager.addLabel(label ); label->setBuddy( m_startDate); m_endDateLabel = new QLabel( QString::fromLatin1( "-" ) ); lay4->addWidget( m_endDateLabel ); m_endDate = new ::AnnotationDialog::DateEdit( false ); lay4->addWidget( m_endDate, 1 ); // Time m_timeLabel = new QLabel( i18n("Time: ") ); lay4->addWidget( m_timeLabel ); m_time= new QTimeEdit; lay4->addWidget( m_time ); m_isFuzzyDate = new QCheckBox( i18n("Use Fuzzy Date") ); m_isFuzzyDate->setWhatsThis( i18nc("@info", "In KPhotoAlbum, images can either have an exact date and time" ", or a fuzzy date which happened any time during" " a specified time interval. Images produced by digital cameras" " do normally have an exact date." "If you don't know exactly when a photo was taken" " (e.g. if the photo comes from an analog camera), then you should set" " Use Fuzzy Date.") ); m_isFuzzyDate->setToolTip( m_isFuzzyDate->whatsThis() ); lay4->addWidget( m_isFuzzyDate ); lay4->addStretch(1); connect(m_isFuzzyDate,SIGNAL(stateChanged(int)),this,SLOT(slotSetFuzzyDate())); QHBoxLayout* lay8 = new QHBoxLayout; lay2->addLayout( lay8 ); m_megapixelLabel = new QLabel( i18n("Minimum megapixels:") ); lay8->addWidget( m_megapixelLabel ); m_megapixel = new QSpinBox; m_megapixel->setRange( 0, 99 ); m_megapixel->setSingleStep( 1 ); m_megapixelLabel->setBuddy( m_megapixel ); lay8->addWidget( m_megapixel ); lay8->addStretch( 1 ); m_max_megapixelLabel = new QLabel( i18n("Maximum megapixels:") ); lay8->addWidget( m_max_megapixelLabel ); m_max_megapixel = new QSpinBox; m_max_megapixel->setRange( 0, 99 ); m_max_megapixel->setSingleStep( 1 ); m_max_megapixelLabel->setBuddy( m_max_megapixel ); lay8->addWidget( m_max_megapixel ); lay8->addStretch( 1 ); QHBoxLayout* lay9 = new QHBoxLayout; lay2->addLayout( lay9 ); label = new QLabel( i18n("Rating:") ); lay9->addWidget( label ); m_rating = new KRatingWidget; m_rating->setSizePolicy( QSizePolicy::Fixed, QSizePolicy::Fixed ); lay9->addWidget( m_rating, 0, Qt::AlignCenter ); connect( m_rating, SIGNAL(ratingChanged(uint)), this, SLOT(slotRatingChanged(uint)) ); m_ratingSearchLabel = new QLabel( i18n("Rating search mode:") ); lay9->addWidget( m_ratingSearchLabel ); m_ratingSearchMode = new KComboBox( lay9 ); m_ratingSearchMode->addItems( QStringList() << i18n("==") << i18n(">=") << i18n("<=") << i18n("!=") ); m_ratingSearchLabel->setBuddy( m_ratingSearchMode ); lay9->addWidget( m_ratingSearchMode ); // File name search pattern QHBoxLayout* lay10 = new QHBoxLayout; lay2->addLayout( lay10 ); m_imageFilePatternLabel = new QLabel( i18n("File Name Pattern: " ) ); lay10->addWidget( m_imageFilePatternLabel ); m_imageFilePattern = new KLineEdit; m_imageFilePattern->setObjectName( i18n("File Name Pattern") ); lay10->addWidget( m_imageFilePattern ); shortCutManager.addLabel( m_imageFilePatternLabel ); m_imageFilePatternLabel->setBuddy( m_imageFilePattern ); m_searchRAW = new QCheckBox( i18n("Search only for RAW files") ); lay2->addWidget( m_searchRAW ); lay9->addStretch( 1 ); lay2->addStretch(1); return top; } QWidget* AnnotationDialog::Dialog::createPreviewWidget() { m_preview = new ImagePreviewWidget(); connect(m_preview, &ImagePreviewWidget::togglePreview, this, &Dialog::togglePreview); return m_preview; } void AnnotationDialog::Dialog::slotRevert() { if ( m_setup == InputSingleImageConfigMode ) load(); } void AnnotationDialog::Dialog::slotIndexChanged( int index ) { if ( m_setup != InputSingleImageConfigMode ) return; if(m_current >= 0 ) writeToInfo(); m_current = index; load(); } void AnnotationDialog::Dialog::doneTagging() { saveAndClose(); if ( Settings::SettingsData::instance()->hasUntaggedCategoryFeatureConfigured() ) { for( DB::ImageInfoListIterator it = m_origList.begin(); it != m_origList.end(); ++it ) { (*it)->removeCategoryInfo( Settings::SettingsData::instance()->untaggedCategory(), Settings::SettingsData::instance()->untaggedTag() ); } } } /* * Copy tags (only tags/categories, not description/label/...) from previous image to the currently showed one */ void AnnotationDialog::Dialog::slotCopyPrevious() { if ( m_setup != InputSingleImageConfigMode ) return; if ( m_current < 1 ) return; // FIXME: it would be better to compute the "previous image" in a better way, but let's stick with this for now... DB::ImageInfo& old_info = m_editList[ m_current - 1 ]; m_positionableTagCandidates.clear(); m_lastSelectedPositionableTag.first = QString(); m_lastSelectedPositionableTag.second = QString(); Q_FOREACH( ListSelect *ls, m_optionList ) { ls->setSelection( old_info.itemsOfCategory( ls->category() ) ); // Also set all positionable tag candidates if ( ls->positionable() ) { QString category = ls->category(); QSet selectedTags = old_info.itemsOfCategory( category ); QSet positionedTagSet = positionedTags( category ); // Add the tag to the positionable candiate list, if no area is already associated with it Q_FOREACH(const auto &tag, selectedTags) { if (!positionedTagSet.contains(tag)) { addTagToCandidateList(category, tag); } } // Check all areas for a linked tag in this category that is probably not selected anymore for(ResizableFrame *area : areas()) { QPair tagData = area->tagData(); if (tagData.first == category) { if (! selectedTags.contains(tagData.second)) { // The linked tag is not selected anymore, so remove it area->removeTagData(); } } } } } } void AnnotationDialog::Dialog::load() { // Remove all areas tidyAreas(); // No areas have been changed m_areasChanged = false; // Empty the positionable tag candidate list and the last selected positionable tag m_positionableTagCandidates.clear(); m_lastSelectedPositionableTag = QPair(); DB::ImageInfo& info = m_editList[ m_current ]; m_startDate->setDate( info.date().start().date() ); if( info.date().hasValidTime() ) { m_time->show(); m_time->setTime( info.date().start().time()); m_isFuzzyDate->setChecked(false); } else { m_time->hide(); m_isFuzzyDate->setChecked(true); } if ( info.date().start().date() == info.date().end().date() ) m_endDate->setDate( QDate() ); else m_endDate->setDate( info.date().end().date() ); m_imageLabel->setText( info.label() ); m_description->setPlainText( info.description() ); if ( m_setup == InputSingleImageConfigMode ) m_rating->setRating( qMax( static_cast(0), info.rating() ) ); m_ratingChanged = false; // A category areas have been linked against could have been deleted // or un-marked as positionable in the meantime, so ... QMap categoryIsPositionable; QList positionableCategories; Q_FOREACH( ListSelect *ls, m_optionList ) { ls->setSelection( info.itemsOfCategory( ls->category() ) ); ls->rePopulate(); // Get all selected positionable tags and add them to the candidate list if (ls->positionable()) { QSet selectedTags = ls->itemsOn(); Q_FOREACH( const QString &tagName, selectedTags ) { addTagToCandidateList( ls->category(), tagName ); } } // ... create a list of all categories and their positionability ... categoryIsPositionable[ls->category()] = ls->positionable(); if (ls->positionable()) { positionableCategories << ls->category(); } } // Create all tagged areas QMap> taggedAreas = info.taggedAreas(); QMapIterator> areasInCategory(taggedAreas); while (areasInCategory.hasNext()) { areasInCategory.next(); QString category = areasInCategory.key(); // ... and check if the respective category is actually there yet and still positionable // (operator[] will insert an empty item if the category has been deleted // and is thus missing in the QMap, but the respective key won't be true) if (categoryIsPositionable[category]) { QMapIterator areaData(areasInCategory.value()); while (areaData.hasNext()) { areaData.next(); QString tag = areaData.key(); // Be sure that the corresponding tag is still checked. The category could have // been un-marked as positionable in the meantime and the tag could have been // deselected, without triggering positionableTagDeselected and the area thus // still remaining. If the category is then re-marked as positionable, the area would // show up without the tag being selected. if(m_listSelectList[category]->tagIsChecked(tag)) { m_preview->preview()->createTaggedArea(category, tag, areaData.value(), m_preview->showAreas()); } } } } if (m_setup == InputSingleImageConfigMode) { setWindowTitle(i18nc("@title:window image %1 of %2 images", "Annotations (%1/%2)", m_current + 1, m_origList.count())); m_preview->canCreateAreas( m_setup == InputSingleImageConfigMode && ! info.isVideo() && m_positionableCategories ); #ifdef HAVE_KGEOMAP updateMapForCurrentImage(); #endif } m_preview->updatePositionableCategories(positionableCategories); } void AnnotationDialog::Dialog::writeToInfo() { Q_FOREACH( ListSelect *ls, m_optionList ) { ls->slotReturn(); } DB::ImageInfo& info = m_editList[ m_current ]; if (! info.size().isValid()) { // The actual image size has been fetched by ImagePreview, so we can add it to // the database silenty, so that it's saved if the database will be saved. info.setSize(m_preview->preview()->getActualImageSize()); } if ( m_time->isHidden() ) { if ( m_endDate->date().isValid() ) info.setDate( DB::ImageDate( QDateTime( m_startDate->date(), QTime(0,0,0) ), QDateTime( m_endDate->date(), QTime( 23,59,59) ) ) ); else info.setDate( DB::ImageDate( QDateTime( m_startDate->date(), QTime(0,0,0) ), QDateTime( m_startDate->date(), QTime( 23,59,59) ) ) ); } else info.setDate( DB::ImageDate( QDateTime( m_startDate->date(), m_time->time() ) ) ); // Generate a list of all tagged areas QMap> taggedAreas; QPair tagData; foreach (ResizableFrame *area, areas()) { tagData = area->tagData(); if ( !tagData.first.isEmpty() ) { taggedAreas[tagData.first][tagData.second] = area->actualCoordinates(); } } info.setLabel( m_imageLabel->text() ); info.setDescription( m_description->toPlainText() ); Q_FOREACH( ListSelect *ls, m_optionList ) { info.setCategoryInfo( ls->category(), ls->itemsOn() ); if (ls->positionable()) { info.setPositionedTags(ls->category(), taggedAreas[ls->category()]); } } if ( m_ratingChanged ) { info.setRating( m_rating->rating() ); m_ratingChanged = false; } } void AnnotationDialog::Dialog::ShowHideSearch( bool show ) { m_megapixel->setVisible( show ); m_megapixelLabel->setVisible( show ); m_max_megapixel->setVisible( show ); m_max_megapixelLabel->setVisible( show ); m_searchRAW->setVisible( show ); m_imageFilePatternLabel->setVisible( show ); m_imageFilePattern->setVisible( show ); m_isFuzzyDate->setChecked( show ); m_isFuzzyDate->setVisible( !show ); slotSetFuzzyDate(); m_ratingSearchMode->setVisible( show ); m_ratingSearchLabel->setVisible( show ); } QList AnnotationDialog::Dialog::areas() const { return m_preview->preview()->findChildren(); } int AnnotationDialog::Dialog::configure( DB::ImageInfoList list, bool oneAtATime ) { ShowHideSearch(false); if ( Settings::SettingsData::instance()->hasUntaggedCategoryFeatureConfigured() ) { DB::ImageDB::instance()->categoryCollection()->categoryForName( Settings::SettingsData::instance()->untaggedCategory() ) ->addItem(Settings::SettingsData::instance()->untaggedTag() ); } if (oneAtATime) { m_setup = InputSingleImageConfigMode; } else { m_setup = InputMultiImageConfigMode; // Hide the default positionable category selector m_preview->updatePositionableCategories(); } #ifdef HAVE_KGEOMAP m_mapIsPopulated = false; m_annotationMap->clear(); #endif m_origList = list; m_editList.clear(); for( DB::ImageInfoListConstIterator it = list.constBegin(); it != list.constEnd(); ++it ) { m_editList.append( *(*it) ); } setup(); if ( oneAtATime ) { m_current = 0; m_preview->configure( &m_editList, true ); load(); } else { m_preview->configure( &m_editList, false ); m_preview->canCreateAreas( false ); m_startDate->setDate( QDate() ); m_endDate->setDate( QDate() ); m_time->hide(); m_rating->setRating( 0 ); m_ratingChanged = false; m_areasChanged = false; Q_FOREACH( ListSelect *ls, m_optionList ) { setUpCategoryListBoxForMultiImageSelection( ls, list ); } m_imageLabel->setText(QString()); m_imageFilePattern->setText(QString()); m_firstDescription = m_editList[0].description(); const bool allTextEqual = std::all_of(m_editList.begin(), m_editList.end(), [=] (const DB::ImageInfo& item) -> bool { return item.description() == m_firstDescription; }); if ( !allTextEqual ) m_firstDescription = m_conflictText; m_description->setPlainText( m_firstDescription ); } showHelpDialog( oneAtATime ? InputSingleImageConfigMode : InputMultiImageConfigMode ); return exec(); } DB::ImageSearchInfo AnnotationDialog::Dialog::search( DB::ImageSearchInfo* search ) { ShowHideSearch(true); #ifdef HAVE_KGEOMAP m_mapIsPopulated = false; m_annotationMap->clear(); #endif m_setup = SearchMode; if ( search ) m_oldSearch = *search; setup(); m_preview->setImage(QStandardPaths::locate(QStandardPaths::DataLocation, QString::fromLatin1("pics/search.jpg"))); m_ratingChanged = false ; showHelpDialog( SearchMode ); int ok = exec(); if ( ok == QDialog::Accepted ) { const QDate start = m_startDate->date(); const QDate end = m_endDate->date(); m_oldSearch = DB::ImageSearchInfo( DB::ImageDate( start, end ), m_imageLabel->text(), m_description->toPlainText(), m_imageFilePattern->text()); Q_FOREACH( const ListSelect *ls, m_optionList ) { m_oldSearch.setCategoryMatchText( ls->category(), ls->text() ); } //FIXME: for the user to search for 0-rated images, he must first change the rating to anything > 0 //then change back to 0 . if( m_ratingChanged) m_oldSearch.setRating( m_rating->rating() ); m_ratingChanged = false; m_oldSearch.setSearchMode( m_ratingSearchMode->currentIndex() ); m_oldSearch.setMegaPixel( m_megapixel->value() ); m_oldSearch.setMaxMegaPixel( m_max_megapixel->value() ); m_oldSearch.setSearchRAW( m_searchRAW->isChecked() ); #ifdef HAVE_KGEOMAP const KGeoMap::GeoCoordinates::Pair regionSelection = m_annotationMap->getRegionSelection(); m_oldSearch.setRegionSelection(regionSelection); #endif return m_oldSearch; } else return DB::ImageSearchInfo(); } void AnnotationDialog::Dialog::setup() { // Repopulate the listboxes in case data has changed // An group might for example have been renamed. Q_FOREACH( ListSelect *ls, m_optionList ) { ls->populate(); } if ( m_setup == SearchMode ) { KGuiItem::assign(m_okBut, KGuiItem(i18nc("@action:button","&Search"), QString::fromLatin1("find")) ); m_continueLaterBut->hide(); m_revertBut->hide(); m_clearBut->show(); m_preview->setSearchMode(true); setWindowTitle( i18nc("@title:window title of the 'find images' window","Search") ); loadInfo( m_oldSearch ); } else { m_okBut->setText( i18n("Done") ); m_continueLaterBut->show(); m_revertBut->setEnabled( m_setup == InputSingleImageConfigMode ); m_clearBut->hide(); m_revertBut->show(); m_preview->setSearchMode(false); m_preview->setToggleFullscreenPreviewEnabled(m_setup == InputSingleImageConfigMode); setWindowTitle( i18nc("@title:window", "Annotations") ); } Q_FOREACH( ListSelect *ls, m_optionList ) { ls->setMode( m_setup ); } } void AnnotationDialog::Dialog::slotClear() { loadInfo( DB::ImageSearchInfo() ); } void AnnotationDialog::Dialog::loadInfo( const DB::ImageSearchInfo& info ) { m_startDate->setDate( info.date().start().date() ); m_endDate->setDate( info.date().end().date() ); Q_FOREACH( ListSelect *ls, m_optionList ) { ls->setText( info.categoryMatchText( ls->category() ) ); } m_imageLabel->setText( info.label() ); m_description->setText(info.description()); } void AnnotationDialog::Dialog::slotOptions() { // create menu entries for dock windows QMenu* menu = new QMenu( this ); QMenu* dockMenu =m_dockWindow->createPopupMenu(); menu->addMenu( dockMenu ) ->setText( i18n( "Configure Window Layout..." ) ); QAction* saveCurrent = dockMenu->addAction( i18n("Save Current Window Setup") ); QAction* reset = dockMenu->addAction( i18n( "Reset layout" ) ); // create SortType entries menu->addSeparator(); QActionGroup* sortTypes = new QActionGroup( menu ); QAction* alphaTreeSort = new QAction( SmallIcon( QString::fromLatin1( "view-list-tree" ) ), i18n("Sort Alphabetically (Tree)"), sortTypes ); QAction* alphaFlatSort = new QAction( SmallIcon( QString::fromLatin1( "draw-text" ) ), i18n("Sort Alphabetically (Flat)"), sortTypes ); QAction* dateSort = new QAction( SmallIcon( QString::fromLatin1( "x-office-calendar" ) ), i18n("Sort by Date"), sortTypes ); alphaTreeSort->setCheckable( true ); alphaFlatSort->setCheckable( true ); dateSort->setCheckable( true ); alphaTreeSort->setChecked( Settings::SettingsData::instance()->viewSortType() == Settings::SortAlphaTree ); alphaFlatSort->setChecked( Settings::SettingsData::instance()->viewSortType() == Settings::SortAlphaFlat ); dateSort->setChecked( Settings::SettingsData::instance()->viewSortType() == Settings::SortLastUse ); menu->addActions( sortTypes->actions() ); connect( dateSort, SIGNAL(triggered()), m_optionList.at(0), SLOT(slotSortDate()) ); connect( alphaTreeSort, SIGNAL(triggered()), m_optionList.at(0), SLOT(slotSortAlphaTree()) ); connect( alphaFlatSort, SIGNAL(triggered()), m_optionList.at(0), SLOT(slotSortAlphaFlat()) ); // create MatchType entries menu->addSeparator(); QActionGroup* matchTypes = new QActionGroup( menu ); QAction* matchFromBeginning = new QAction( i18n( "Match Tags from the First Character"), matchTypes ); QAction* matchFromWordStart = new QAction( i18n( "Match Tags from Word Boundaries"), matchTypes ); QAction* matchAnywhere = new QAction( i18n( "Match Tags Anywhere"), matchTypes ); matchFromBeginning->setCheckable( true ); matchFromWordStart->setCheckable( true ); matchAnywhere->setCheckable( true ); // TODO add StatusTip text? // set current state: matchFromBeginning->setChecked( Settings::SettingsData::instance()->matchType() == AnnotationDialog::MatchFromBeginning ); matchFromWordStart->setChecked( Settings::SettingsData::instance()->matchType() == AnnotationDialog::MatchFromWordStart ); matchAnywhere->setChecked( Settings::SettingsData::instance()->matchType() == AnnotationDialog::MatchAnywhere ); // add MatchType actions to menu: menu->addActions( matchTypes->actions() ); // create toggle-show-selected entry# if ( m_setup != SearchMode ) { menu->addSeparator(); QAction* showSelectedOnly = new QAction( SmallIcon( QString::fromLatin1( "view-filter" ) ), i18n("Show Only Selected Ctrl+S"), menu ); showSelectedOnly->setCheckable( true ); showSelectedOnly->setChecked( ShowSelectionOnlyManager::instance().selectionIsLimited() ); menu->addAction( showSelectedOnly ); connect( showSelectedOnly, SIGNAL(triggered()), &ShowSelectionOnlyManager::instance(), SLOT(toggle()) ); } // execute menu & handle response: QAction* res = menu->exec( QCursor::pos() ); if ( res == saveCurrent ) slotSaveWindowSetup(); else if ( res == reset ) slotResetLayout(); else if ( res == matchFromBeginning ) Settings::SettingsData::instance()->setMatchType( AnnotationDialog::MatchFromBeginning ); else if ( res == matchFromWordStart ) Settings::SettingsData::instance()->setMatchType( AnnotationDialog::MatchFromWordStart ); else if ( res == matchAnywhere ) Settings::SettingsData::instance()->setMatchType( AnnotationDialog::MatchAnywhere ); } int AnnotationDialog::Dialog::exec() { m_stack->setCurrentWidget( m_dockWindow ); showTornOfWindows(); this->setFocus(); // Set temporary focus before show() is called so that extra cursor is not shown on any "random" input widget show(); // We need to call show before we call setupFocus() otherwise the widget will not yet all have been moved in place. setupFocus(); const int ret = QDialog::exec(); hideTornOfWindows(); return ret; } void AnnotationDialog::Dialog::slotSaveWindowSetup() { const QByteArray data = m_dockWindow->saveState(); QFile file( QString::fromLatin1( "%1/layout.dat" ).arg( Settings::SettingsData::instance()->imageDirectory() ) ); if ( !file.open( QIODevice::WriteOnly ) ) { KMessageBox::sorry( this, i18n("

Could not save the window layout.

" "File %1 could not be opened because of the following error: %2" , file.fileName(), file.errorString() ) ); } else if ( ! ( file.write( data ) && file.flush() ) ) { KMessageBox::sorry( this, i18n("

Could not save the window layout.

" "File %1 could not be written because of the following error: %2" , file.fileName(), file.errorString() ) ); } file.close(); } void AnnotationDialog::Dialog::closeEvent( QCloseEvent* e ) { e->ignore(); reject(); } void AnnotationDialog::Dialog::hideTornOfWindows() { for( QDockWidget* dock : m_dockWidgets ) { if ( dock->isFloating() ) { qCDebug(AnnotationDialogLog) << "Hiding dock: " << dock->objectName(); dock->hide(); } } } void AnnotationDialog::Dialog::showTornOfWindows() { for (QDockWidget* dock: m_dockWidgets ) { if ( dock->isFloating() ) { qCDebug(AnnotationDialogLog) << "Showing dock: " << dock->objectName(); dock->show(); } } } AnnotationDialog::ListSelect* AnnotationDialog::Dialog::createListSel( const DB::CategoryPtr& category ) { ListSelect* sel = new ListSelect( category, m_dockWindow ); m_optionList.append( sel ); connect( DB::ImageDB::instance()->categoryCollection(), SIGNAL(itemRemoved(DB::Category*,QString)), this, SLOT(slotDeleteOption(DB::Category*,QString)) ); connect( DB::ImageDB::instance()->categoryCollection(), SIGNAL(itemRenamed(DB::Category*,QString,QString)), this, SLOT(slotRenameOption(DB::Category*,QString,QString)) ); return sel; } void AnnotationDialog::Dialog::slotDeleteOption( DB::Category* category, const QString& value ) { for( QList::Iterator it = m_editList.begin(); it != m_editList.end(); ++it ) { (*it).removeCategoryInfo( category->name(), value ); } } void AnnotationDialog::Dialog::slotRenameOption( DB::Category* category, const QString& oldValue, const QString& newValue ) { for( QList::Iterator it = m_editList.begin(); it != m_editList.end(); ++it ) { (*it).renameItem( category->name(), oldValue, newValue ); } } void AnnotationDialog::Dialog::reject() { if (m_stack->currentWidget() == m_fullScreenPreview) { togglePreview(); return; } m_fullScreenPreview->stopPlayback(); if (hasChanges()) { int code = KMessageBox::questionYesNo( this, i18n("

Some changes are made to annotations. Do you really want to cancel all recent changes for each affected file?

") ); if ( code == KMessageBox::No ) return; } closeDialog(); } void AnnotationDialog::Dialog::closeDialog() { tidyAreas(); m_accept = QDialog::Rejected; QDialog::reject(); } bool AnnotationDialog::Dialog::hasChanges() { bool changed = false; if ( m_setup == InputSingleImageConfigMode ) { writeToInfo(); for ( int i = 0; i < m_editList.count(); ++i ) { changed |= (*(m_origList[i]) != m_editList[i]); } changed |= m_areasChanged; } else if ( m_setup == InputMultiImageConfigMode ) { changed |= ( !m_startDate->date().isNull() ); changed |= ( !m_endDate->date().isNull() ); Q_FOREACH( ListSelect *ls, m_optionList ) { StringSet on, partialOn; std::tie(on, partialOn) = selectionForMultiSelect( ls, m_origList ); changed |= (on != ls->itemsOn()); changed |= (partialOn != ls->itemsUnchanged()); } changed |= ( !m_imageLabel->text().isEmpty() ); changed |= ( m_description->toPlainText() != m_firstDescription ); changed |= m_ratingChanged; } return changed; } void AnnotationDialog::Dialog::rotate( int angle ) { if ( m_setup == InputMultiImageConfigMode ) { // In doneTagging the preview will be queried for its angle. } else { DB::ImageInfo& info = m_editList[ m_current ]; info.rotate( angle, DB::RotateImageInfoOnly ); emit imageRotated( info.fileName() ); } } void AnnotationDialog::Dialog::slotSetFuzzyDate() { if ( m_isFuzzyDate->isChecked() ) { m_time->hide(); m_timeLabel->hide(); m_endDate->show(); m_endDateLabel->show(); } else { m_time->show(); m_timeLabel->show(); m_endDate->hide(); m_endDateLabel->hide(); } } void AnnotationDialog::Dialog::slotDeleteImage() { // CTRL+Del is a common key combination when editing text // TODO: The word right of cursor should be deleted as expected also in date and category fields if ( m_setup == SearchMode ) return; if( m_setup == InputMultiImageConfigMode ) //TODO: probably delete here should mean remove from selection return; DB::ImageInfoPtr info = m_origList[m_current]; m_origList.remove( info ); m_editList.removeAll( m_editList.at( m_current ) ); MainWindow::DirtyIndicator::markDirty(); if ( m_origList.count() == 0 ) { doneTagging(); return; } if ( m_current == (int)m_origList.count() ) // we deleted the last image m_current--; load(); } void AnnotationDialog::Dialog::showHelpDialog( UsageMode type ) { QString doNotShowKey; QString txt; if ( type == SearchMode ) { doNotShowKey = QString::fromLatin1( "image_config_search_show_help" ); txt = i18n( "

You have just opened the advanced search dialog; to get the most out of it, " "it is suggested that you read the section in the manual on " "advanced searching.

" "

This dialog is also used for typing in information about images; you can find " "extra tips on its usage by reading about " "typing in.

" ); } else { doNotShowKey = QString::fromLatin1( "image_config_typein_show_help" ); txt = i18n("

You have just opened one of the most important windows in KPhotoAlbum; " "it contains lots of functionality which has been optimized for fast usage.

" "

It is strongly recommended that you take 5 minutes to read the " "documentation for this " "dialog

" ); } KMessageBox::information( this, txt, QString(), doNotShowKey, KMessageBox::AllowLink ); } void AnnotationDialog::Dialog::resizeEvent( QResizeEvent* ) { Settings::SettingsData::instance()->setWindowGeometry( Settings::AnnotationDialog, geometry() ); } void AnnotationDialog::Dialog::moveEvent( QMoveEvent * ) { Settings::SettingsData::instance()->setWindowGeometry( Settings::AnnotationDialog, geometry() ); } void AnnotationDialog::Dialog::setupFocus() { QList list = findChildren(); QList orderedList; // Iterate through all widgets in our dialog. for ( QObject* obj : list ) { QWidget* current = static_cast( obj ); if ( !current->property("WantsFocus").isValid() || !current->isVisible() ) continue; int cx = current->mapToGlobal( QPoint(0,0) ).x(); int cy = current->mapToGlobal( QPoint(0,0) ).y(); bool inserted = false; // Iterate through the ordered list of widgets, and insert the current one, so it is in the right position in the tab chain. for( QList::iterator orderedIt = orderedList.begin(); orderedIt != orderedList.end(); ++orderedIt ) { const QWidget* w = *orderedIt; int wx = w->mapToGlobal( QPoint(0,0) ).x(); int wy = w->mapToGlobal( QPoint(0,0) ).y(); if ( wy > cy || ( wy == cy && wx >= cx ) ) { orderedList.insert( orderedIt, current ); inserted = true; break; } } if (!inserted) orderedList.append( current ); } // now setup tab order. QWidget* prev = nullptr; QWidget* first = nullptr; Q_FOREACH( QWidget *widget, orderedList ) { if ( prev ) { setTabOrder( prev, widget ); } else { first = widget; } prev = widget; } if ( first ) { setTabOrder( prev, first ); } // Finally set focus on the first list select Q_FOREACH( QWidget *widget, orderedList ) { if ( widget->property("FocusCandidate").isValid() && widget->isVisible() ) { widget->setFocus(); break; } } } void AnnotationDialog::Dialog::slotResetLayout() { m_dockWindow->restoreState(m_dockWindowCleanState); } void AnnotationDialog::Dialog::slotStartDateChanged( const DB::ImageDate& date ) { if ( date.start() == date.end() ) m_endDate->setDate( QDate() ); else m_endDate->setDate( date.end().date() ); } void AnnotationDialog::Dialog::loadWindowLayout() { QString fileName = QString::fromLatin1( "%1/layout.dat" ).arg( Settings::SettingsData::instance()->imageDirectory() ); if ( !QFileInfo(fileName).exists() ) { // create default layout // label/date/rating in a visual block with description: m_dockWindow->splitDockWidget(m_generalDock, m_descriptionDock, Qt::Vertical); // more space for description: m_dockWindow->resizeDocks({m_generalDock, m_descriptionDock},{60,100}, Qt::Vertical); // more space for preview: m_dockWindow->resizeDocks({m_generalDock, m_descriptionDock, m_previewDock},{200,200,800}, Qt::Horizontal); #ifdef HAVE_KGEOMAP // group the map with the preview m_dockWindow->tabifyDockWidget(m_previewDock, m_mapDock); // make sure the preview tab is active: m_previewDock->raise(); #endif return; } QFile file( fileName ); file.open( QIODevice::ReadOnly ); QByteArray data = file.readAll(); m_dockWindow->restoreState(data); } void AnnotationDialog::Dialog::setupActions() { m_actions = new KActionCollection( this ); QAction * action = nullptr; action = m_actions->addAction( QString::fromLatin1("annotationdialog-sort-alphatree"), m_optionList.at(0), SLOT(slotSortAlphaTree()) ); action->setText( i18n("Sort Alphabetically (Tree)") ); m_actions->setDefaultShortcut(action, Qt::CTRL+Qt::Key_F4); action = m_actions->addAction( QString::fromLatin1("annotationdialog-sort-alphaflat"), m_optionList.at(0), SLOT(slotSortAlphaFlat()) ); action->setText( i18n("Sort Alphabetically (Flat)") ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-sort-MRU"), m_optionList.at(0), SLOT(slotSortDate()) ); action->setText( i18n("Sort Most Recently Used") ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-toggle-sort"), m_optionList.at(0), SLOT(toggleSortType()) ); action->setText( i18n("Toggle Sorting") ); m_actions->setDefaultShortcut(action, Qt::CTRL+Qt::Key_T ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-toggle-showing-selected-only"), &ShowSelectionOnlyManager::instance(), SLOT(toggle()) ); action->setText( i18n("Toggle Showing Selected Items Only") ); m_actions->setDefaultShortcut(action, Qt::CTRL+Qt::Key_S ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-next-image"), m_preview, SLOT(slotNext()) ); action->setText( i18n("Annotate Next") ); m_actions->setDefaultShortcut(action, Qt::Key_PageDown ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-prev-image"), m_preview, SLOT(slotPrev()) ); action->setText( i18n("Annotate Previous") ); m_actions->setDefaultShortcut(action, Qt::Key_PageUp ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-OK-dialog"), this, SLOT(doneTagging()) ); action->setText( i18n("OK dialog") ); m_actions->setDefaultShortcut(action, Qt::CTRL+Qt::Key_Return ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-delete-image"), this, SLOT(slotDeleteImage()) ); action->setText( i18n("Delete") ); m_actions->setDefaultShortcut(action, Qt::CTRL+Qt::Key_Delete ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-copy-previous"), this, SLOT(slotCopyPrevious()) ); action->setText( i18n("Copy tags from previous image") ); m_actions->setDefaultShortcut(action, Qt::ALT+Qt::Key_Insert ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-rotate-left"), m_preview, SLOT(rotateLeft()) ); action->setText( i18n("Rotate counterclockwise") ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-rotate-right"), m_preview, SLOT(rotateRight()) ); action->setText( i18n("Rotate clockwise") ); action = m_actions->addAction( QString::fromLatin1("annotationdialog-toggle-viewer"), this, SLOT(togglePreview()) ); action->setText( i18n("Toggle fullscreen preview") ); m_actions->setDefaultShortcut(action, Qt::CTRL + Qt::Key_Space ); foreach (QAction* action, m_actions->actions()) { action->setShortcutContext(Qt::WindowShortcut); addAction(action); } // the annotation dialog is created when it's first used; // therefore, its actions are registered well after the MainWindow sets up its actionCollection, // and it has to read the shortcuts here, after they are set up: m_actions->readSettings(); } KActionCollection* AnnotationDialog::Dialog::actions() { return m_actions; } void AnnotationDialog::Dialog::setUpCategoryListBoxForMultiImageSelection( ListSelect* listSel, const DB::ImageInfoList& images ) { StringSet on, partialOn; std::tie(on,partialOn) = selectionForMultiSelect( listSel, images ); listSel->setSelection( on, partialOn ); } std::tuple AnnotationDialog::Dialog::selectionForMultiSelect( ListSelect* listSel, const DB::ImageInfoList& images ) { const QString category = listSel->category(); const StringSet allItems = DB::ImageDB::instance()->categoryCollection()->categoryForName( category )->itemsInclCategories().toSet(); StringSet itemsNotSelectedOnAllImages; StringSet itemsOnSomeImages; for ( DB::ImageInfoList::ConstIterator imageIt = images.begin(); imageIt != images.end(); ++ imageIt ) { const StringSet itemsOnThisImage = (*imageIt)->itemsOfCategory( category ); itemsNotSelectedOnAllImages += ( allItems - itemsOnThisImage ); itemsOnSomeImages += itemsOnThisImage; } const StringSet itemsOnAllImages = allItems - itemsNotSelectedOnAllImages; const StringSet itemsPartiallyOn = itemsOnSomeImages - itemsOnAllImages; return std::make_tuple( itemsOnAllImages, itemsPartiallyOn ); } void AnnotationDialog::Dialog::slotRatingChanged( unsigned int ) { m_ratingChanged = true; } void AnnotationDialog::Dialog::continueLater() { saveAndClose(); } void AnnotationDialog::Dialog::saveAndClose() { tidyAreas(); m_fullScreenPreview->stopPlayback(); if (m_origList.isEmpty()) { // all images are deleted. QDialog::accept(); return; } // I need to check for the changes first, as the case for m_setup == InputSingleImageConfigMode, saves to the m_origList, // and we can thus not check for changes anymore const bool anyChanges = hasChanges(); if ( m_setup == InputSingleImageConfigMode ) { writeToInfo(); for ( int i = 0; i < m_editList.count(); ++i ) { *(m_origList[i]) = m_editList[i]; } } else if ( m_setup == InputMultiImageConfigMode ) { Q_FOREACH( ListSelect *ls, m_optionList ) { ls->slotReturn(); } for( DB::ImageInfoListConstIterator it = m_origList.constBegin(); it != m_origList.constEnd(); ++it ) { DB::ImageInfoPtr info = *it; - info->delaySavingChanges(true); if ( !m_startDate->date().isNull() ) info->setDate( DB::ImageDate( m_startDate->date(), m_endDate->date(), m_time->time() ) ); Q_FOREACH( ListSelect *ls, m_optionList ) { info->addCategoryInfo( ls->category(), ls->itemsOn() ); info->removeCategoryInfo( ls->category(), ls->itemsOff() ); } if ( !m_imageLabel->text().isEmpty() ) { info->setLabel( m_imageLabel->text() ); } if ( !m_description->toPlainText().isEmpty() && m_description->toPlainText().compare( m_conflictText ) ) { info->setDescription( m_description->toPlainText() ); } if( m_ratingChanged) { info->setRating( m_rating->rating() ); } - info->delaySavingChanges(false); } m_ratingChanged = false; } m_accept = QDialog::Accepted; if (anyChanges) { MainWindow::DirtyIndicator::markDirty(); } QDialog::accept(); } AnnotationDialog::Dialog::~Dialog() { qDeleteAll( m_optionList ); m_optionList.clear(); } void AnnotationDialog::Dialog::togglePreview() { if (m_setup == InputSingleImageConfigMode) { if ( m_stack->currentWidget() == m_fullScreenPreview ) { m_stack->setCurrentWidget( m_dockWindow ); m_fullScreenPreview->stopPlayback(); } else { m_stack->setCurrentWidget( m_fullScreenPreview ); m_fullScreenPreview->load( DB::FileNameList() << m_editList[ m_current].fileName() ); } } } void AnnotationDialog::Dialog::tidyAreas() { // Remove all areas marked on the preview image foreach (ResizableFrame *area, areas()) { area->markTidied(); area->deleteLater(); } } void AnnotationDialog::Dialog::slotNewArea(ResizableFrame *area) { area->setDialog(this); } void AnnotationDialog::Dialog::positionableTagSelected(QString category, QString tag) { // Be sure not to propose an already-associated tag QPair tagData = qMakePair(category, tag); foreach (ResizableFrame *area, areas()) { if (area->tagData() == tagData) { return; } } // Set the selected tag as the last selected positionable tag m_lastSelectedPositionableTag = tagData; // Add the tag to the positionable tag candidate list addTagToCandidateList(category, tag); } void AnnotationDialog::Dialog::positionableTagDeselected(QString category, QString tag) { // Remove the tag from the candidate list removeTagFromCandidateList(category, tag); // Search for areas linked against the tag on this image if (m_setup == InputSingleImageConfigMode) { QPair deselectedTag = QPair(category, tag); foreach (ResizableFrame *area, areas()) { if (area->tagData() == deselectedTag) { area->removeTagData(); m_areasChanged = true; // Only one area can be associated with the tag, so we can return here return; } } } // Removal of tagged areas in InputMultiImageConfigMode is done in DB::ImageInfo::removeCategoryInfo } void AnnotationDialog::Dialog::addTagToCandidateList(QString category, QString tag) { m_positionableTagCandidates << QPair(category, tag); } void AnnotationDialog::Dialog::removeTagFromCandidateList(QString category, QString tag) { // Is the deselected tag the last selected positionable tag? if (m_lastSelectedPositionableTag.first == category && m_lastSelectedPositionableTag.second == tag) { m_lastSelectedPositionableTag = QPair(); } // Remove the tag from the candidate list m_positionableTagCandidates.removeAll(QPair(category, tag)); // When a positionable tag is entered via the AreaTagSelectDialog, it's added to this // list twice, so we use removeAll here to be sure to also wipe duplicate entries. } QPair AnnotationDialog::Dialog::lastSelectedPositionableTag() const { return m_lastSelectedPositionableTag; } QList> AnnotationDialog::Dialog::positionableTagCandidates() const { return m_positionableTagCandidates; } void AnnotationDialog::Dialog::slotShowAreas(bool showAreas) { foreach (ResizableFrame *area, areas()) { area->setVisible(showAreas); } } void AnnotationDialog::Dialog::positionableTagRenamed(QString category, QString oldTag, QString newTag) { // Is the renamed tag the last selected positionable tag? if (m_lastSelectedPositionableTag.first == category && m_lastSelectedPositionableTag.second == oldTag) { m_lastSelectedPositionableTag.second = newTag; } // Check the candidate list for the tag QPair oldTagData = QPair(category, oldTag); if (m_positionableTagCandidates.contains(oldTagData)) { // The tag is in the list, so update it m_positionableTagCandidates.removeAt(m_positionableTagCandidates.indexOf(oldTagData)); m_positionableTagCandidates << QPair(category, newTag); } // Check if an area on the current image contains the changed or proposed tag foreach (ResizableFrame *area, areas()) { if (area->tagData() == oldTagData) { area->setTagData(category, newTag); } } } void AnnotationDialog::Dialog::descriptionPageUpDownPressed(QKeyEvent *event) { if (event->key() == Qt::Key_PageUp) { m_actions->action(QString::fromLatin1("annotationdialog-prev-image"))->trigger(); } else if (event->key() == Qt::Key_PageDown) { m_actions->action(QString::fromLatin1("annotationdialog-next-image"))->trigger(); } } void AnnotationDialog::Dialog::checkProposedTagData( QPair tagData, ResizableFrame *areaToExclude) const { foreach (ResizableFrame *area, areas()) { if (area != areaToExclude && area->proposedTagData() == tagData && area->tagData().first.isEmpty()) { area->removeProposedTagData(); } } } void AnnotationDialog::Dialog::areaChanged() { m_areasChanged = true; } /** * @brief positionableTagValid checks whether a given tag can still be associated to an area. * This checks for empty and duplicate tags. * @return */ bool AnnotationDialog::Dialog::positionableTagAvailable(const QString &category, const QString &tag) const { if (category.isEmpty() || tag.isEmpty()) return false; // does any area already have that tag? foreach (const ResizableFrame *area, areas()) { const auto tagData = area->tagData(); if (tagData.first == category && tagData.second == tag) return false; } return true; } /** * @brief Generates a set of positionable tags currently used on the image * @param category * @return */ QSet AnnotationDialog::Dialog::positionedTags(const QString &category) const { QSet tags; foreach (const ResizableFrame *area, areas()) { const auto tagData = area->tagData(); if (tagData.first == category) tags += tagData.second; } return tags; } AnnotationDialog::ListSelect *AnnotationDialog::Dialog::listSelectForCategory(const QString &category) { return m_listSelectList.value(category,nullptr); } #ifdef HAVE_KGEOMAP void AnnotationDialog::Dialog::updateMapForCurrentImage() { if (m_setup != InputSingleImageConfigMode) { return; } if (m_editList[m_current].coordinates().hasCoordinates()) { m_annotationMap->setCenter(m_editList[m_current]); m_annotationMap->displayStatus(Map::MapView::MapStatus::ImageHasCoordinates); } else { m_annotationMap->displayStatus(Map::MapView::MapStatus::ImageHasNoCoordinates); } } void AnnotationDialog::Dialog::annotationMapVisibilityChanged(bool visible) { // This populates the map if it's added when the dialog is already open if ( visible ) { // when the map dockwidget is already visible on show(), the call to // annotationMapVisibilityChanged is executed in the GUI thread. // This ensures that populateMap() doesn't block the GUI in this case: QTimer::singleShot(0, this, SLOT(populateMap())); } else { m_cancelMapLoading = true; } } void AnnotationDialog::Dialog::populateMap() { // populateMap is called every time the map widget gets visible if (m_mapIsPopulated) { return; } m_annotationMap->displayStatus(Map::MapView::MapStatus::Loading); m_cancelMapLoading = false; m_mapLoadingProgress->setMaximum(m_editList.count()); m_mapLoadingProgress->show(); m_cancelMapLoadingButton->show(); int processedImages = 0; int imagesWithCoordinates = 0; foreach (DB::ImageInfo info, m_editList) { processedImages++; m_mapLoadingProgress->setValue(processedImages); // keep things responsive by processing events manually: QApplication::processEvents(); if (info.coordinates().hasCoordinates()) { m_annotationMap->addImage(info); imagesWithCoordinates++; } // m_cancelMapLoading is set to true by clicking the "Cancel" button if (m_cancelMapLoading) { m_annotationMap->clear(); break; } } // at this point either we canceled loading or the map is populated: m_mapIsPopulated = ! m_cancelMapLoading; mapLoadingFinished(imagesWithCoordinates > 0, imagesWithCoordinates == processedImages); } void AnnotationDialog::Dialog::setCancelMapLoading() { m_cancelMapLoading = true; } void AnnotationDialog::Dialog::mapLoadingFinished(bool mapHasImages, bool allImagesHaveCoordinates) { m_mapLoadingProgress->hide(); m_cancelMapLoadingButton->hide(); if (m_setup == InputSingleImageConfigMode) { m_annotationMap->displayStatus(Map::MapView::MapStatus::ImageHasNoCoordinates); } else { if (m_setup == SearchMode) { m_annotationMap->displayStatus(Map::MapView::MapStatus::SearchCoordinates); } else { if (mapHasImages) { if (! allImagesHaveCoordinates) { m_annotationMap->displayStatus(Map::MapView::MapStatus::SomeImagesHaveNoCoordinates); } else { m_annotationMap->displayStatus(Map::MapView::MapStatus::ImageHasCoordinates); } } else { m_annotationMap->displayStatus(Map::MapView::MapStatus::NoImagesHaveNoCoordinates); } } } if (m_setup != SearchMode) { m_annotationMap->zoomToMarkers(); updateMapForCurrentImage(); } } #endif // vi:expandtab:tabstop=4 shiftwidth=4: diff --git a/DB/ImageInfo.cpp b/DB/ImageInfo.cpp index f9b144ef..3c1d8c51 100644 --- a/DB/ImageInfo.cpp +++ b/DB/ImageInfo.cpp @@ -1,864 +1,806 @@ -/* Copyright (C) 2003-2015 Jesper K. Pedersen +/* Copyright (C) 2003-2019 The KPhotoAlbum Development Team This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; see the file COPYING. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "ImageInfo.h" #include "FileInfo.h" #include "Logging.h" #include #include #include #include #include #include #include #include #include #include #include using namespace DB; ImageInfo::ImageInfo() :m_null( true ), m_rating(-1), m_stackId(0), m_stackOrder(0) , m_videoLength(-1) , m_isMatched( false ), m_matchGeneration( -1 ) - , m_locked( false ), m_dirty( false ), m_delaySaving( false ) + , m_locked( false ), m_dirty( false ) { } ImageInfo::ImageInfo( const DB::FileName& fileName, MediaType type, bool readExifInfo, bool storeExifInfo) : m_imageOnDisk( YesOnDisk ), m_null( false ), m_size( -1, -1 ), m_type( type ) , m_rating(-1), m_stackId(0), m_stackOrder(0) , m_videoLength(-1) , m_isMatched( false ), m_matchGeneration( -1 ) - , m_locked(false), m_delaySaving( true ) + , m_locked(false) { QFileInfo fi( fileName.absolute() ); m_label = fi.completeBaseName(); m_angle = 0; setFileName(fileName); // Read Exif information if ( readExifInfo ) { ExifMode mode = EXIFMODE_INIT; if ( ! storeExifInfo) mode &= ~EXIFMODE_DATABASE_UPDATE; readExif(fileName, mode); } m_dirty = false; - m_delaySaving = false; } void ImageInfo::setIsMatched(bool isMatched) { m_isMatched = isMatched; } bool ImageInfo::isMatched() const { return m_isMatched; } void ImageInfo::setMatchGeneration(int matchGeneration) { m_matchGeneration = matchGeneration; } int ImageInfo::matchGeneration() const { return m_matchGeneration; } -/** Change delaying of saving changes. - * - * Will save changes when set to false. - * - * Use this method to set multiple attributes with only one - * database operation. - * - * Example: - * \code - * info.delaySavingChanges(true); - * info.setLabel("Hello"); - * info.setDescription("Hello world"); - * info.delaySavingChanges(false); - * \endcode - * - * \see saveChanges() - */ -void ImageInfo::delaySavingChanges(bool b) -{ - m_delaySaving = b; - if (!b) - saveChanges(); -} - void ImageInfo::setLabel( const QString& desc ) { if (desc != m_label) m_dirty = true; m_label = desc; - saveChangesIfNotDelayed(); } QString ImageInfo::label() const { return m_label; } void ImageInfo::setDescription( const QString& desc ) { if (desc != m_description) m_dirty = true; m_description = desc.trimmed(); - saveChangesIfNotDelayed(); } QString ImageInfo::description() const { return m_description; } void ImageInfo::setCategoryInfo( const QString& key, const StringSet& value ) { // Don't check if really changed, because it's too slow. m_dirty = true; m_categoryInfomation[key] = value; - saveChangesIfNotDelayed(); } bool ImageInfo::hasCategoryInfo( const QString& key, const QString& value ) const { return m_categoryInfomation[key].contains(value); } bool DB::ImageInfo::hasCategoryInfo( const QString& key, const StringSet& values ) const { return values.intersects( m_categoryInfomation[key] ); } StringSet ImageInfo::itemsOfCategory( const QString& key ) const { return m_categoryInfomation[key]; } void ImageInfo::renameItem( const QString& category, const QString& oldValue, const QString& newValue ) { if (m_taggedAreas.contains(category)) { if (m_taggedAreas[category].contains(oldValue)) { m_taggedAreas[category][newValue] = m_taggedAreas[category][oldValue]; m_taggedAreas[category].remove(oldValue); } } StringSet& set = m_categoryInfomation[category]; StringSet::iterator it = set.find( oldValue ); if ( it != set.end() ) { m_dirty = true; set.erase( it ); set.insert( newValue ); - saveChangesIfNotDelayed(); } } DB::FileName ImageInfo::fileName() const { return m_fileName; } void ImageInfo::setFileName( const DB::FileName& fileName ) { if (fileName != m_fileName) m_dirty = true; m_fileName = fileName; m_imageOnDisk = Unchecked; DB::CategoryPtr folderCategory = DB::ImageDB::instance()->categoryCollection()-> categoryForSpecial(DB::Category::FolderCategory); if (folderCategory) { DB::MemberMap& map = DB::ImageDB::instance()->memberMap(); createFolderCategoryItem( folderCategory, map ); //ImageDB::instance()->setMemberMap( map ); } - saveChangesIfNotDelayed(); } void ImageInfo::rotate( int degrees, RotationMode mode ) { // ensure positive degrees: degrees += 360; degrees = degrees % 360; if ( degrees == 0 ) return; m_dirty = true; m_angle = ( m_angle + degrees ) % 360; if (degrees == 90 || degrees == 270) { m_size.transpose(); } // the AnnotationDialog manages this by itself and sets RotateImageInfoOnly: if ( mode == RotateImageInfoAndAreas ) { for ( auto& areasOfCategory : m_taggedAreas ) { for ( auto& area : areasOfCategory ) { QRect rotatedArea; // parameter order for QRect::setCoords: // setCoords( left, top, right, bottom ) // keep in mind that _size is already transposed switch (degrees) { case 90: rotatedArea.setCoords( m_size.width() - area.bottom(), area.left(), m_size.width() - area.top(), area.right() ); break; case 180: rotatedArea.setCoords( m_size.width() - area.right(), m_size.height() - area.bottom(), m_size.width() - area.left(), m_size.height() - area.top() ); break; case 270: rotatedArea.setCoords( area.top(), m_size.height() - area.right(), area.bottom(), m_size.height() - area.left() ); break; default: // degrees==0; "odd" values won't happen. rotatedArea = area; break; } // update _taggedAreas[category][tag]: area = rotatedArea; } } } - saveChangesIfNotDelayed(); } int ImageInfo::angle() const { return m_angle; } void ImageInfo::setAngle( int angle ) { if (angle != m_angle) m_dirty = true; m_angle = angle; - saveChangesIfNotDelayed(); } short ImageInfo::rating() const { return m_rating; } void ImageInfo::setRating( short rating ) { Q_ASSERT( (rating >= 0 && rating <= 10) || rating == -1 ); if ( rating > 10 ) rating = 10; if ( rating < -1 ) rating = -1; if ( m_rating != rating ) m_dirty = true; m_rating = rating; - saveChangesIfNotDelayed(); } DB::StackID ImageInfo::stackId() const { return m_stackId; } void ImageInfo::setStackId( const DB::StackID stackId ) { if ( stackId != m_stackId ) m_dirty = true; m_stackId = stackId; - saveChangesIfNotDelayed(); } unsigned int ImageInfo::stackOrder() const { return m_stackOrder; } void ImageInfo::setStackOrder( const unsigned int stackOrder ) { if ( stackOrder != m_stackOrder ) m_dirty = true; m_stackOrder = stackOrder; - saveChangesIfNotDelayed(); } void ImageInfo::setVideoLength(int length) { if ( m_videoLength != length ) m_dirty = true; m_videoLength = length; - saveChangesIfNotDelayed(); } int ImageInfo::videoLength() const { return m_videoLength; } void ImageInfo::setDate( const ImageDate& date ) { if (date != m_date) m_dirty = true; m_date = date; - saveChangesIfNotDelayed(); } ImageDate& ImageInfo::date() { return m_date; } ImageDate ImageInfo::date() const { return m_date; } bool ImageInfo::operator!=( const ImageInfo& other ) const { return !(*this == other); } bool ImageInfo::operator==( const ImageInfo& other ) const { bool changed = ( m_fileName != other.m_fileName || m_label != other.m_label || ( !m_description.isEmpty() && !other.m_description.isEmpty() && m_description != other.m_description ) || // one might be isNull. m_date != other.m_date || m_angle != other.m_angle || m_rating != other.m_rating || ( m_stackId != other.m_stackId || ! ( ( m_stackId == 0 ) ? true : ( m_stackOrder == other.m_stackOrder ) ) ) ); if ( !changed ) { QStringList keys = DB::ImageDB::instance()->categoryCollection()->categoryNames(); for( QStringList::ConstIterator it = keys.constBegin(); it != keys.constEnd(); ++it ) changed |= m_categoryInfomation[*it] != other.m_categoryInfomation[*it]; } return !changed; } void ImageInfo::renameCategory( const QString& oldName, const QString& newName ) { m_dirty = true; m_categoryInfomation[newName] = m_categoryInfomation[oldName]; m_categoryInfomation.remove(oldName); m_taggedAreas[newName] = m_taggedAreas[oldName]; m_taggedAreas.remove(oldName); - saveChangesIfNotDelayed(); } void ImageInfo::setMD5Sum( const MD5& sum, bool storeEXIF ) { if (sum != m_md5sum) { // if we make a QObject derived class out of imageinfo, we might invalidate thumbnails from here // file changed -> reload/invalidate metadata: ExifMode mode = EXIFMODE_ORIENTATION | EXIFMODE_DATABASE_UPDATE; // fuzzy dates are usually set for a reason if (!m_date.isFuzzy()) mode |= EXIFMODE_DATE; // FIXME (ZaJ): the "right" thing to do would be to update the description // - if it is currently empty (done.) // - if it has been set from the exif info and not been changed (TODO) if (m_description.isEmpty()) mode |= EXIFMODE_DESCRIPTION; if (!storeEXIF) mode &= ~EXIFMODE_DATABASE_UPDATE; readExif( fileName(), mode); // FIXME (ZaJ): it *should* make sense to set the ImageDB::md5Map() from here, but I want // to make sure I fully understand everything first... // this could also be done as signal md5Changed(old,new) // image size is invalidated by the thumbnail builder, if needed m_dirty = true; } m_md5sum = sum; - saveChangesIfNotDelayed(); } void ImageInfo::setLocked( bool locked ) { m_locked = locked; } bool ImageInfo::isLocked() const { return m_locked; } void ImageInfo::readExif(const DB::FileName& fullPath, DB::ExifMode mode) { DB::FileInfo exifInfo = DB::FileInfo::read( fullPath, mode ); - bool oldDelaySaving = m_delaySaving; - delaySavingChanges(true); - // Date if ( updateDateInformation(mode) ) { const ImageDate newDate ( exifInfo.dateTime() ); setDate( newDate ); } // Orientation if ( (mode & EXIFMODE_ORIENTATION) && Settings::SettingsData::instance()->useEXIFRotate() ) { setAngle( exifInfo.angle() ); } // Description if ( (mode & EXIFMODE_DESCRIPTION) && Settings::SettingsData::instance()->useEXIFComments() ) { bool doSetDescription = true; QString desc = exifInfo.description(); if ( Settings::SettingsData::instance()->stripEXIFComments() ) { for( const auto& ignoredComment : Settings::SettingsData::instance()->EXIFCommentsToStrip() ) { if ( desc == ignoredComment ) { doSetDescription = false; break; } } } if (doSetDescription) { setDescription(desc); } } - delaySavingChanges(false); - m_delaySaving = oldDelaySaving; - // Database update if ( mode & EXIFMODE_DATABASE_UPDATE ) { Exif::Database::instance()->add( exifInfo ); #ifdef HAVE_KGEOMAP // GPS coords might have changed... m_coordsIsSet = false; #endif } } QStringList ImageInfo::availableCategories() const { return m_categoryInfomation.keys(); } QSize ImageInfo::size() const { return m_size; } void ImageInfo::setSize( const QSize& size ) { if (size != m_size) m_dirty = true; m_size = size; - saveChangesIfNotDelayed(); } bool ImageInfo::imageOnDisk( const DB::FileName& fileName ) { return fileName.exists(); } ImageInfo::ImageInfo( const DB::FileName& fileName, const QString& label, const QString& description, const ImageDate& date, int angle, const MD5& md5sum, const QSize& size, MediaType type, short rating, unsigned int stackId, unsigned int stackOrder ) { - m_delaySaving = true; m_fileName = fileName; m_label =label; m_description =description; m_date = date; m_angle =angle; m_md5sum =md5sum; m_size = size; m_imageOnDisk = Unchecked; m_locked = false; m_null = false; m_type = type; m_dirty = true; - delaySavingChanges(false); if ( rating > 10 ) rating = 10; if ( rating < -1 ) rating = -1; m_rating = rating; m_stackId = stackId; m_stackOrder = stackOrder; m_videoLength= -1; } -// TODO: we should get rid of this operator. It seems only be necessary -// because of the 'delaySavings' field that gets a special value. -// ImageInfo should just be a dumb data object holder and not incorporate -// storing strategies. ImageInfo& ImageInfo::operator=( const ImageInfo& other ) { m_fileName = other.m_fileName; m_label = other.m_label; m_description = other.m_description; m_date = other.m_date; m_categoryInfomation = other.m_categoryInfomation; m_taggedAreas = other.m_taggedAreas; m_angle = other.m_angle; m_imageOnDisk = other.m_imageOnDisk; m_md5sum = other.m_md5sum; m_null = other.m_null; m_size = other.m_size; m_type = other.m_type; m_dirty = other.m_dirty; m_rating = other.m_rating; m_stackId = other.m_stackId; m_stackOrder = other.m_stackOrder; m_videoLength = other.m_videoLength; - delaySavingChanges(false); return *this; } MediaType DB::ImageInfo::mediaType() const { return m_type; } bool ImageInfo::isVideo() const { return m_type == Video; } void DB::ImageInfo::createFolderCategoryItem( DB::CategoryPtr folderCategory, DB::MemberMap& memberMap ) { QString folderName = Utilities::relativeFolderName( m_fileName.relative() ); if ( folderName.isEmpty() ) return; if ( ! memberMap.contains( folderCategory->name(), folderName ) ) { QStringList directories = folderName.split(QString::fromLatin1( "/" ) ); QString curPath; for( QStringList::ConstIterator directoryIt = directories.constBegin(); directoryIt != directories.constEnd(); ++directoryIt ) { if ( curPath.isEmpty() ) curPath = *directoryIt; else { QString oldPath = curPath; curPath = curPath + QString::fromLatin1( "/" ) + *directoryIt; memberMap.addMemberToGroup( folderCategory->name(), oldPath, curPath ); } } folderCategory->addItem( folderName ); } m_categoryInfomation.insert( folderCategory->name() , StringSet() << folderName ); } void DB::ImageInfo::copyExtraData( const DB::ImageInfo& from, bool copyAngle) { m_categoryInfomation = from.m_categoryInfomation; m_description = from.m_description; // Hmm... what should the date be? orig or modified? // _date = from._date; if (copyAngle) m_angle = from.m_angle; m_rating = from.m_rating; } void DB::ImageInfo::removeExtraData () { m_categoryInfomation.clear(); m_description.clear(); m_rating = -1; } void ImageInfo::merge(const ImageInfo &other) { // Merge date if ( other.date() != m_date) { // a fuzzy date has been set by the user and therefore "wins" over an exact date. // two fuzzy dates can be merged // two exact dates should ideally be cross-checked with Exif information in the file. // Nevertheless, we merge them into a fuzzy date to avoid the complexity of checking the file. if (other.date().isFuzzy()) { if (m_date.isFuzzy()) m_date.extendTo(other.date()); else m_date = other.date(); } else if (!m_date.isFuzzy()) { m_date.extendTo(other.date()); } // else: keep m_date } // Merge description if ( !other.description().isEmpty() ) { if ( m_description.isEmpty() ) m_description = other.description(); else if (m_description != other.description()) m_description += QString::fromUtf8("\n-----------\n") + other.m_description; } // Clear untagged tag if only one of the images was untagged const QString untaggedCategory = Settings::SettingsData::instance()->untaggedCategory(); const QString untaggedTag = Settings::SettingsData::instance()->untaggedTag(); const bool isCompleted = !m_categoryInfomation[untaggedCategory].contains(untaggedTag) || !other.m_categoryInfomation[untaggedCategory].contains(untaggedTag); // Merge tags QSet keys = QSet::fromList(m_categoryInfomation.keys()); keys.unite(QSet::fromList(other.m_categoryInfomation.keys())); for( const QString& key : keys) { m_categoryInfomation[key].unite(other.m_categoryInfomation[key]); } // Clear untagged tag if only one of the images was untagged if (isCompleted) m_categoryInfomation[untaggedCategory].remove(untaggedTag); // merge stacks: if (isStacked() || other.isStacked()) { DB::FileNameList stackImages; if (!isStacked()) stackImages.append(fileName()); else stackImages.append(DB::ImageDB::instance()->getStackFor(fileName())); stackImages.append(DB::ImageDB::instance()->getStackFor(other.fileName())); DB::ImageDB::instance()->unstack(stackImages); if (!DB::ImageDB::instance()->stack(stackImages)) qCWarning(DBLog, "Could not merge stacks!"); } } void DB::ImageInfo::addCategoryInfo( const QString& category, const StringSet& values ) { for ( StringSet::const_iterator valueIt = values.constBegin(); valueIt != values.constEnd(); ++valueIt ) { if (! m_categoryInfomation[category].contains( *valueIt ) ) { m_dirty = true; m_categoryInfomation[category].insert( *valueIt ); } } - saveChangesIfNotDelayed(); } void DB::ImageInfo::clearAllCategoryInfo() { m_categoryInfomation.clear(); m_taggedAreas.clear(); } void DB::ImageInfo::removeCategoryInfo( const QString& category, const StringSet& values ) { for ( StringSet::const_iterator valueIt = values.constBegin(); valueIt != values.constEnd(); ++valueIt ) { if ( m_categoryInfomation[category].contains( *valueIt ) ) { m_dirty = true; m_categoryInfomation[category].remove(*valueIt); m_taggedAreas[category].remove(*valueIt); } } - saveChangesIfNotDelayed(); } void DB::ImageInfo::addCategoryInfo( const QString& category, const QString& value, const QRect& area ) { if (! m_categoryInfomation[category].contains( value ) ) { m_dirty = true; m_categoryInfomation[category].insert( value ); if (area.isValid()) { m_taggedAreas[category][value] = area; } } - saveChangesIfNotDelayed(); } void DB::ImageInfo::removeCategoryInfo( const QString& category, const QString& value ) { if ( m_categoryInfomation[category].contains( value ) ) { m_dirty = true; m_categoryInfomation[category].remove( value ); m_taggedAreas[category].remove( value ); } - saveChangesIfNotDelayed(); } void DB::ImageInfo::setPositionedTags(const QString& category, const QMap &positionedTags) { m_dirty = true; m_taggedAreas[category] = positionedTags; - saveChangesIfNotDelayed(); } bool DB::ImageInfo::updateDateInformation( int mode ) const { if ((mode & EXIFMODE_DATE) == 0) return false; if ( (mode & EXIFMODE_FORCE) != 0 ) return true; return true; } QMap> DB::ImageInfo::taggedAreas() const { return m_taggedAreas; } QRect DB::ImageInfo::areaForTag(QString category, QString tag) const { // QMap::value returns a default constructed value if the key is not found: return m_taggedAreas.value(category).value(tag); } #ifdef HAVE_KGEOMAP KGeoMap::GeoCoordinates DB::ImageInfo::coordinates() const { if (m_coordsIsSet) { return m_coordinates; } static const int EXIF_GPS_VERSIONID = 0; static const int EXIF_GPS_LATREF = 1; static const int EXIF_GPS_LAT = 2; static const int EXIF_GPS_LONREF = 3; static const int EXIF_GPS_LON = 4; static const int EXIF_GPS_ALTREF = 5; static const int EXIF_GPS_ALT = 6; static const QString S = QString::fromUtf8("S"); static const QString W = QString::fromUtf8("W"); static QList fields; if (fields.isEmpty()) { // the order here matters! we use the named int constants afterwards to refer to them: fields.append( new Exif::IntExifElement( "Exif.GPSInfo.GPSVersionID" ) ); // actually a byte value fields.append( new Exif::StringExifElement( "Exif.GPSInfo.GPSLatitudeRef" ) ); fields.append( new Exif::RationalExifElement( "Exif.GPSInfo.GPSLatitude" ) ); fields.append( new Exif::StringExifElement( "Exif.GPSInfo.GPSLongitudeRef" ) ); fields.append( new Exif::RationalExifElement( "Exif.GPSInfo.GPSLongitude" ) ); fields.append( new Exif::IntExifElement( "Exif.GPSInfo.GPSAltitudeRef" ) ); // actually a byte value fields.append( new Exif::RationalExifElement( "Exif.GPSInfo.GPSAltitude" ) ); } // read field values from database: bool foundIt = Exif::Database::instance()->readFields( m_fileName, fields ); // if the Database query result doesn't contain exif GPS info (-> upgraded exifdb from DBVersion < 2), it is null // if the result is int 0, then there's no exif gps information in the image // otherwise we can proceed to parse the information if ( foundIt && fields[EXIF_GPS_VERSIONID]->value().isNull() ) { // update exif DB and repeat the search: Exif::Database::instance()->remove( fileName() ); Exif::Database::instance()->add( fileName() ); Exif::Database::instance()->readFields( m_fileName, fields ); Q_ASSERT( !fields[EXIF_GPS_VERSIONID]->value().isNull() ); } KGeoMap::GeoCoordinates coords; // gps info set? // don't use the versionid field here, because some cameras use 0 as its value if ( foundIt && fields[EXIF_GPS_LAT]->value().toInt() != -1.0 && fields[EXIF_GPS_LON]->value().toInt() != -1.0 ) { // lat/lon/alt reference determines sign of float: double latr = (fields[EXIF_GPS_LATREF]->value().toString() == S ) ? -1.0 : 1.0; double lat = fields[EXIF_GPS_LAT]->value().toFloat(); double lonr = (fields[EXIF_GPS_LONREF]->value().toString() == W ) ? -1.0 : 1.0; double lon = fields[EXIF_GPS_LON]->value().toFloat(); double altr = (fields[EXIF_GPS_ALTREF]->value().toInt() == 1 ) ? -1.0 : 1.0; double alt = fields[EXIF_GPS_ALT]->value().toFloat(); if (lat != -1.0 && lon != -1.0) { coords.setLatLon(latr * lat, lonr * lon); if (alt != 0.0f) { coords.setAlt(altr * alt); } } } m_coordinates = coords; m_coordsIsSet = true; return m_coordinates; } #endif // vi:expandtab:tabstop=4 shiftwidth=4: diff --git a/DB/ImageInfo.h b/DB/ImageInfo.h index 0c607208..36fa8385 100644 --- a/DB/ImageInfo.h +++ b/DB/ImageInfo.h @@ -1,248 +1,235 @@ -/* Copyright (C) 2003-2015 Jesper K. Pedersen +/* Copyright (C) 2003-2019 The KPhotoAlbum Development Team This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; see the file COPYING. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #ifndef IMAGEINFO_H #define IMAGEINFO_H #include #include #include #include "ImageDate.h" #include "Utilities/StringSet.h" #include "MD5.h" #include "ExifMode.h" #include "DB/CategoryPtr.h" #include #include #include "FileName.h" #include "config-kpa-kgeomap.h" #ifdef HAVE_KGEOMAP #include #endif namespace Plugins { class ImageInfo; } namespace XMLDB { class Database; } namespace DB { enum PathType { RelativeToImageRoot, AbsolutePath }; enum RotationMode { RotateImageInfoAndAreas, RotateImageInfoOnly }; using Utilities::StringSet; class MemberMap; enum MediaType { Image = 0x01, Video = 0x02 }; const MediaType anyMediaType = MediaType(Image | Video); typedef unsigned int StackID; class ImageInfo :public QSharedData { public: ImageInfo(); explicit ImageInfo( const DB::FileName& fileName, MediaType type = Image, bool readExifInfo = true, bool storeExifInfo = true); ImageInfo( const DB::FileName& fileName, const QString& label, const QString& description, const ImageDate& date, int angle, const MD5& md5sum, const QSize& size, MediaType type, short rating = -1, StackID stackId = 0, unsigned int stackOrder = 0 ); - virtual ~ImageInfo() { saveChanges(); } FileName fileName() const; void setFileName( const DB::FileName& relativeFileName ); void setLabel( const QString& ); QString label() const; void setDescription( const QString& ); QString description() const; void setDate( const ImageDate& ); ImageDate date() const; ImageDate& date(); void readExif(const DB::FileName& fullPath, DB::ExifMode mode); void rotate( int degrees, RotationMode mode=RotateImageInfoAndAreas ); int angle() const; void setAngle( int angle ); short rating() const; void setRating( short rating ); bool isStacked() const { return m_stackId != 0; } StackID stackId() const; unsigned int stackOrder() const; void setStackOrder( const unsigned int stackOrder ); void setVideoLength(int seconds); int videoLength() const; void setCategoryInfo( const QString& key, const StringSet& value ); void addCategoryInfo( const QString& category, const StringSet& values ); /** * Enable a tag within a category for this image. * Optionally, the tag's position can be given (for positionable categories). * @param category the category name * @param value the tag name * @param area the image region that the tag applies to. */ void addCategoryInfo(const QString& category, const QString& value, const QRect& area = QRect()); void clearAllCategoryInfo(); void removeCategoryInfo( const QString& category, const StringSet& values ); void removeCategoryInfo( const QString& category, const QString& value ); /** * Set the tagged areas for the image. * It is assumed that the positioned tags have already been set to the ImageInfo * using one of the functions setCategoryInfo or addCategoryInfo. * * @param category the category name. * @param positionedTags a mapping of tag names to image areas. */ void setPositionedTags(const QString& category, const QMap &positionedTags); bool hasCategoryInfo( const QString& key, const QString& value ) const; bool hasCategoryInfo( const QString& key, const StringSet& values ) const; QStringList availableCategories() const; StringSet itemsOfCategory( const QString& category ) const; void renameItem( const QString& key, const QString& oldValue, const QString& newValue ); void renameCategory( const QString& oldName, const QString& newName ); bool operator!=( const ImageInfo& other ) const; bool operator==( const ImageInfo& other ) const; ImageInfo& operator=( const ImageInfo& other ); + static bool imageOnDisk( const DB::FileName& fileName ); const MD5& MD5Sum() const { return m_md5sum; } void setMD5Sum( const MD5& sum, bool storeEXIF=true ); void setLocked( bool ); bool isLocked() const; bool isNull() const { return m_null; } QSize size() const; void setSize( const QSize& size ); MediaType mediaType() const; - void setMediaType( MediaType type ) { if (type != m_type) m_dirty = true; m_type = type; saveChangesIfNotDelayed(); } + void setMediaType( MediaType type ) { if (type != m_type) m_dirty = true; m_type = type; } bool isVideo() const; void createFolderCategoryItem( DB::CategoryPtr, DB::MemberMap& memberMap ); - void delaySavingChanges(bool b=true); - void copyExtraData( const ImageInfo& from, bool copyAngle = true); void removeExtraData(); /** * Merge another ImageInfo into this one. * The other ImageInfo is not altered in any way or removed. */ void merge(const ImageInfo& other); QMap> taggedAreas() const; /** * Return the area associated with a tag. * @param category the category name * @param tag the tag name * @return the associated area, or QRect() if no association exists. */ QRect areaForTag(QString category, QString tag) const; void setIsMatched(bool isMatched); bool isMatched() const; void setMatchGeneration(int matchGeneration); int matchGeneration() const; #ifdef HAVE_KGEOMAP KGeoMap::GeoCoordinates coordinates() const; #endif protected: - /** Save changes to database. - * - * Back-ends, which need changes to be instantly in database, - * should override this. - */ - virtual void saveChanges() {} - - void saveChangesIfNotDelayed() { if (!m_delaySaving) saveChanges(); } - void setIsNull(bool b) { m_null = b; } bool isDirty() const { return m_dirty; } void setIsDirty(bool b) { m_dirty = b; } bool updateDateInformation( int mode ) const; void setStackId( const StackID stackId ); friend class XMLDB::Database; private: DB::FileName m_fileName; QString m_label; QString m_description; ImageDate m_date; QMap m_categoryInfomation; QMap> m_taggedAreas; int m_angle; enum OnDisk { YesOnDisk, NoNotOnDisk, Unchecked }; mutable OnDisk m_imageOnDisk; MD5 m_md5sum; bool m_null; QSize m_size; MediaType m_type; short m_rating; StackID m_stackId; unsigned int m_stackOrder; int m_videoLength; bool m_isMatched; int m_matchGeneration; #ifdef HAVE_KGEOMAP mutable KGeoMap::GeoCoordinates m_coordinates; mutable bool m_coordsIsSet = false; #endif // Cache information bool m_locked; // Will be set to true after every change bool m_dirty; - - bool m_delaySaving; }; } #endif /* IMAGEINFO_H */ // vi:expandtab:tabstop=4 shiftwidth=4: diff --git a/DB/NewImageFinder.cpp b/DB/NewImageFinder.cpp index e2d28b85..d97d2110 100644 --- a/DB/NewImageFinder.cpp +++ b/DB/NewImageFinder.cpp @@ -1,756 +1,755 @@ /* Copyright (C) 2003-2019 The KPhotoAlbum Development Team This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; see the file COPYING. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "NewImageFinder.h" #include "FastDir.h" #include "Logging.h" #include "ImageScout.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace DB; /***************************************************************** * * NOTES ON PERFORMANCE * ===== == =========== * * - Robert Krawitz 2018-05-24 * * * GENERAL NOTES ON STORAGE I/O * ------- ----- -- ------- --- * * The two main gates to loading new images are: * * 1) I/O (how fast can we read images off mass storage) * * Different I/O devices have different characteristics in terms of * througput, media latency, and protocol latency. * * - Throughput is the raw speed at which data can be transferred, * limited by the physical and/or electronic characteristics of * the medium and the interface. Short of reducing the amount of * data that's transferred, or clever games with using the most * efficient part of the medium (the outer tracks only for HDD's, * a practice referred to as "short stroking" because it reduces * the distance the head has to seek, at the cost of wasting a * lot of capacity), there's nothing that can be done about this. * * - Media latency is the latency component due to characteristics * of the underlying storage medium. For spinning disks, this is * a function of rotational latency and sek latency. In some * cases, particularly with hard disks, it is possible to reduce * media latency by arranging to access the data in a way that * reduces seeking. See DB/FastDir.cpp for an example of this. * * While media latency can sometimes be hidden by overlapping * I/O, generally not possible to avoid it. Sometimes trying too * hard can actually increase media latency if it results in I/O * operations competing against each other requiring additional * seeks. * * Overlapping I/O with computation is another matter; that can * easily yield benefit, especially if it eliminates rotational * latency. * * - Protocol latency. This refers to things like SATA overhead, * network overhead (for images stored on a network), and so * forth. This can encompass multiple things, and often they can * be pipelined by means of multiple queued I/O operations. For * example, multiple commands can be issued to modern interfaces * (SATA, NVMe) and many network interfaces without waiting for * earlier operations to return. * * If protocol latency is high compared with media latency, * having multiple requests outstanding simultaneously can * yield significant benefits. * * iostat is a valuable tool for investigating throughput and * looking for possible optimizations. The IO/sec and data * read/written per second when compared against known media * characteristics (disk and SSD throughput, network bandwidth) * provides valuable information about whether we're getting close * to full performance from the I/O, and user and system CPU time * give us additional clues about whether we're I/O-bound or * CPU-bound. * * Historically in the computer field, operations that require * relatively simple processing on large volumes of data are I/O * bound. But with very fast I/O devices such as NVMe SSDs, some * of which reach 3 GB/sec, that's not always the case. * * 2) Image (mostly JPEG) loading. * * This is a function of image characteristics and image processing * libraries. Sometimes it's possible to apply parameters to * the underlying image loader to speed it up. This shows up as user * CPU time. Usually the only way to improve this performance * characteristic is to use more or faster CPU cores (sometimes GPUs * can assist here) or use better image loading routines (better * libraries). * * * DESCRIPTION OF KPHOTOALBUM IMAGE LOAD PROCESS * ----------- -- ----------- ----- ---- ------- * * KPhotoAlbum, when it loads an image, performs three processing steps: * * 1) Compute the MD5 checksum * * 2) Extract the Exif metadata * * 3) Generate a thumbnail * * Previous to this round of performance tuning, the first two steps * were performed in the first pass, and thumbnails were generated in * a separate pass. Assuming that the set of new images is large enough * that they cannot all fit in RAM buffers, this results in the I/O * being performed twice. The rewrite results in I/O being performed once. * * In addition, I have made many other changes: * * 1) Prior to the MD5 calculation step, a new thread, called a "scout * thread", reads the files into memory. While this memory is not * directly used in the later computations, it results in the images * being in RAM when they are later needed, making the I/O very fast * (copying data in memory rather than reading it from storage). * * This is a way to overlap I/O with computation. * * 2) The MD5 checksum uses its own I/O to read the data in in larger * chunks than the Qt MD5 routine does. The Qt routine reads it in * in 4KiB chunks; my experimentation has found that 256KiB chunks * are more efficient, even with a scout thread (it reduces the * number of system calls). * * 3) When searching for other images to stack with the image being * loaded, the new image loader no longer attempts to determine * whether other candidate filenames are present, nor does it * compute the MD5 checksum of any such files it does find. Rather, * it only checks for files that are already in KPhotoAlbum, either * previously or as a result of the current load. Merely checking * for the presence of another file is not cheap, and it's not * necessary; if an image will belong to a stack, we'll either know * it now or when other images that can be stacked are loaded. * * 4) The Exif metadata extraction is now done only once; previously * it was performed several times at different stages of the loading * process. * * 5) The thumbnail index is now written out incrementally rather than * the entire index (which can be many megabytes in a large image * database) being rewritten frequently. The index is fully rewritten * prior to exit. * * * BASELINE PERFORMANCE * -------- ----------- * * These measurements were all taken on a Lenovo ThinkPad P70 with 32 * GB of dual-channel DDR4-2400 DRAM, a Xeon E3-1505M CPU (4 cores/8 * total hyperthreads, 2.8-3.7 GHz Skylake; usually runs around * 3.1-3.2 GHz in practice), a Seagate ST2000LM015-2E8174 2TB HDD, and * a Crucial MX300 1TB SATA SSD. Published numbers and measurements I * took otherwise indicate that the HDD can handle about 105-110 * MB/sec with a maximum of 180 IO/sec (in a favorable case). The SSD * is rated to handle 530 MB/sec read, 510 MB/sec write, 92K random * reads/sec, and 83K random writes/sec. * * The image set I used for all measurements, except as noted, * consists of 10839 total files of which about 85% are 20 MP JPEG and * the remainder (with a few exceptions are 20 MP RAW files from a * Canon EOS 7D mkII camera. The total dataset is about 92 GB in * size. * * I baselined both drives by reading the same dataset by means of * * % ls | xargs cat | dd bs=1048576 of=/dev/null * * The HDD required between 850 and 870 seconds (14'10" to 14'30") to * perform this operation, yielding about 105-108 MB/sec. The SSD * achieved about 271 MB/sec, which is well under its rated throughput * (hdparm -Tt yields 355 MB/sec, which is likewise nowhere close to * its rated throughput). hdparm -Tt on the HDD yields about 120 * MB/sec, but throughput to an HDD depends upon which part of the * disk is being read. The outer tracks have a greater angular * density to achieve the same linear density (in other words, the * circumference of an outer track is longer than that of an inner * track, and the data is stored at a constant linear density). So * hdparm isn't very useful on an HDD except as a best case. * * Note also that hdparm does a single stream read from the device. * It does not take advantage of the ability to queue multiple * requests. * * * ANALYSIS OF KPHOTOALBUM LOAD PERFORMANCE * -------- -- ----------- ---- ----------- * * I analyzed the following cases, with images stored both on the * HDD and the SSD: * * 1) Images loaded (All, JPEG only, RAW only) * * B) Thumbnail creation (Including, Excluding) * * C) Scout threads (0, 1, 2, 3) * * The JPG image set constitutes 9293 images totaling about 55 GB. The * JPEG files are mostly 20 MP high quality files, in the range of * 6-10 MB. * The RAW image set constitutes 1544 images totaling about 37 GB. The * RAW files are 20 MP files, in the range of 25 MB. * The ALL set consists of 10839 or 10840 images totaling about 92 GB * (the above set plus 2 .MOV files and in some cases one additional * JPEG file). * * Times are elapsed times; CPU consumption is approximate user+system * CPU consumption. Numbers in parentheses are with thumbnail * building disabled. Note that in the cases with no scout threads on * the SSD the times were reproducibly shorter with thumbnail building * enabled (reasons are not determined at this time). * * Cases building RAW thumbnails generally consumed somewhat more * system CPU (in the range of 10-15%) than JPEG-only cases. This may * be due to custom I/O routines used for generating thumbnails with * JPEG files; RAW files used the I/O provided by libkdcraw, which * uses smaller I/O operations. * * Estimating CPU time for mixed workloads proved very problematic, * as there were significant changes over time. * * Elapsed Time * ------- ---- * * SSD HDD * * JPG - 0 scouts 4:03 (3:59) * JPG - 1 scout 2:46 (2:44) * JPG - 2 scouts 2:20 (2:07) * JPG - 3 scouts 2:21 (1:58) * * ALL - 0 scouts 6:32 (7:03) 16:01 * ALL - 1 scout 4:33 (4:33) 15:01 * ALL - 2 scouts 3:37 (3:28) 16:59 * ALL - 3 scouts 3:36 (3:15) * * RAW - 0 scouts 2:18 (2:46) * RAW - 1 scout 1:46 (1:46) * RAW - 2 scouts 1:17 (1:17) * RAW - 3 scouts 1:13 (1:13) * * User+System CPU * ----------- --- * * SSD HDD * * JPG - 0 scouts 40% (12%) * JPG - 1 scout 70% (20%) * JPG - 2 scouts 85% (15%) * JPG - 3 scouts 85% (15%) * * RAW - 0 scouts 15% (10%) * RAW - 1 scout 18% (12%) * RAW - 2 scouts 25% (15%) * RAW - 3 scouts 25% (15%) * * I also used kcachegrind to measure CPU consumption on smaller * subsets of images (with and without thumbnail creation). In terms * of user CPU consumption, thumbnail creation constitutes the large * majority of CPU cycles for processing JPEG files, followed by MD5 * computation, with Exif parsing lagging far behind. For RAW files, * MD5 computation consumes more cycles, likely in part due to the * larger size of RAW files but possibly also related to the smaller * filesize of embedded thumbnails (on the Canon 7D mkII, the embedded * thumbnail is full size but low quality). * * With thumbnail generation: * ---- --------- ----------- * * RAW JPEG * * Thumbnail generation 44% 82% * libjpeg processing 43% 82% * MD5 computation 51% 13% * Read Exif 1% 1.0% * * Without thumbnail generation: * ------- --------- ----------- * * RAW JPEG * * MD5 computation 92% 80% * Read Exif 4% 10% * * * CONCLUSIONS * ----------- * * For loading files from hard disk (likely the most common case), * there's no reason to consider any loading method other than using a * single scout thread and computing thumbnails concurrently. Even * with thumbnail computation, there is very little CPU utilization. * * Loading from SATA SSD benefits from two scout threads, and possibly * more. For minimal time to regain control, there is some benefit * seen from separating thumbnail generation from the rest of the * processing stages at the cost of more total elapsed time. This is * more evident with JPEG files than with RAW files in this test case. * RAW files typically have smaller thumbnail images which can be * extracted and processed more quickly than full-size JPEG files. On * a slower CPU, it may be desirable to return control to the user * even if the thumbnails are not built yet. * * Two other cases would be NVMe (or other very fast) SSDs and network * storage. Since we're seeing evidence of CPU saturation on SATA * SSDs, we would likely see this even more strongly with NVMe; with * large numbers of images it may be desirable to separate the * thumbnail building from the rest of the processing. It may also be * beneficial to use more scout threads. * * Network storage presents a different problem. It is likely to have * lower throughput -- and certainly much higher latency -- than even * HDD, unless the underlying storage medium is SSD and the data is * located on a very fast, low latency network. So there would be no * benefit to separating thumbnail processing. However, due to * protocol vs. media latency discussed above, it may well work to use * more scout threads. However, this may saturate the network and the * storage, to the detriment of other users, and there's probably no * general (or easily discoverable) optimum for this. * * It's my judgment that most images will be stored on HDDs for at * least the next few years, so tuning for that use case is probably * the best single choice to be made. * *****************************************************************/ namespace { // Number of scout threads for preloading images. More than one scout thread // yields about 10% less performance with higher IO/sec but lower I/O throughput, // most probably due to thrashing. constexpr int IMAGE_SCOUT_THREAD_COUNT = 1; bool canReadImage( const DB::FileName& fileName ) { bool fastMode = !Settings::SettingsData::instance()->ignoreFileExtension(); QMimeDatabase::MatchMode mode = fastMode ? QMimeDatabase::MatchExtension : QMimeDatabase::MatchDefault; QMimeDatabase db; QMimeType mimeType = db.mimeTypeForFile( fileName.absolute(), mode ); return QImageReader::supportedMimeTypes().contains( mimeType.name().toUtf8() ) || ImageManager::ImageDecoder::mightDecode( fileName ); } } bool NewImageFinder::findImages() { // Load the information from the XML file. DB::FileNameSet loadedFiles; QElapsedTimer timer; timer.start(); // TODO: maybe the databas interface should allow to query if it // knows about an image ? Here we've to iterate through all of them and it // might be more efficient do do this in the database without fetching the // whole info. for ( const DB::FileName& fileName : DB::ImageDB::instance()->images()) { loadedFiles.insert(fileName); } m_pendingLoad.clear(); searchForNewFiles( loadedFiles, Settings::SettingsData::instance()->imageDirectory() ); int filesToLoad = m_pendingLoad.count(); loadExtraFiles(); qCDebug(TimingLog) << "Loaded " << filesToLoad << " images in " << timer.elapsed() / 1000.0 << " seconds"; // Man this is not super optimal, but will be changed onces the image finder moves to become a background task. if ( MainWindow::FeatureDialog::hasVideoThumbnailer() ) { BackgroundTaskManager::JobManager::instance()->addJob( new BackgroundJobs::SearchForVideosWithoutVideoThumbnailsJob ); } // To avoid deciding if the new images are shown in a given thumbnail view or in a given search // we rather just go to home. return (!m_pendingLoad.isEmpty()); // returns if new images was found. } void NewImageFinder::searchForNewFiles( const DB::FileNameSet& loadedFiles, QString directory ) { qApp->processEvents( QEventLoop::AllEvents ); directory = Utilities::stripEndingForwardSlash(directory); const QString imageDir = Utilities::stripEndingForwardSlash(Settings::SettingsData::instance()->imageDirectory()); FastDir dir( directory ); const QStringList dirList = dir.entryList( ); ImageManager::RAWImageDecoder dec; QStringList excluded; excluded << Settings::SettingsData::instance()->excludeDirectories(); excluded = excluded.at(0).split(QString::fromLatin1(",")); bool skipSymlinks = Settings::SettingsData::instance()->skipSymlinks(); // Keep files within a directory more local by processing all files within the // directory, and then all subdirectories. QStringList subdirList; for( QStringList::const_iterator it = dirList.constBegin(); it != dirList.constEnd(); ++it ) { const DB::FileName file = DB::FileName::fromAbsolutePath(directory + QString::fromLatin1("/") + *it); if ( (*it) == QString::fromLatin1(".") || (*it) == QString::fromLatin1("..") || excluded.contains( (*it) ) || loadedFiles.contains( file ) || dec._skipThisFile(loadedFiles, file) || (*it) == QString::fromLatin1("CategoryImages") ) continue; QFileInfo fi( file.absolute() ); if ( !fi.isReadable() ) continue; if ( skipSymlinks && fi.isSymLink() ) continue; if ( fi.isFile() ) { if ( ! DB::ImageDB::instance()->isBlocking( file ) ) { if ( canReadImage(file) ) m_pendingLoad.append( qMakePair( file, DB::Image ) ); else if ( Utilities::isVideo( file ) ) m_pendingLoad.append( qMakePair( file, DB::Video ) ); } } else if ( fi.isDir() ) { subdirList.append( file.absolute() ); } } for( QStringList::const_iterator it = subdirList.constBegin(); it != subdirList.constEnd(); ++it ) searchForNewFiles( loadedFiles, *it ); } void NewImageFinder::loadExtraFiles() { // FIXME: should be converted to a threadpool for SMP stuff and whatnot :] QProgressDialog dialog; QElapsedTimer timeSinceProgressUpdate; dialog.setLabelText( i18n("

Loading information from new files

" "

Depending on the number of images, this may take some time.
" "However, there is only a delay when new images are found.

") ); QProgressBar *progressBar = new QProgressBar; progressBar->setFormat( QLatin1String("%v/%m") ); dialog.setBar(progressBar); dialog.setMaximum( m_pendingLoad.count() ); dialog.setMinimumDuration( 1000 ); QAtomicInt loadedCount = 0; setupFileVersionDetection(); int count = 0; ImageScoutQueue asyncPreloadQueue; for( LoadList::Iterator it = m_pendingLoad.begin(); it != m_pendingLoad.end(); ++it ) { asyncPreloadQueue.enqueue((*it).first); } ImageScout scout(asyncPreloadQueue, loadedCount, IMAGE_SCOUT_THREAD_COUNT); scout.start(); Exif::Database::instance()->startInsertTransaction(); dialog.setValue( count ); // ensure to call setProgress(0) timeSinceProgressUpdate.start(); for( LoadList::Iterator it = m_pendingLoad.begin(); it != m_pendingLoad.end(); ++it, ++count ) { qApp->processEvents( QEventLoop::AllEvents ); if ( dialog.wasCanceled() ) { m_pendingLoad.clear(); Exif::Database::instance()->abortInsertTransaction(); return; } // (*it).first: DB::FileName // (*it).second: DB::MediaType loadExtraFile( (*it).first, (*it).second ); loadedCount++; // Atomic if ( timeSinceProgressUpdate.elapsed() >= 1000 ) { dialog.setValue( count ); timeSinceProgressUpdate.restart(); } } dialog.setValue( count ); // loadExtraFile() has already inserted all images into the // database, but without committing the changes DB::ImageDB::instance()->commitDelayedImages(); Exif::Database::instance()->commitInsertTransaction(); ImageManager::ThumbnailBuilder::instance()->save(); } void NewImageFinder::setupFileVersionDetection() { // should be cached because loading once per image is expensive m_modifiedFileCompString = Settings::SettingsData::instance()->modifiedFileComponent(); m_modifiedFileComponent = QRegExp(m_modifiedFileCompString); m_originalFileComponents << Settings::SettingsData::instance()->originalFileComponent(); m_originalFileComponents = m_originalFileComponents.at(0).split(QString::fromLatin1(";")); } void NewImageFinder::loadExtraFile( const DB::FileName& newFileName, DB::MediaType type ) { MD5 sum = MD5Sum( newFileName ); if ( handleIfImageHasBeenMoved(newFileName, sum) ) return; // check to see if this is a new version of a previous image // We'll get the Exif data later, when we get the MD5 checksum. ImageInfoPtr info = ImageInfoPtr(new ImageInfo( newFileName, type, false, false )); ImageInfoPtr originalInfo; DB::FileName originalFileName; if (Settings::SettingsData::instance()->detectModifiedFiles()) { // requires at least *something* in the modifiedFileComponent if (m_modifiedFileCompString.length() >= 0 && newFileName.relative().contains(m_modifiedFileComponent)) { for( QStringList::const_iterator it = m_originalFileComponents.constBegin(); it != m_originalFileComponents.constEnd(); ++it ) { QString tmp = newFileName.relative(); tmp.replace(m_modifiedFileComponent, (*it)); originalFileName = DB::FileName::fromRelativePath(tmp); MD5 originalSum; if (newFileName == originalFileName) originalSum = sum; else if (DB::ImageDB::instance()->md5Map()->containsFile( originalFileName ) ) originalSum = DB::ImageDB::instance()->md5Map()->lookupFile( originalFileName ); else // Do *not* attempt to compute the checksum here. It forces a filesystem // lookup on a file that may not exist and substantially degrades // performance by about 25% on an SSD and about 30% on a spinning disk. // If one of these other files exist, it will be found later in // the image search at which point we'll detect the modified file. continue; if ( DB::ImageDB::instance()->md5Map()->contains( originalSum ) ) { // we have a previous copy of this file; copy it's data // from the original. originalInfo = DB::ImageDB::instance()->info( originalFileName ); if ( !originalInfo ) { qCDebug(DBLog) << "Original info not found by name for " << originalFileName.absolute() << ", trying by MD5 sum."; originalFileName = DB::ImageDB::instance()->md5Map()->lookup( originalSum ); if (!originalFileName.isNull()) { qCDebug(DBLog) << "Substitute image " << originalFileName.absolute() << " found."; originalInfo = DB::ImageDB::instance()->info( originalFileName ); } if ( !originalInfo ) { qCWarning(DBLog,"How did that happen? We couldn't find info for the original image %s; can't copy the original data to %s", qPrintable(originalFileName.absolute()), qPrintable(newFileName.absolute())); continue; } } info->copyExtraData(*originalInfo); /* if requested to move, then delete old data from original */ if (Settings::SettingsData::instance()->moveOriginalContents() ) { originalInfo->removeExtraData(); } break; } } } } ImageInfoList newImages; newImages.append( info ); DB::ImageDB::instance()->addImages( newImages, false ); // also inserts image into exif db if present: info->setMD5Sum( sum ); DB::ImageDB::instance()->md5Map()->insert( sum, info->fileName()); if (originalInfo && Settings::SettingsData::instance()->autoStackNewFiles() ) { // stack the files together DB::FileName olderfile = originalFileName; DB::FileName newerfile = info->fileName(); DB::FileNameList tostack; // the newest file should go to the top of the stack tostack.append(newerfile); DB::FileNameList oldStack; if ( ( oldStack = DB::ImageDB::instance()->getStackFor( olderfile)).isEmpty() ) { tostack.append(olderfile); } else { for ( const DB::FileName& tmp : oldStack ) { tostack.append( tmp ); } } DB::ImageDB::instance()->stack(tostack); MainWindow::Window::theMainWindow()->setStackHead(newerfile); // ordering: XXX we ideally want to place the new image right // after the older one in the list. } markUnTagged(info); ImageManager::ThumbnailBuilder::instance()->buildOneThumbnail( info ); if ( info->isVideo() && MainWindow::FeatureDialog::hasVideoThumbnailer() ) { // needs to be done *after* insertion into database BackgroundTaskManager::JobManager::instance()->addJob( new BackgroundJobs::ReadVideoLengthJob(info->fileName(), BackgroundTaskManager::BackgroundVideoPreviewRequest)); } } bool NewImageFinder::handleIfImageHasBeenMoved(const FileName &newFileName, const MD5& sum) { if ( DB::ImageDB::instance()->md5Map()->contains( sum ) ) { const DB::FileName matchedFileName = DB::ImageDB::instance()->md5Map()->lookup(sum); QFileInfo fi( matchedFileName.absolute() ); if ( !fi.exists() ) { // The file we had a collapse with didn't exists anymore so it is likely moved to this new name ImageInfoPtr info = DB::ImageDB::instance()->info( matchedFileName); if ( !info ) qCWarning(DBLog, "How did that happen? We couldn't find info for the images %s", qPrintable(matchedFileName.relative())); else { - info->delaySavingChanges(true); fi = QFileInfo ( matchedFileName.relative() ); if ( info->label() == fi.completeBaseName() ) { fi = QFileInfo( newFileName.absolute() ); info->setLabel( fi.completeBaseName() ); } DB::ImageDB::instance()->renameImage( info, newFileName ); // We need to insert the new name into the MD5 map, // as it is a map, the value for the moved file will automatically be deleted. DB::ImageDB::instance()->md5Map()->insert( sum, info->fileName()); Exif::Database::instance()->remove( matchedFileName ); Exif::Database::instance()->add( newFileName); ImageManager::ThumbnailBuilder::instance()->buildOneThumbnail( info ); return true; } } } return false; // The image wasn't just moved } bool NewImageFinder::calculateMD5sums( const DB::FileNameList& list, DB::MD5Map* md5Map, bool* wasCanceled) { // FIXME: should be converted to a threadpool for SMP stuff and whatnot :] QProgressDialog dialog; dialog.setLabelText( i18np("

Calculating checksum for %1 file

","

Calculating checksums for %1 files

", list.size()) + i18n("

By storing a checksum for each image " "KPhotoAlbum is capable of finding images " "even when you have moved them on the disk.

")); dialog.setMaximum(list.size()); dialog.setMinimumDuration( 1000 ); int count = 0; DB::FileNameList cantRead; bool dirty = false; for (const FileName& fileName : list) { if ( count % 10 == 0 ) { dialog.setValue( count ); // ensure to call setProgress(0) qApp->processEvents( QEventLoop::AllEvents ); if ( dialog.wasCanceled() ) { if ( wasCanceled ) *wasCanceled = true; return dirty; } } MD5 md5 = MD5Sum( fileName ); if (md5.isNull()) { cantRead << fileName; continue; } ImageInfoPtr info = ImageDB::instance()->info(fileName); if ( info->MD5Sum() != md5 ) { info->setMD5Sum( md5 ); dirty = true; ImageManager::ThumbnailCache::instance()->removeThumbnail(fileName); } md5Map->insert( md5, fileName ); ++count; } if ( wasCanceled ) *wasCanceled = false; if ( !cantRead.empty() ) KMessageBox::informationList( nullptr, i18n("Following files could not be read:"), cantRead.toStringList(DB::RelativeToImageRoot) ); return dirty; } void DB::NewImageFinder::markUnTagged( ImageInfoPtr info ) { if ( Settings::SettingsData::instance()->hasUntaggedCategoryFeatureConfigured() ) { info->addCategoryInfo( Settings::SettingsData::instance()->untaggedCategory(), Settings::SettingsData::instance()->untaggedTag() ); } } // vi:expandtab:tabstop=4 shiftwidth=4: diff --git a/XMLDB/Database.cpp b/XMLDB/Database.cpp index 7b5b6aef..d83aa8e5 100644 --- a/XMLDB/Database.cpp +++ b/XMLDB/Database.cpp @@ -1,797 +1,796 @@ /* Copyright (C) 2003-2019 The KPhotoAlbum Development Team This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; see the file COPYING. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "Database.h" #include "FileReader.h" #include "FileWriter.h" #include "XMLCategory.h" #include "XMLImageDateCollection.h" #include "Logging.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using Utilities::StringSet; namespace { void checkForBackupFile( const QString& fileName, DB::UIDelegate &ui) { QString backupName = QFileInfo( fileName ).absolutePath() + QString::fromLatin1("/.#") + QFileInfo( fileName ).fileName(); QFileInfo backUpFile( backupName); QFileInfo indexFile( fileName ); if ( !backUpFile.exists() || indexFile.lastModified() > backUpFile.lastModified() || backUpFile.size() == 0 ) return; const long backupSizeKB = backUpFile.size() >> 10; const DB::UserFeedback choice = ui.questionYesNo( QString::fromUtf8("Autosave file found: '%1', %2KB.").arg(backupName).arg(backupSizeKB) , i18n("Autosave file '%1' exists (size %3 KB) and is newer than '%2'. " "Should the autosave file be used?", backupName, fileName, backupSizeKB) , i18n("Found Autosave File") ); if ( choice == DB::UserFeedback::Confirm ) { qCInfo(XMLDBLog) << "Using autosave file:" << backupName; QFile in( backupName ); if ( in.open( QIODevice::ReadOnly ) ) { QFile out( fileName ); if (out.open( QIODevice::WriteOnly ) ) { char data[1024]; int len; while ( (len = in.read( data, 1024 ) ) ) out.write( data, len ); } } } } } // namespace bool XMLDB::Database::s_anyImageWithEmptySize = false; XMLDB::Database::Database(const QString& configFile , DB::UIDelegate &delegate) : ImageDB(delegate) , m_fileName(configFile) { checkForBackupFile( configFile, uiDelegate() ); FileReader reader( this ); reader.read( configFile ); m_nextStackId = reader.nextStackId(); connect( categoryCollection(), SIGNAL(itemRemoved(DB::Category*,QString)), this, SLOT(deleteItem(DB::Category*,QString)) ); connect( categoryCollection(), SIGNAL(itemRenamed(DB::Category*,QString,QString)), this, SLOT(renameItem(DB::Category*,QString,QString)) ); connect( categoryCollection(), SIGNAL(itemRemoved(DB::Category*,QString)), &m_members, SLOT(deleteItem(DB::Category*,QString)) ); connect( categoryCollection(), SIGNAL(itemRenamed(DB::Category*,QString,QString)), &m_members, SLOT(renameItem(DB::Category*,QString,QString)) ); connect( categoryCollection(), SIGNAL(categoryRemoved(QString)), &m_members, SLOT(deleteCategory(QString))); } uint XMLDB::Database::totalCount() const { return m_images.count(); } /** * I was considering merging the two calls to this method (one for images, one for video), but then I * realized that all the work is really done after the check for whether the given * imageInfo is of the right type, and as a match can't be both, this really * would buy me nothing. */ QMap XMLDB::Database::classify(const DB::ImageSearchInfo& info, const QString &category, DB::MediaType typemask , DB::ClassificationMode mode) { QElapsedTimer timer; timer.start(); QMap map; DB::GroupCounter counter( category ); Utilities::StringSet alreadyMatched = info.findAlreadyMatched( category ); DB::ImageSearchInfo noMatchInfo = info; QString currentMatchTxt = noMatchInfo.categoryMatchText( category ); if ( currentMatchTxt.isEmpty() ) noMatchInfo.setCategoryMatchText( category, DB::ImageDB::NONE() ); else noMatchInfo.setCategoryMatchText( category, QString::fromLatin1( "%1 & %2" ).arg(currentMatchTxt).arg(DB::ImageDB::NONE()) ); noMatchInfo.setCacheable( false ); // Iterate through the whole database of images. for (const auto &imageInfo : m_images) { bool match = ( (imageInfo)->mediaType() & typemask ) && !(imageInfo)->isLocked() && info.match( imageInfo ) && rangeInclude( imageInfo ); if ( match ) { // If the given image is currently matched. // Now iterate through all the categories the current image // contains, and increase them in the map mapping from category // to count. StringSet items = (imageInfo)->itemsOfCategory(category); counter.count( items, imageInfo->date() ); for (const auto &categoryName: items) { if ( !alreadyMatched.contains(categoryName) ) // We do not want to match "Jesper & Jesper" map[categoryName].add(imageInfo->date()); } // Find those with no other matches if ( noMatchInfo.match( imageInfo ) ) map[DB::ImageDB::NONE()].count++; // this is a shortcut for the browser overview page, // where we are only interested whether there are sub-categories to a category if (mode == DB::ClassificationMode::PartialCount && map.size()>1) { qCInfo(TimingLog) << "Database::classify(partial): " << timer.restart() << "ms."; return map; } } } QMap groups = counter.result(); for( QMap::iterator it= groups.begin(); it != groups.end(); ++it ) { map[it.key()] = it.value(); } qCInfo(TimingLog) << "Database::classify(): " << timer.restart() << "ms."; return map; } void XMLDB::Database::renameCategory( const QString& oldName, const QString newName ) { for( DB::ImageInfoListIterator it = m_images.begin(); it != m_images.end(); ++it ) { (*it)->renameCategory( oldName, newName ); } } void XMLDB::Database::addToBlockList(const DB::FileNameList& list) { Q_FOREACH(const DB::FileName& fileName, list) { m_blockList.insert(fileName); } deleteList( list ); } void XMLDB::Database::deleteList(const DB::FileNameList& list) { Q_FOREACH(const DB::FileName& fileName, list) { DB::ImageInfoPtr inf = fileName.info(); StackMap::iterator found = m_stackMap.find(inf->stackId()); if ( inf->isStacked() && found != m_stackMap.end() ) { const DB::FileNameList origCache = found.value(); DB::FileNameList newCache; Q_FOREACH(const DB::FileName& cacheName, origCache) { if (fileName != cacheName) newCache.append(cacheName); } if (newCache.size() <= 1) { // we're destroying a stack Q_FOREACH(const DB::FileName& cacheName, newCache) { DB::ImageInfoPtr cacheInf = cacheName.info(); cacheInf->setStackId(0); cacheInf->setStackOrder(0); } m_stackMap.remove( inf->stackId() ); } else { m_stackMap.insert(inf->stackId(), newCache); } } m_imageCache.remove( inf->fileName().absolute() ); m_images.remove( inf ); } Exif::Database::instance()->remove( list ); emit totalChanged( m_images.count() ); emit imagesDeleted(list); emit dirty(); } void XMLDB::Database::renameItem( DB::Category* category, const QString& oldName, const QString& newName ) { for( DB::ImageInfoListIterator it = m_images.begin(); it != m_images.end(); ++it ) { (*it)->renameItem( category->name(), oldName, newName ); } } void XMLDB::Database::deleteItem( DB::Category* category, const QString& value ) { for( DB::ImageInfoListIterator it = m_images.begin(); it != m_images.end(); ++it ) { (*it)->removeCategoryInfo( category->name(), value ); } } void XMLDB::Database::lockDB( bool lock, bool exclude ) { DB::ImageSearchInfo info = Settings::SettingsData::instance()->currentLock(); for( DB::ImageInfoListIterator it = m_images.begin(); it != m_images.end(); ++it ) { if ( lock ) { bool match = info.match( *it ); if ( !exclude ) match = !match; (*it)->setLocked( match ); } else (*it)->setLocked( false ); } } void XMLDB::Database::clearDelayedImages() { m_delayedCache.clear(); m_delayedUpdate.clear(); } void XMLDB::Database::forceUpdate( const DB::ImageInfoList& images ) { // FIXME: merge stack information DB::ImageInfoList newImages = images.sort(); if ( m_images.count() == 0 ) { // case 1: The existing imagelist is empty. Q_FOREACH( const DB::ImageInfoPtr& imageInfo, newImages ) m_imageCache.insert( imageInfo->fileName().absolute(), imageInfo ); m_images = newImages; } else if ( newImages.count() == 0 ) { // case 2: No images to merge in - that's easy ;-) return; } else if ( newImages.first()->date().start() > m_images.last()->date().start() ) { // case 2: The new list is later than the existsing Q_FOREACH( const DB::ImageInfoPtr& imageInfo, newImages ) m_imageCache.insert( imageInfo->fileName().absolute(), imageInfo ); m_images.appendList(newImages); } else if ( m_images.isSorted() ) { // case 3: The lists overlaps, and the existsing list is sorted Q_FOREACH( const DB::ImageInfoPtr& imageInfo, newImages ) m_imageCache.insert( imageInfo->fileName().absolute(), imageInfo ); m_images.mergeIn( newImages ); } else{ // case 4: The lists overlaps, and the existsing list is not sorted in the overlapping range. Q_FOREACH( const DB::ImageInfoPtr& imageInfo, newImages ) m_imageCache.insert( imageInfo->fileName().absolute(), imageInfo ); m_images.appendList( newImages ); } } void XMLDB::Database::addImages( const DB::ImageInfoList& images, bool doUpdate ) { Q_FOREACH( const DB::ImageInfoPtr& info, images ) { info->addCategoryInfo( i18n( "Media Type" ), info->mediaType() == DB::Image ? i18n( "Image" ) : i18n( "Video" ) ); m_delayedCache.insert( info->fileName().absolute(), info ); m_delayedUpdate << info; } if ( doUpdate ) { commitDelayedImages(); } } void XMLDB::Database::commitDelayedImages() { uint imagesAdded = m_delayedUpdate.count(); if ( imagesAdded > 0 ) { forceUpdate(m_delayedUpdate); m_delayedCache.clear(); m_delayedUpdate.clear(); // It's the responsibility of the caller to add the Exif information. // It's more efficient from an I/O perspective to minimize the number // of passes over the images, and with the ability to add the Exif // data in a transaction, there's no longer any need to read it here. emit totalChanged( m_images.count() ); emit dirty(); } } void XMLDB::Database::renameImage( DB::ImageInfoPtr info, const DB::FileName& newName ) { - info->delaySavingChanges(false); info->setFileName(newName); } DB::ImageInfoPtr XMLDB::Database::info( const DB::FileName& fileName ) const { if ( fileName.isNull() ) return DB::ImageInfoPtr(); const QString name = fileName.absolute(); if (m_imageCache.contains( name )) return m_imageCache[name]; if (m_delayedCache.contains( name )) return m_delayedCache[name]; Q_FOREACH( const DB::ImageInfoPtr& imageInfo, m_images ) m_imageCache.insert( imageInfo->fileName().absolute(), imageInfo ); if ( m_imageCache.contains( name ) ) { return m_imageCache[ name ]; } return DB::ImageInfoPtr(); } bool XMLDB::Database::rangeInclude( DB::ImageInfoPtr info ) const { if (m_selectionRange.start().isNull() ) return true; DB::ImageDate::MatchType tp = info->date().isIncludedIn( m_selectionRange ); if ( m_includeFuzzyCounts ) return ( tp == DB::ImageDate::ExactMatch || tp == DB::ImageDate::RangeMatch ); else return ( tp == DB::ImageDate::ExactMatch ); } DB::MemberMap& XMLDB::Database::memberMap() { return m_members; } void XMLDB::Database::save( const QString& fileName, bool isAutoSave ) { FileWriter saver( this ); saver.save( fileName, isAutoSave ); } DB::MD5Map* XMLDB::Database::md5Map() { return &m_md5map; } bool XMLDB::Database::isBlocking( const DB::FileName& fileName ) { return m_blockList.contains( fileName ); } DB::FileNameList XMLDB::Database::images() { return m_images.files(); } DB::FileNameList XMLDB::Database::search( const DB::ImageSearchInfo& info, bool requireOnDisk) const { return searchPrivate( info, requireOnDisk, true ); } DB::FileNameList XMLDB::Database::searchPrivate( const DB::ImageSearchInfo& info, bool requireOnDisk, bool onlyItemsMatchingRange) const { // When searching for images counts for the datebar, we want matches outside the range too. // When searching for images for the thumbnail view, we only want matches inside the range. DB::FileNameList result; for( DB::ImageInfoListConstIterator it = m_images.constBegin(); it != m_images.constEnd(); ++it ) { bool match = !(*it)->isLocked() && info.match( *it ) && ( !onlyItemsMatchingRange || rangeInclude( *it )); match &= !requireOnDisk || DB::ImageInfo::imageOnDisk( (*it)->fileName() ); if (match) result.append((*it)->fileName()); } return result; } void XMLDB::Database::sortAndMergeBackIn(const DB::FileNameList& fileNameList) { DB::ImageInfoList infoList; Q_FOREACH( const DB::FileName &fileName, fileNameList ) infoList.append(fileName.info()); m_images.sortAndMergeBackIn(infoList); } DB::CategoryCollection* XMLDB::Database::categoryCollection() { return &m_categoryCollection; } QExplicitlySharedDataPointer XMLDB::Database::rangeCollection() { return QExplicitlySharedDataPointer( new XMLImageDateCollection( searchPrivate( Browser::BrowserWidget::instance()->currentContext(), false, false))); } void XMLDB::Database::reorder( const DB::FileName& item, const DB::FileNameList& selection, bool after) { Q_ASSERT(!item.isNull()); DB::ImageInfoList list = takeImagesFromSelection(selection); insertList(item, list, after ); } // Remove all the images from the database that match the given selection and // return that sublist. // This returns the selected and erased images in the order in which they appear // in the image list itself. DB::ImageInfoList XMLDB::Database::takeImagesFromSelection(const DB::FileNameList& selection) { DB::ImageInfoList result; if (selection.isEmpty()) return result; // iterate over all images (expensive!!) TODO: improve? for( DB::ImageInfoListIterator it = m_images.begin(); it != m_images.end(); /**/ ) { const DB::FileName imagefile = (*it)->fileName(); DB::FileNameList::const_iterator si = selection.begin(); // for each image, iterate over selection, break on match for ( /**/; si != selection.end(); ++si ) { const DB::FileName file = *si; if ( imagefile == file ) { break; } } // if image is not in selection, simply advance to next, if not add to result and erase if (si == selection.end()) { ++it; } else { result << *it; m_imageCache.remove( (*it)->fileName().absolute() ); it = m_images.erase(it); } // if all images from selection are in result (size of lists is equal) break. if (result.size() == selection.size()) break; } return result; } void XMLDB::Database::insertList( const DB::FileName& fileName, const DB::ImageInfoList& list, bool after) { DB::ImageInfoListIterator imageIt = m_images.begin(); for( ; imageIt != m_images.end(); ++imageIt ) { if ( (*imageIt)->fileName() == fileName ) { break; } } // since insert() inserts before iterator increment when inserting AFTER image if ( after ) imageIt++; for( DB::ImageInfoListConstIterator it = list.begin(); it != list.end(); ++it ) { // the call to insert() destroys the given iterator so use the new one after the call imageIt = m_images.insert( imageIt, *it ); m_imageCache.insert( (*it)->fileName().absolute(), *it ); // increment always to retain order of selected images imageIt++; } emit dirty(); } bool XMLDB::Database::stack(const DB::FileNameList& items) { unsigned int changed = 0; QSet stacks; QList images; unsigned int stackOrder = 1; Q_FOREACH(const DB::FileName& fileName, items) { DB::ImageInfoPtr imgInfo = fileName.info(); Q_ASSERT( imgInfo ); if ( imgInfo->isStacked() ) { stacks << imgInfo->stackId(); stackOrder = qMax( stackOrder, imgInfo->stackOrder() + 1 ); } else { images << imgInfo; } } if ( stacks.size() > 1 ) return false; // images already in different stacks -> can't stack DB::StackID stackId = ( stacks.size() == 1 ) ? *(stacks.begin() ) : m_nextStackId++; Q_FOREACH( DB::ImageInfoPtr info, images ) { info->setStackOrder( stackOrder ); info->setStackId( stackId ); m_stackMap[stackId].append(info->fileName()); ++changed; ++stackOrder; } if ( changed ) emit dirty(); return changed; } void XMLDB::Database::unstack(const DB::FileNameList& items) { Q_FOREACH(const DB::FileName& fileName, items) { DB::FileNameList allInStack = getStackFor(fileName); if (allInStack.size() <= 2) { // we're destroying stack here Q_FOREACH(const DB::FileName& stackFileName, allInStack) { DB::ImageInfoPtr imgInfo = stackFileName.info(); Q_ASSERT( imgInfo ); if ( imgInfo->isStacked() ) { m_stackMap.remove( imgInfo->stackId() ); imgInfo->setStackId( 0 ); imgInfo->setStackOrder( 0 ); } } } else { DB::ImageInfoPtr imgInfo = fileName.info(); Q_ASSERT( imgInfo ); if ( imgInfo->isStacked() ) { m_stackMap[imgInfo->stackId()].removeAll(fileName); imgInfo->setStackId( 0 ); imgInfo->setStackOrder( 0 ); } } } if (!items.isEmpty()) emit dirty(); } DB::FileNameList XMLDB::Database::getStackFor(const DB::FileName& referenceImg) const { DB::ImageInfoPtr imageInfo = info( referenceImg ); if ( !imageInfo || ! imageInfo->isStacked() ) return DB::FileNameList(); StackMap::iterator found = m_stackMap.find(imageInfo->stackId()); if ( found != m_stackMap.end() ) return found.value(); // it wasn't in the cache -> rebuild it m_stackMap.clear(); for( DB::ImageInfoListConstIterator it = m_images.constBegin(); it != m_images.constEnd(); ++it ) { if ( (*it)->isStacked() ) { DB::StackID stackid = (*it)->stackId(); m_stackMap[stackid].append((*it)->fileName()); } } found = m_stackMap.find(imageInfo->stackId()); if ( found != m_stackMap.end() ) return found.value(); else return DB::FileNameList(); } void XMLDB::Database::copyData(const DB::FileName &from, const DB::FileName &to) { (*info(to)).merge(*info(from)); } int XMLDB::Database::fileVersion() { // File format version, bump it up every time the format for the file changes. return 8; } // During profiling of loading, I found that a significant amount of time was spent in QDateTime::fromString. // Reviewing the code, I fount that it did a lot of extra checks we don't need (like checking if the string have // timezone information (which they won't in KPA), this function is a replacement that is faster than the original. QDateTime dateTimeFromString(const QString& str) { static QChar T = QChar::fromLatin1('T'); if ( str[10] == T) return QDateTime(QDate::fromString(str.left(10), Qt::ISODate),QTime::fromString(str.mid(11),Qt::ISODate)); else return QDateTime::fromString(str,Qt::ISODate); } DB::ImageInfoPtr XMLDB::Database::createImageInfo( const DB::FileName& fileName, ReaderPtr reader, Database* db, const QMap *newToOldCategory ) { static QString _label_ = QString::fromUtf8("label"); static QString _description_ = QString::fromUtf8("description"); static QString _startDate_ = QString::fromUtf8("startDate"); static QString _endDate_ = QString::fromUtf8("endDate"); static QString _yearFrom_ = QString::fromUtf8("yearFrom"); static QString _monthFrom_ = QString::fromUtf8("monthFrom"); static QString _dayFrom_ = QString::fromUtf8("dayFrom"); static QString _hourFrom_ = QString::fromUtf8("hourFrom"); static QString _minuteFrom_ = QString::fromUtf8("minuteFrom"); static QString _secondFrom_ = QString::fromUtf8("secondFrom"); static QString _yearTo_ = QString::fromUtf8("yearTo"); static QString _monthTo_ = QString::fromUtf8("monthTo"); static QString _dayTo_ = QString::fromUtf8("dayTo"); static QString _angle_ = QString::fromUtf8("angle"); static QString _md5sum_ = QString::fromUtf8("md5sum"); static QString _width_ = QString::fromUtf8("width"); static QString _height_ = QString::fromUtf8("height"); static QString _rating_ = QString::fromUtf8("rating"); static QString _stackId_ = QString::fromUtf8("stackId"); static QString _stackOrder_ = QString::fromUtf8("stackOrder"); static QString _videoLength_ = QString::fromUtf8("videoLength"); static QString _options_ = QString::fromUtf8("options"); static QString _0_ = QString::fromUtf8("0"); static QString _minus1_ = QString::fromUtf8("-1"); static QString _MediaType_ = i18n("Media Type"); static QString _Image_ = i18n("Image"); static QString _Video_ = i18n("Video"); QString label; if (reader->hasAttribute(_label_)) label = reader->attribute(_label_); else label = QFileInfo(fileName.relative()).completeBaseName(); QString description; if ( reader->hasAttribute(_description_) ) description = reader->attribute(_description_); DB::ImageDate date; if ( reader->hasAttribute(_startDate_) ) { QDateTime start; QString str = reader->attribute( _startDate_ ); if ( !str.isEmpty() ) start = dateTimeFromString( str ); str = reader->attribute( _endDate_ ); if ( !str.isEmpty() ) date = DB::ImageDate( start, dateTimeFromString(str) ); else date = DB::ImageDate( start ); } else { int yearFrom = 0, monthFrom = 0, dayFrom = 0, yearTo = 0, monthTo = 0, dayTo = 0, hourFrom = -1, minuteFrom = -1, secondFrom = -1; yearFrom = reader->attribute( _yearFrom_, _0_ ).toInt(); monthFrom = reader->attribute( _monthFrom_, _0_ ).toInt(); dayFrom = reader->attribute( _dayFrom_, _0_ ).toInt(); hourFrom = reader->attribute( _hourFrom_, _minus1_ ).toInt(); minuteFrom = reader->attribute( _minuteFrom_, _minus1_ ).toInt(); secondFrom = reader->attribute( _secondFrom_, _minus1_ ).toInt(); yearTo = reader->attribute( _yearTo_, _0_ ).toInt(); monthTo = reader->attribute( _monthTo_, _0_ ).toInt(); dayTo = reader->attribute( _dayTo_, _0_ ).toInt(); date = DB::ImageDate( yearFrom, monthFrom, dayFrom, yearTo, monthTo, dayTo, hourFrom, minuteFrom, secondFrom ); } int angle = reader->attribute( _angle_, _0_).toInt(); DB::MD5 md5sum(reader->attribute( _md5sum_ )); s_anyImageWithEmptySize |= !reader->hasAttribute(_width_); int w = reader->attribute( _width_ , _minus1_ ).toInt(); int h = reader->attribute( _height_ , _minus1_ ).toInt(); QSize size = QSize( w,h ); DB::MediaType mediaType = Utilities::isVideo(fileName) ? DB::Video : DB::Image; short rating = reader->attribute( _rating_, _minus1_ ).toShort(); DB::StackID stackId = reader->attribute( _stackId_, _0_ ).toULong(); unsigned int stackOrder = reader->attribute( _stackOrder_, _0_ ).toULong(); DB::ImageInfo* info = new DB::ImageInfo( fileName, label, description, date, angle, md5sum, size, mediaType, rating, stackId, stackOrder ); if ( reader->hasAttribute(_videoLength_)) info->setVideoLength(reader->attribute(_videoLength_).toInt()); DB::ImageInfoPtr result(info); possibleLoadCompressedCategories( reader, result, db, newToOldCategory ); while( reader->readNextStartOrStopElement(_options_).isStartToken) { readOptions( result, reader, newToOldCategory ); } info->addCategoryInfo( _MediaType_, info->mediaType() == DB::Image ? _Image_ : _Video_ ); return result; } void XMLDB::Database::readOptions( DB::ImageInfoPtr info, ReaderPtr reader, const QMap *newToOldCategory ) { static QString _name_ = QString::fromUtf8("name"); static QString _value_ = QString::fromUtf8("value"); static QString _option_ = QString::fromUtf8("option"); static QString _area_ = QString::fromUtf8("area"); while (reader->readNextStartOrStopElement(_option_).isStartToken) { QString name = FileReader::unescape( reader->attribute(_name_) ); // If the silent update to db version 6 has been done, use the updated category names. if (newToOldCategory) { name = newToOldCategory->key(name,name); } if ( !name.isNull() ) { // Read values while (reader->readNextStartOrStopElement(_value_).isStartToken) { QString value = reader->attribute(_value_); if (reader->hasAttribute(_area_)) { QStringList areaData = reader->attribute(_area_).split(QString::fromUtf8(" ")); int x = areaData[0].toInt(); int y = areaData[1].toInt(); int w = areaData[2].toInt(); int h = areaData[3].toInt(); QRect area = QRect(QPoint(x, y), QPoint(x + w - 1, y + h - 1)); if (! value.isNull()) { info->addCategoryInfo(name, value, area); } } else { if (! value.isNull()) { info->addCategoryInfo(name, value); } } reader->readEndElement(); } } } } void XMLDB::Database::possibleLoadCompressedCategories( ReaderPtr reader, DB::ImageInfoPtr info, Database* db, const QMap *newToOldCategory ) { if ( db == nullptr ) return; Q_FOREACH( const DB::CategoryPtr categoryPtr, db->m_categoryCollection.categories() ) { QString categoryName = categoryPtr->name(); QString oldCategoryName; if ( newToOldCategory ) { // translate to old categoryName, defaulting to the original name if not found: oldCategoryName = newToOldCategory->value( categoryName, categoryName ); } else { oldCategoryName = categoryName; } QString str = reader->attribute( FileWriter::escape( oldCategoryName ) ); if ( !str.isEmpty() ) { QStringList list = str.split(QString::fromLatin1( "," ), QString::SkipEmptyParts ); Q_FOREACH( const QString &tagString, list ) { int id = tagString.toInt(); QString name = static_cast(categoryPtr.data())->nameForId(id); info->addCategoryInfo( categoryName, name ); } } } } // vi:expandtab:tabstop=4 shiftwidth=4: