1 /* ============================================================
2  *
3  * This file is a part of digiKam project
4  * https://www.digikam.org
5  *
6  * Date        : 2000-12-05
7  * Description : helper class used to modify tag albums in views
8  *
9  * Copyright (C) 2009-2010 by Johannes Wienke <languitar at semipol dot de>
10  * Copyright (C) 2010-2021 by Gilles Caulier <caulier dot gilles at gmail dot com>
11  *
12  * This program is free software; you can redistribute it
13  * and/or modify it under the terms of the GNU General
14  * Public License as published by the Free Software Foundation;
15  * either version 2, or (at your option)
16  * any later version.
17  *
18  * This program is distributed in the hope that it will be useful,
19  * but WITHOUT ANY WARRANTY; without even the implied warranty of
20  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21  * GNU General Public License for more details.
22  *
23  * ============================================================ */
24 
25 #include "tagmodificationhelper.h"
26 
27 // Qt includes
28 
29 #include <QApplication>
30 #include <QAction>
31 #include <QMessageBox>
32 
33 // KDE includes
34 
35 #include <klocalizedstring.h>
36 
37 // Local includes
38 
39 #include "digikam_debug.h"
40 #include "album.h"
41 #include "albumpointer.h"
42 #include "coredb.h"
43 #include "coredbtransaction.h"
44 #include "iteminfo.h"
45 #include "itemtagpair.h"
46 #include "metadatahub.h"
47 #include "scancontroller.h"
48 #include "statusprogressbar.h"
49 #include "tagsactionmngr.h"
50 #include "tagproperties.h"
51 #include "tageditdlg.h"
52 #include "facetags.h"
53 #include "facedbaccess.h"
54 #include "facedb.h"
55 
56 namespace Digikam
57 {
58 
59 class Q_DECL_HIDDEN TagModificationHelper::Private
60 {
61 public:
62 
Private()63     explicit Private()
64       : parentTag   (nullptr),
65         dialogParent(nullptr)
66     {
67     }
68 
69     AlbumPointer<TAlbum>  parentTag;
70     QWidget*              dialogParent;
71 };
72 
TagModificationHelper(QObject * const parent,QWidget * const dialogParent)73 TagModificationHelper::TagModificationHelper(QObject* const parent, QWidget* const dialogParent)
74     : QObject(parent),
75       d      (new Private)
76 {
77     d->dialogParent = dialogParent;
78 }
79 
~TagModificationHelper()80 TagModificationHelper::~TagModificationHelper()
81 {
82     delete d;
83 }
84 
bindTag(QAction * action,TAlbum * album) const85 void TagModificationHelper::bindTag(QAction* action, TAlbum* album) const
86 {
87     action->setData(QVariant::fromValue(AlbumPointer<TAlbum>(album)));
88 }
89 
boundTag(QObject * sender) const90 TAlbum* TagModificationHelper::boundTag(QObject* sender) const
91 {
92     QAction* action = nullptr;
93 
94     if ((action = qobject_cast<QAction*>(sender)))
95     {
96         return action->data().value<AlbumPointer<TAlbum> >();
97     }
98 
99     return nullptr;
100 }
101 
bindMultipleTags(QAction * action,const QList<TAlbum * > & tags)102 void TagModificationHelper::bindMultipleTags(QAction* action, const QList<TAlbum*>& tags)
103 {
104     action->setData(QVariant::fromValue(tags));
105 }
106 
boundMultipleTags(QObject * sender)107 QList<TAlbum*> TagModificationHelper::boundMultipleTags(QObject* sender)
108 {
109     QAction* action = nullptr;
110 
111     if ((action = qobject_cast<QAction*>(sender)))
112     {
113         return (action->data().value<QList<TAlbum*> >());
114     }
115 
116     return QList<TAlbum*>();
117 }
118 
slotTagNew(TAlbum * parent,const QString & title,const QString & iconName)119 TAlbum* TagModificationHelper::slotTagNew(TAlbum* parent, const QString& title, const QString& iconName)
120 {
121     // ensure that there is a parent
122 
123     AlbumPointer<TAlbum> p(parent);
124 
125     if (!p)
126     {
127         p = AlbumManager::instance()->findTAlbum(0);
128 
129         if (!p)
130         {
131             qCDebug(DIGIKAM_GENERAL_LOG) << "Could not find root tag album";
132             return nullptr;
133         }
134     }
135 
136     QString      editTitle    = title;
137     QString      editIconName = iconName;
138     QKeySequence ks;
139 
140     if (title.isEmpty())
141     {
142         bool doCreate = TagEditDlg::tagCreate(d->dialogParent, p, editTitle, editIconName, ks);
143 
144         if (!doCreate || !p)
145         {
146             return nullptr;
147         }
148     }
149 
150     QMap<QString, QString> errMap;
151     AlbumList tList = TagEditDlg::createTAlbum(p, editTitle, editIconName, ks, errMap);
152     TagEditDlg::showtagsListCreationError(d->dialogParent, errMap);
153 
154     if (errMap.isEmpty() && !tList.isEmpty())
155     {
156         TAlbum* tag = nullptr;
157 
158         foreach (Album* const album, tList)
159         {
160             tag = static_cast<TAlbum*>(album);
161             emit tagCreated(tag);
162         }
163 
164         return tag;
165     }
166     else
167     {
168         return nullptr;
169     }
170 }
171 
slotTagNew()172 TAlbum* TagModificationHelper::slotTagNew()
173 {
174     return slotTagNew(boundTag(sender()));
175 }
176 
slotTagEdit(TAlbum * t)177 void TagModificationHelper::slotTagEdit(TAlbum* t)
178 {
179     if (!t)
180     {
181         return;
182     }
183 
184     AlbumPointer<TAlbum> tag(t);
185 
186     QString      title, icon;
187     QKeySequence ks;
188 
189     bool doEdit = TagEditDlg::tagEdit(d->dialogParent, tag, title, icon, ks);
190 
191     if (!doEdit || !tag)
192     {
193         return;
194     }
195 
196     if (tag->title() != title)
197     {
198         QString errMsg;
199 
200         if (AlbumManager::instance()->renameTAlbum(tag, title, errMsg))
201         {
202             // TODO: make an option to edit the full name of a face tag
203 
204             if (FaceTags::isPerson(tag->id()))
205             {
206                 TagProperties props(tag->id());
207                 props.setProperty(TagPropertyName::person(),         title);
208                 props.setProperty(TagPropertyName::faceEngineName(), title);
209             }
210         }
211         else
212         {
213             QMessageBox::critical(qApp->activeWindow(), qApp->applicationName(), errMsg);
214         }
215     }
216 
217     if (tag->icon() != icon)
218     {
219         QString errMsg;
220 
221         if (!AlbumManager::instance()->updateTAlbumIcon(tag, icon, 0, errMsg))
222         {
223             QMessageBox::critical(qApp->activeWindow(), qApp->applicationName(), errMsg);
224         }
225     }
226 
227     if (tag->property(TagPropertyName::tagKeyboardShortcut()) != ks.toString())
228     {
229         TagsActionMngr::defaultManager()->updateTagShortcut(tag->id(), ks);
230     }
231 
232     emit tagEdited(tag);
233 }
234 
slotTagEdit()235 void TagModificationHelper::slotTagEdit()
236 {
237     slotTagEdit(boundTag(sender()));
238 }
239 
slotTagDelete(TAlbum * t)240 void TagModificationHelper::slotTagDelete(TAlbum* t)
241 {
242     if (!t || t->isRoot())
243     {
244         return;
245     }
246 
247     AlbumPointer<TAlbum> tag(t);
248 
249     // find number of subtags
250 
251     int children = 0;
252     AlbumIterator iter(tag);
253 
254     while (iter.current())
255     {
256         ++children;
257         ++iter;
258     }
259 
260     // ask for deletion of children
261 
262     if (children)
263     {
264         int result = QMessageBox::warning(d->dialogParent, qApp->applicationName(),
265                                           i18np("Tag '%2' has one subtag. "
266                                                 "Deleting this will also delete "
267                                                 "the subtag.\n"
268                                                 "Do you want to continue?",
269                                                 "Tag '%2' has %1 subtags. "
270                                                 "Deleting this will also delete "
271                                                 "the subtags.\n"
272                                                 "Do you want to continue?",
273                                                 children,
274                                                 tag->title()),
275                                           QMessageBox::Yes | QMessageBox::Cancel);
276 
277         if ((result != QMessageBox::Yes) || !tag)
278         {
279             return;
280         }
281     }
282 
283     QString message;
284     QList<qlonglong> assignedItems = CoreDbAccess().db()->getItemIDsInTag(tag->id());
285 
286     if (!assignedItems.isEmpty())
287     {
288         message = i18np("Tag '%2' is assigned to one item. "
289                         "Do you want to continue?",
290                         "Tag '%2' is assigned to %1 items. "
291                         "Do you want to continue?",
292                         assignedItems.count(), tag->title());
293     }
294     else
295     {
296         message = i18n("Delete '%1' tag?", tag->title());
297     }
298 
299     int result = QMessageBox::warning(qApp->activeWindow(), i18n("Delete Tag"),
300                                       message,
301                                       QMessageBox::Yes | QMessageBox::Cancel);
302 
303     if (result == QMessageBox::Yes && tag)
304     {
305         emit aboutToDeleteTag(tag);
306         QString errMsg;
307 
308         if (!AlbumManager::instance()->deleteTAlbum(tag, errMsg))
309         {
310             QMessageBox::critical(qApp->activeWindow(), qApp->applicationName(), errMsg);
311         }
312     }
313 }
314 
slotTagDelete()315 void TagModificationHelper::slotTagDelete()
316 {
317     slotTagDelete(boundTag(sender()));
318 }
319 
slotMultipleTagDel(const QList<TAlbum * > & tags)320 void TagModificationHelper::slotMultipleTagDel(const QList<TAlbum*>& tags)
321 {
322     QString tagWithChildrens;
323     QString tagWithoutImages;
324     QString tagWithImages;
325     QMultiMap<int, TAlbum*> sortedTags;
326 
327     foreach (TAlbum* const t, tags)
328     {
329         if (!t || t->isRoot())
330         {
331             continue;
332         }
333 
334         AlbumPointer<TAlbum> tag(t);
335 
336         // find number of subtags
337 
338         int children = 0;
339         AlbumIterator iter(tag);
340 
341         while (iter.current())
342         {
343             ++children;
344             ++iter;
345         }
346 
347         if (children)
348         {
349             tagWithChildrens.append(tag->title() + QLatin1Char(' '));
350         }
351 
352         QList<qlonglong> assignedItems = CoreDbAccess().db()->getItemIDsInTag(tag->id());
353 
354         if (!assignedItems.isEmpty())
355         {
356             tagWithImages.append(tag->title() + QLatin1Char(' '));
357         }
358         else
359         {
360             tagWithoutImages.append(tag->title() + QLatin1Char(' '));
361         }
362 
363         /**
364          * Tags must be deleted from children to parents, if we don't want
365          * to step on invalid index. Use QMultiMap to order them by distance
366          * to root tag
367          */
368 
369         Album* parent = t;
370         int depth     = 0;
371 
372         while (!parent->isRoot())
373         {
374             parent = parent->parent();
375             depth++;
376         }
377 
378         sortedTags.insert(depth, tag);
379     }
380 
381     // ask for deletion of children
382 
383     if (!tagWithChildrens.isEmpty())
384     {
385         int result = QMessageBox::warning(qApp->activeWindow(), qApp->applicationName(),
386                                           i18n("Tags '%1' have one or more subtags. "
387                                                "Deleting them will also delete "
388                                                "the subtags.\n"
389                                                "Do you want to continue?",
390                                                tagWithChildrens),
391                                           QMessageBox::Yes | QMessageBox::Cancel);
392 
393         if (result != QMessageBox::Yes)
394         {
395             return;
396         }
397     }
398 
399     QString message;
400 
401     if (!tagWithImages.isEmpty())
402     {
403         message = i18n("Tags '%1' are assigned to one or more items. "
404                         "Do you want to continue?",
405                         tagWithImages);
406     }
407     else
408     {
409         message = i18n("Delete '%1' tag(s)?", tagWithoutImages);
410     }
411 
412     int result = QMessageBox::warning(qApp->activeWindow(), i18n("Delete Tag"),
413                                       message,
414                                       QMessageBox::Yes | QMessageBox::Cancel);
415 
416     if (result == QMessageBox::Yes)
417     {
418         QMultiMap<int, TAlbum*>::iterator it;
419 
420         /**
421          * QMultimap doesn't provide reverse iterator, -1 is required
422          * because end() points after the last element
423          */
424 
425         for (it = sortedTags.end()-1 ; it != sortedTags.begin()-1 ; --it)
426         {
427             emit aboutToDeleteTag(it.value());
428             QString errMsg;
429 
430             if (!AlbumManager::instance()->deleteTAlbum(it.value(), errMsg))
431             {
432                 QMessageBox::critical(qApp->activeWindow(), qApp->applicationName(), errMsg);
433             }
434         }
435     }
436 }
437 
slotMultipleTagDel()438 void TagModificationHelper::slotMultipleTagDel()
439 {
440     QList<TAlbum*> lst = boundMultipleTags(sender());
441     qCDebug(DIGIKAM_GENERAL_LOG) << lst.size();
442     slotMultipleTagDel(lst);
443 }
444 
slotFaceTagDelete(TAlbum * t)445 void TagModificationHelper::slotFaceTagDelete(TAlbum* t)
446 {
447     QList<TAlbum*> tag;
448     tag.append(t);
449     slotMultipleFaceTagDel(tag);
450 }
451 
slotFaceTagDelete()452 void TagModificationHelper::slotFaceTagDelete()
453 {
454     slotFaceTagDelete(boundTag(sender()));
455 }
456 
slotMultipleFaceTagDel(const QList<TAlbum * > & tags)457 void TagModificationHelper::slotMultipleFaceTagDel(const QList<TAlbum*>& tags)
458 {
459     QString tagsWithChildren;
460     QString tagsWithImages;
461 
462     // We use a set here since else one tag could occur more than once
463     // which could lead to undefined behaviour.
464 
465     QList<TAlbum*> allPersonTagsToDelete;
466     int tagsWithChildrenCount = 0;
467     QList<qlonglong> allAssignedItems;
468     int tagsWithImagesCount   = 0;
469 
470     foreach (TAlbum* const selectedTag, tags)
471     {
472         if (!selectedTag                                            ||
473             selectedTag->isRoot()                                   ||
474             (selectedTag->id() == FaceTags::unknownPersonTagId())   ||
475             (selectedTag->id() == FaceTags::ignoredPersonTagId())   ||
476             (selectedTag->id() == FaceTags::unconfirmedPersonTagId()))
477         {
478             continue;
479         }
480 
481         // find tags and subtags with person property
482 
483         QList<TAlbum*> personTagsToDelete = getFaceTags(selectedTag);
484 
485         // If there is more than one person tag in the list,
486         // the tag to remove has at least one sub tag that is a face tag.
487         // Thus, we have to warn.
488         //
489         // If there is only one face tag, it is either the original tag,
490         // or it is the only sub tag of the original tag.
491         // Behave, like the face tag itself was selected.
492 
493         if (personTagsToDelete.size() > 1)
494         {
495             if (tagsWithChildrenCount > 0)
496             {
497                 tagsWithChildren.append(QLatin1String(" , "));
498             }
499 
500             tagsWithChildren.append(selectedTag->title());
501             ++tagsWithChildrenCount;
502         }
503 
504         // Get the assigned faces for all person tags to delete
505 
506         foreach (TAlbum* const tAlbum, personTagsToDelete)
507         {
508             // If the global set does not yet contain the tag
509 
510             if (!allPersonTagsToDelete.contains(tAlbum))
511             {
512                 QList<qlonglong> assignedItems = CoreDbAccess().db()->getImagesWithImageTagProperty(
513                     tAlbum->id(), ImageTagPropertyName::tagRegion());
514 
515                 QList<qlonglong> autodetected  = CoreDbAccess().db()->getImagesWithImageTagProperty(
516                     tAlbum->id(), ImageTagPropertyName::autodetectedFace());
517 
518                 foreach (const qlonglong& id1, autodetected)
519                 {
520                     if (!assignedItems.contains(id1))
521                     {
522                         assignedItems << id1;
523                     }
524                 }
525 
526                 if (!assignedItems.isEmpty())
527                 {
528                     // Add the items to the global set for potential untagging
529 
530                     foreach (const qlonglong& id2, assignedItems)
531                     {
532                         if (!allAssignedItems.contains(id2))
533                         {
534                             allAssignedItems << id2;
535                         }
536                     }
537 
538                     if (tagsWithImagesCount > 0)
539                     {
540                         tagsWithImages.append(QLatin1String(" , "));
541                     }
542 
543                     tagsWithImages.append(tAlbum->title());
544                     ++tagsWithImagesCount;
545                 }
546             }
547         }
548 
549         // Add the found tags to the global set.
550 
551         foreach (TAlbum* const album, personTagsToDelete)
552         {
553             if (!allPersonTagsToDelete.contains(album))
554             {
555                 allPersonTagsToDelete << album;
556             }
557         }
558     }
559 
560     if (allPersonTagsToDelete.isEmpty() && allAssignedItems.isEmpty())
561     {
562         return;
563     }
564 
565     // ask for deletion of children
566 
567     if (tagsWithChildrenCount)
568     {
569         QString message = i18np("Face tag '%2' has at least one face tag child. "
570                                 "Removing it will also remove the children.\n"
571                                 "Do you want to continue?",
572                                 "Face tags '%2' have at least one face tag child. "
573                                 "Removing it will also remove the children.\n"
574                                 "Do you want to continue?",
575                                 tagsWithChildrenCount, tagsWithChildren);
576 
577         bool removeChildren = (QMessageBox::Yes == QMessageBox::warning(qApp->activeWindow(),
578                                                      qApp->applicationName(), message,
579                                                      QMessageBox::Yes | QMessageBox::Cancel));
580 
581         if (!removeChildren)
582         {
583             return;
584         }
585     }
586 
587     QString message;
588 
589     if (!allAssignedItems.isEmpty())
590     {
591         message = i18np("Face tag '%2' is assigned to at least one item. "
592                         "Do you want to continue?",
593                         "Face tags '%2' are assigned to at least one item. "
594                         "Do you want to continue?",
595                         tagsWithImagesCount, tagsWithImages);
596     }
597     else
598     {
599         message = i18np("Remove face tag?", "Remove face tags?", tags.size());
600     }
601 
602     bool removeFaceTag = (QMessageBox::Yes == QMessageBox::warning(qApp->activeWindow(),
603                                                 qApp->applicationName(), message,
604                                                 QMessageBox::Yes | QMessageBox::Cancel));
605 
606     if (removeFaceTag)
607     {
608         // Now we ask the user if we should also remove the tags from the images.
609 
610         QString msg = i18np("Remove the tag corresponding to this face tag from the images?",
611                             "Remove the %1 tags corresponding to this face tags from the images?",
612                             allPersonTagsToDelete.size());
613 
614         bool removeTagFromImages = (QMessageBox::Yes == QMessageBox::warning(qApp->activeWindow(),
615                                                           qApp->applicationName(), msg,
616                                                           QMessageBox::Yes | QMessageBox::No));
617 
618         MetadataHub metadataHub;
619 
620         // remove the face region from images and unassign the tag if wished
621 
622         foreach (const qlonglong& imageId, allAssignedItems)
623         {
624             foreach (TAlbum* const tagToRemove, allPersonTagsToDelete)
625             {
626                 ItemTagPair imageTagAssociation(imageId, tagToRemove->id());
627 
628                 if (imageTagAssociation.isAssigned())
629                 {
630                     imageTagAssociation.removeProperties(ImageTagPropertyName::autodetectedFace());
631                     imageTagAssociation.removeProperties(ImageTagPropertyName::tagRegion());
632 
633                     if (removeTagFromImages)
634                     {
635                         imageTagAssociation.unAssignTag();
636 
637                         // Load the current metadata and sync the tags
638 
639                         ItemInfo info(imageId);
640 
641                         if (!info.isNull())
642                         {
643                             metadataHub.load(info);
644 
645                             if (!metadataHub.writeToMetadata(info))
646                             {
647                                 qCWarning(DIGIKAM_GENERAL_LOG) << "Tags in image not changed:" << info.filePath();
648                             }
649                         }
650                     }
651                 }
652             }
653         }
654 
655         foreach (TAlbum* const tAlbum, allPersonTagsToDelete)
656         {
657             TagProperties props(tAlbum->id());
658 
659             // Delete TagPropertyName::person() and TagPropertyName::faceEngineName()
660             // fetch the UUID to delete the identity from facesdb
661 
662             props.removeProperties(TagPropertyName::person());
663             props.removeProperties(TagPropertyName::faceEngineName());
664             QString uuid = props.value(TagPropertyName::faceEngineUuid());
665             qCDebug(DIGIKAM_GENERAL_LOG) << "Remove person tag properties for tag "
666                                          << tAlbum->title() << " with uuid " << uuid;
667 
668             if (!uuid.isEmpty())
669             {
670                 // Delete the UUID
671 
672                 props.removeProperties(TagPropertyName::faceEngineUuid());
673 
674                 // delete the faces db identity with this uuid.
675 
676                 FaceDbAccess access;
677                 access.db()->deleteIdentity(uuid);
678             }
679 
680             // reset tag icon
681 
682             QString errMsg;
683             AlbumManager::instance()->updateTAlbumIcon(tAlbum, tAlbum->standardIconName(), 0, errMsg);
684         }
685     }
686 }
687 
slotMultipleFaceTagDel()688 void TagModificationHelper::slotMultipleFaceTagDel()
689 {
690     QList<TAlbum*> lst = boundMultipleTags(sender());
691     qCDebug(DIGIKAM_GENERAL_LOG) << lst.size();
692     slotMultipleFaceTagDel(lst);
693 }
694 
slotTagToFaceTag(TAlbum * tAlbum)695 void TagModificationHelper::slotTagToFaceTag(TAlbum* tAlbum)
696 {
697     if (!tAlbum)
698     {
699         return;
700     }
701 
702     if (!FaceTags::isPerson(tAlbum->id()))
703     {
704         FaceTags::ensureIsPerson(tAlbum->id());
705 
706         // reset tag icon
707 
708         QString errMsg;
709         AlbumManager::instance()->updateTAlbumIcon(tAlbum, tAlbum->standardIconName(), 0, errMsg);
710     }
711 }
712 
slotTagToFaceTag()713 void TagModificationHelper::slotTagToFaceTag()
714 {
715     slotTagToFaceTag(boundTag(sender()));
716 }
717 
slotMultipleTagsToFaceTags(const QList<TAlbum * > & tags)718 void TagModificationHelper::slotMultipleTagsToFaceTags(const QList<TAlbum*>& tags)
719 {
720     foreach (TAlbum* const selectedTag, tags)
721     {
722         slotTagToFaceTag(selectedTag);
723     }
724 }
725 
slotMultipleTagsToFaceTags()726 void TagModificationHelper::slotMultipleTagsToFaceTags()
727 {
728     QList<TAlbum*> lst = boundMultipleTags(sender());
729     qCDebug(DIGIKAM_GENERAL_LOG) << lst.size();
730     slotMultipleTagsToFaceTags(lst);
731 }
732 
getFaceTags(TAlbum * rootTag)733 QList<TAlbum*> TagModificationHelper::getFaceTags(TAlbum* rootTag)
734 {
735     if (!rootTag)
736     {
737         return QList<TAlbum*>();
738     }
739 
740     QList<TAlbum*> tags;
741     tags.append(rootTag);
742 
743     return getFaceTags(tags).values();
744 }
745 
getFaceTags(const QList<TAlbum * > & tags)746 QSet<TAlbum*> TagModificationHelper::getFaceTags(const QList<TAlbum*>& tags)
747 {
748     QSet<TAlbum*> faceTags;
749 
750     foreach (TAlbum* const tAlbum, tags)
751     {
752         if (FaceTags::isPerson(tAlbum->id()))
753         {
754             faceTags.insert(tAlbum);
755         }
756 
757         AlbumPointer<TAlbum> tag(tAlbum);
758         AlbumIterator iter(tag);
759 
760         // Get all child tags which have the person property.
761 
762         while (iter.current())
763         {
764             Album* const album    = iter.current();
765             TAlbum* const tAlbum2 = dynamic_cast<TAlbum*>(album);
766 
767             // Make sure that no nullp pointer dereference is done.
768             // though while(iter.current()) already tests for the current
769             // album being true, i.e. > 0 , i.e. non-null
770 
771             if (tAlbum2 && FaceTags::isPerson(tAlbum2->id()))
772             {
773                 faceTags.insert(tAlbum2);
774             }
775 
776             ++iter;
777         }
778     }
779 
780     return faceTags;
781 }
782 
783 } // namespace Digikam
784