File indexing completed on 2024-04-28 15:40:25

0001 // SPDX-FileCopyrightText: 2003 - 2020 The KPhotoAlbum Development Team
0002 // SPDX-FileCopyrightText: 2021 - 2023 Johannes Zarl-Zierl <johannes@zarl-zierl.at>
0003 //
0004 // SPDX-License-Identifier: GPL-2.0-or-later
0005 
0006 #include "DescriptionUtil.h"
0007 
0008 #include <DB/CategoryCollection.h>
0009 #include <DB/ImageDB.h>
0010 #include <kpabase/Logging.h>
0011 #include <kpabase/SettingsData.h>
0012 #include <kpaexif/Info.h>
0013 
0014 #include <KLocalizedString>
0015 #include <QList>
0016 #include <QTextCodec>
0017 #include <QUrl>
0018 
0019 namespace
0020 {
0021 const QLatin1String LINE_BREAK("<br/>");
0022 }
0023 
0024 /**
0025  * Add a line label + info text to the result text if info is not empty.
0026  * If the result already contains something, a HTML newline is added first.
0027  * To be used in createInfoText().
0028  */
0029 static void AddNonEmptyInfo(const QString &label, const QString &infoText, QString *result)
0030 {
0031     if (infoText.isEmpty()) {
0032         return;
0033     }
0034     if (!result->isEmpty()) {
0035         *result += LINE_BREAK;
0036     }
0037     result->append(label).append(infoText);
0038 }
0039 
0040 /**
0041  * Given an ImageInfoPtr this function will create an HTML blob about the
0042  * image. The blob is used in the viewer and in the tool tip box from the
0043  * thumbnail view.
0044  *
0045  * As the HTML text is created, the parameter linkMap is filled with
0046  * information about hyperlinks. The map maps from an index to a pair of
0047  * (categoryName, categoryItem). This linkMap is used when the user selects
0048  * one of the hyberlinks.
0049  */
0050 QString Utilities::createInfoText(DB::ImageInfoPtr info, QMap<int, QPair<QString, QString>> *linkMap)
0051 {
0052     Q_ASSERT(info);
0053 
0054     QString result;
0055     if (Settings::SettingsData::instance()->showFilename()) {
0056         AddNonEmptyInfo(i18n("<b>File Name: </b> "), info->fileName().relative(), &result);
0057     }
0058 
0059     if (Settings::SettingsData::instance()->showDate()) {
0060         QString dateString = info->date().toString(Settings::SettingsData::instance()->showTime() ? true : false);
0061         dateString.append(timeAgo(info));
0062         AddNonEmptyInfo(i18n("<b>Date: </b> "), dateString, &result);
0063     }
0064 
0065     /* XXX */
0066     if (Settings::SettingsData::instance()->showImageSize() && info->mediaType() == DB::Image) {
0067         const QSize imageSize = info->size();
0068         // Do not add -1 x -1 text
0069         if (imageSize.width() >= 0 && imageSize.height() >= 0) {
0070             const double megapix = imageSize.width() * imageSize.height() / 1000000.0;
0071             QString infoText = i18nc("width x height", "%1x%2", QString::number(imageSize.width()), QString::number(imageSize.height()));
0072             if (megapix > 0.05) {
0073                 infoText += i18nc("short for: x megapixels", " (%1MP)", QString::number(megapix, 'f', 1));
0074             }
0075             const double aspect = (double)imageSize.width() / (double)imageSize.height();
0076             // 0.995 - 1.005 can still be considered quadratic
0077             if (aspect > 1.005)
0078                 infoText += i18nc("aspect ratio", " (%1:1)", QLocale::system().toString(aspect, 'f', 2));
0079             else if (aspect >= 0.995)
0080                 infoText += i18nc("aspect ratio", " (1:1)");
0081             else
0082                 infoText += i18nc("aspect ratio", " (1:%1)", QLocale::system().toString(1.0 / aspect, 'f', 2));
0083             AddNonEmptyInfo(i18n("<b>Image Size: </b> "), infoText, &result);
0084         }
0085     }
0086 
0087     if (Settings::SettingsData::instance()->showRating()) {
0088         if (info->rating() != -1) {
0089             if (!result.isEmpty())
0090                 result += QString::fromLatin1("<br/>");
0091             QUrl rating;
0092             rating.setScheme(QString::fromLatin1("kratingwidget"));
0093             // we don't use the host part, but if we don't set it, we can't use port:
0094             rating.setHost(QString::fromLatin1("int"));
0095             rating.setPort(qMin(qMax(static_cast<short int>(0), info->rating()), static_cast<short int>(10)));
0096             result += QString::fromLatin1("<img src=\"%1\"/>").arg(rating.toString(QUrl::None));
0097         }
0098     }
0099 
0100     const QList<DB::CategoryPtr> categories = DB::ImageDB::instance()->categoryCollection()->categories();
0101     int link = 0;
0102     for (const DB::CategoryPtr &category : categories) {
0103         const QString categoryName = category->name();
0104         if (category->doShow()) {
0105             StringSet items = info->itemsOfCategory(categoryName);
0106 
0107             if (DB::ImageDB::instance()->untaggedCategoryFeatureConfigured()
0108                 && !Settings::SettingsData::instance()->untaggedImagesTagVisible()) {
0109 
0110                 if (categoryName == Settings::SettingsData::instance()->untaggedCategory()) {
0111                     if (items.contains(Settings::SettingsData::instance()->untaggedTag())) {
0112                         items.remove(Settings::SettingsData::instance()->untaggedTag());
0113                     }
0114                 }
0115             }
0116 
0117             if (!items.empty()) {
0118                 QString title = QString::fromUtf8("<b>%1: </b> ").arg(category->name());
0119                 QString infoText;
0120                 bool first = true;
0121                 for (const QString &item : qAsConst(items)) {
0122                     if (first)
0123                         first = false;
0124                     else
0125                         infoText += QString::fromLatin1(", ");
0126 
0127                     if (linkMap) {
0128                         ++link;
0129                         (*linkMap)[link] = QPair<QString, QString>(categoryName, item);
0130                         infoText += QString::fromLatin1("<a href=\"%1\">%2</a>").arg(link).arg(item);
0131                         infoText += formatAge(category, item, info);
0132                     } else
0133                         infoText += item;
0134                 }
0135                 AddNonEmptyInfo(title, infoText, &result);
0136             }
0137         }
0138     }
0139 
0140     if (Settings::SettingsData::instance()->showLabel()) {
0141         AddNonEmptyInfo(i18n("<b>Label: </b> "), info->label(), &result);
0142     }
0143 
0144     if (Settings::SettingsData::instance()->showDescription() && !info->description().trimmed().isEmpty()) {
0145         AddNonEmptyInfo(i18n("<b>Description: </b> "), info->description(),
0146                         &result);
0147     }
0148 
0149     QString exifText;
0150     if (Settings::SettingsData::instance()->showEXIF()) {
0151         typedef QMap<QString, QStringList> ExifMap;
0152         typedef ExifMap::const_iterator ExifMapIterator;
0153         ExifMap exifMap = Exif::Info::instance()->infoForViewer(info->fileName(), Settings::SettingsData::instance()->iptcCharset());
0154 
0155         for (ExifMapIterator exifIt = exifMap.constBegin(); exifIt != exifMap.constEnd(); ++exifIt) {
0156             if (exifIt.key().startsWith(QString::fromLatin1("Exif.")))
0157                 for (QStringList::const_iterator valuesIt = exifIt.value().constBegin(); valuesIt != exifIt.value().constEnd(); ++valuesIt) {
0158                     QString exifName = exifIt.key().split(QChar::fromLatin1('.')).last();
0159                     AddNonEmptyInfo(QString::fromLatin1("<b>%1: </b> ").arg(exifName),
0160                                     *valuesIt, &exifText);
0161                 }
0162         }
0163 
0164         QString iptcText;
0165         for (ExifMapIterator exifIt = exifMap.constBegin(); exifIt != exifMap.constEnd(); ++exifIt) {
0166             if (!exifIt.key().startsWith(QString::fromLatin1("Exif.")))
0167                 for (QStringList::const_iterator valuesIt = exifIt.value().constBegin(); valuesIt != exifIt.value().constEnd(); ++valuesIt) {
0168                     QString iptcName = exifIt.key().split(QChar::fromLatin1('.')).last();
0169                     AddNonEmptyInfo(QString::fromLatin1("<b>%1: </b> ").arg(iptcName),
0170                                     *valuesIt, &iptcText);
0171                 }
0172         }
0173 
0174         if (!iptcText.isEmpty()) {
0175             if (exifText.isEmpty())
0176                 exifText = iptcText;
0177             else
0178                 exifText += QString::fromLatin1("<hr>") + iptcText;
0179         }
0180     }
0181 
0182     if (!result.isEmpty() && !exifText.isEmpty())
0183         result += QString::fromLatin1("<hr>");
0184     result += exifText;
0185 
0186     return result;
0187 }
0188 
0189 namespace
0190 {
0191 enum class TimeUnit {
0192     /** Denotes a negative age. */
0193     Invalid,
0194     Days,
0195     Months,
0196     Years
0197 };
0198 class AgeSpec
0199 {
0200 public:
0201     /**
0202      * @brief The I18nContext enum determines how an age is displayed.
0203      */
0204     enum class I18nContext {
0205         /// For birthdays, e.g. "Jesper was 30 years in this image".
0206         Birthday,
0207         /// For ages of events, e.g. "This image was taken 30 years ago".
0208         Anniversary
0209     };
0210     int age; ///< The number of \c units, e.g. the "5" in "5 days"
0211     TimeUnit unit;
0212 
0213     AgeSpec();
0214     AgeSpec(int age, TimeUnit unit);
0215 
0216     /**
0217      * @brief format
0218      * @param context the context where the formatted age is used.
0219      * @return a localized string describing the time range.
0220      */
0221     QString format(I18nContext context) const;
0222     /**
0223      * @brief isValid
0224      * @return \c true, if the AgeSpec contains a valid age that is not negative. \c false otherwise.
0225      */
0226     bool isValid() const;
0227     bool operator==(const AgeSpec &other) const;
0228 };
0229 
0230 AgeSpec::AgeSpec()
0231     : age(70)
0232     , unit(TimeUnit::Invalid)
0233 {
0234 }
0235 
0236 AgeSpec::AgeSpec(int age, TimeUnit unit)
0237     : age(age)
0238     , unit(unit)
0239 {
0240 }
0241 
0242 QString AgeSpec::format(I18nContext context) const
0243 {
0244     switch (unit) {
0245     case TimeUnit::Invalid:
0246         return {};
0247     case TimeUnit::Days:
0248         if (context == I18nContext::Birthday)
0249             return i18ncp("As in 'The baby is 1 day old'", "1 day", "%1 days", age);
0250         else
0251             return i18ncp("As in 'This happened 1 day ago'", "1 day ago", "%1 days ago", age);
0252     case TimeUnit::Months:
0253         if (context == I18nContext::Birthday)
0254             return i18ncp("As in 'The baby is 1 month old'", "1 month", "%1 months", age);
0255         else
0256             return i18ncp("As in 'This happened 1 month ago'", "1 month ago", "%1 months ago", age);
0257     case TimeUnit::Years:
0258         if (context == I18nContext::Birthday)
0259             return i18ncp("As in 'The baby is 1 year old'", "1 year", "%1 years", age);
0260         else
0261             return i18ncp("As in 'This happened 1 year ago'", "1 year ago", "%1 years ago", age);
0262     }
0263     Q_ASSERT(false);
0264     return {};
0265 }
0266 
0267 bool AgeSpec::isValid() const
0268 {
0269     return unit != TimeUnit::Invalid;
0270 }
0271 
0272 bool AgeSpec::operator==(const AgeSpec &other) const
0273 {
0274     return (age == other.age && unit == other.unit);
0275 }
0276 
0277 /**
0278  * @brief dateDifference computes the difference between two dates with an appropriate unit.
0279  * It can be used to generate human readable date differences,
0280  * e.g. "6 months" instead of "0.5 years".
0281  *
0282  * @param priorDate
0283  * @param laterDate
0284  * @return a DateSpec with appropriate scale.
0285  */
0286 AgeSpec dateDifference(const QDate &priorDate, const QDate &laterDate)
0287 {
0288     const int priorDay = priorDate.day();
0289     const int laterDay = laterDate.day();
0290     const int priorMonth = priorDate.month();
0291     const int laterMonth = laterDate.month();
0292     const int priorYear = priorDate.year();
0293     const int laterYear = laterDate.year();
0294 
0295     // Image before birth
0296     const int days = priorDate.daysTo(laterDate);
0297     if (days < 0)
0298         return {};
0299 
0300     if (days < 31)
0301         return { days, TimeUnit::Days };
0302 
0303     int months = (laterYear - priorYear) * 12;
0304     months += (laterMonth - priorMonth);
0305     months += (laterDay >= priorDay) ? 0 : -1;
0306 
0307     if (months < 24)
0308         return { months, TimeUnit::Months };
0309     else
0310         return { months / 12, TimeUnit::Years };
0311 }
0312 
0313 #ifdef TEST_DATEDIFF
0314 
0315 bool compare(const QString &actual, const QString &expected)
0316 {
0317     const bool isEqual = (actual == expected);
0318     if (!isEqual) {
0319         qDebug() << "Expected:" << expected << ", got:" << actual;
0320     }
0321     return isEqual;
0322 }
0323 void testDateDifference()
0324 {
0325     using namespace Utilities;
0326     bool passed = true;
0327     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1971, 7, 11)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("0 days"));
0328     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1971, 8, 10)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("30 days"));
0329     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1971, 8, 11)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("1 month"));
0330     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1971, 8, 12)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("1 month"));
0331     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1971, 9, 10)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("1 month"));
0332     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1971, 9, 11)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("2 month"));
0333     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1972, 6, 10)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("10 month"));
0334     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1972, 6, 11)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("11 month"));
0335     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1972, 6, 12)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("11 month"));
0336     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1972, 7, 10)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("11 month"));
0337     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1972, 7, 11)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("12 month"));
0338     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1972, 7, 12)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("12 month"));
0339     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1972, 12, 11)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("17 month"));
0340     passed &= compare(dateDifference(QDate(1971, 7, 11), QDate(1973, 7, 11)).format(AgeSpec::I18nContext::Birthday), QString::fromLatin1("2 years"));
0341     Q_ASSERT(passed);
0342     qDebug() << "Tested dateDifference without problems.";
0343 }
0344 #endif
0345 }
0346 
0347 QString Utilities::formatAge(DB::CategoryPtr category, const QString &item, DB::ImageInfoPtr info)
0348 {
0349 #ifdef TEST_DATEDIFF
0350     testDateDifference(); // I wish I could get my act together to set up a test suite.
0351 #endif
0352     const QDate birthDate = category->birthDate(item);
0353     const QDate start = info->date().start().date();
0354     const QDate end = info->date().end().date();
0355 
0356     if (birthDate.isNull() || !info->date().isValid())
0357         return {};
0358 
0359     const AgeSpec minAge = dateDifference(birthDate, start);
0360     const AgeSpec maxAge = dateDifference(birthDate, end);
0361 
0362     if (!minAge.isValid() && !maxAge.isValid())
0363         return {}; // This is for example a person on an image before their birth
0364     if (minAge == maxAge)
0365         return i18n(" (%1)", minAge.format(AgeSpec::I18nContext::Birthday));
0366     else if (!minAge.isValid())
0367         return i18n(" (&lt; %1)", maxAge.format(AgeSpec::I18nContext::Birthday));
0368     else {
0369         if (minAge.unit == maxAge.unit)
0370             return i18nc("E.g. ' (1-2 years)'", " (%1-%2)", minAge.age, maxAge.format(AgeSpec::I18nContext::Birthday));
0371         else
0372             return i18nc("E.g. ' (7 months-1 year)'", " (%1-%2)", minAge.format(AgeSpec::I18nContext::Birthday), maxAge.format(AgeSpec::I18nContext::Birthday));
0373     }
0374 }
0375 
0376 QString Utilities::timeAgo(const DB::ImageInfoPtr info)
0377 {
0378     const QDate startDate = info->date().start().date();
0379     const QDate endDate = info->date().end().date();
0380     const QDate today = QDate::currentDate();
0381 
0382     if (!info->date().isValid())
0383         return {};
0384 
0385     const AgeSpec minTimeAgo = dateDifference(endDate, today);
0386     const AgeSpec maxTimeAgo = dateDifference(startDate, today);
0387     if (!minTimeAgo.isValid()) {
0388         return {};
0389     }
0390     if (minTimeAgo == maxTimeAgo) {
0391         return i18n(" (%1)", minTimeAgo.format(AgeSpec::I18nContext::Anniversary));
0392     } else {
0393         if (minTimeAgo.unit == maxTimeAgo.unit)
0394             return i18nc("E.g. ' (1-2 years ago)'", " (%1-%2)", minTimeAgo.age, maxTimeAgo.format(AgeSpec::I18nContext::Anniversary));
0395         else
0396             return i18nc("E.g. '(7 months ago-1 year ago)'", " (%1-%2)", minTimeAgo.format(AgeSpec::I18nContext::Anniversary), maxTimeAgo.format(AgeSpec::I18nContext::Anniversary));
0397     }
0398 }