File indexing completed on 2024-05-19 09:22:09

0001 /*
0002  *   SPDX-FileCopyrightText: 2006 Aaron Seigo <aseigo@kde.org>
0003  *   SPDX-FileCopyrightText: 2010 Marco Martin <notmart@gmail.com>
0004  *   SPDX-FileCopyrightText: 2015 Vishesh Handa <vhanda@kde.org>
0005  *   SPDX-FileCopyrightText: 2022 Natalie Clarius <natalie_clarius@yahoo.de>
0006  *
0007  *   SPDX-License-Identifier: LGPL-2.0-only
0008  */
0009 
0010 #include "datetimerunner.h"
0011 
0012 #include <KFormat>
0013 #include <KLocalizedString>
0014 
0015 #include <QDateTime>
0016 #include <QIcon>
0017 #include <QLocale>
0018 #include <QRegularExpression>
0019 #include <QTimeZone>
0020 
0021 #include <math.h>
0022 
0023 static const QString dateWord = i18nc("Note this is a KRunner keyword", "date");
0024 static const QString timeWord = i18nc("Note this is a KRunner keyword", "time");
0025 static const QString conversionWords = i18nc(
0026     "words to specify a time in a time zone or to convert a time to a time zone, e.g. 'current time in Berlin' or '18:00 UTC to CET', separated by '|' (will "
0027     "be used as a regex)",
0028     "to|in|as|at");
0029 static const QRegularExpression conversionWordsRegex = QRegularExpression(QString("\\s(%1|>|->)\\s").arg(conversionWords));
0030 
0031 DateTimeRunner::DateTimeRunner(QObject *parent, const KPluginMetaData &metaData)
0032     : AbstractRunner(parent, metaData)
0033 {
0034     addSyntax(dateWord, i18n("Displays the current date"));
0035     addSyntax(timeWord, i18n("Displays the current time"));
0036     addSyntax(dateWord + i18nc("The <> and space are part of the example query", " <timezone>"),
0037               i18n("Displays the current date and difference to system date in a given timezone"));
0038     addSyntax(timeWord + i18nc("The <> and space are part of the example query", " <timezone>"),
0039               i18n("Displays the current time and difference to system time in a given timezone"));
0040     addSyntax(i18nc("The <> and space are part of the example query", "<time> <timezone> in <timezone>"),
0041               i18n("Converts the time from the first timezone to the second timezone. If only one time zone is given, the other will be the "
0042                    "system time zone. If no date or time is given, it will be the current date and time."));
0043 }
0044 
0045 DateTimeRunner::~DateTimeRunner()
0046 {
0047 }
0048 
0049 void DateTimeRunner::match(RunnerContext &context)
0050 {
0051     QString term = context.query();
0052 
0053     if (term.compare(dateWord, Qt::CaseInsensitive) == 0) {
0054         // current date in local time zone
0055 
0056         const QDate date = QDate::currentDate();
0057         const QString dateStr = QLocale().toString(date);
0058         addMatch(i18n("Today's date is %1", dateStr), dateStr, 1.0, QStringLiteral("view-calendar-day"), context);
0059     } else if (term.startsWith(dateWord + QLatin1Char(' '), Qt::CaseInsensitive)) {
0060         // current date in remote time zone
0061 
0062         term = term.replace(conversionWordsRegex, QStringLiteral(" ")); // strip away conversion words
0063         const auto zoneTerm = QStringView(term).right(term.length() - dateWord.length() - 1);
0064         const auto zones = matchingTimeZones(zoneTerm);
0065         for (auto it = zones.constBegin(), itEnd = zones.constEnd(); it != itEnd; ++it) {
0066             const QTimeZone zone = it.value();
0067             const QString zoneStr = it.key();
0068             const QDateTime datetime = QDateTime::currentDateTime().toTimeZone(zone);
0069             const QString dateStr = QLocale().toString(datetime.date());
0070 
0071             const qint64 dateDiff = QDateTime::currentDateTime().daysTo(QDateTime(datetime.date(), datetime.time())) * (24 * 60 * 60 * 1000); // full days in ms
0072             const QString dateDiffNumStr = KFormat().formatSpelloutDuration(abs(dateDiff));
0073             const QString dateDiffLaterStr =
0074                 i18nc("time difference between time zones, e.g. in Stockholm it's 1 calendar day later than in Brasilia", "%1 later", dateDiffNumStr);
0075             const QString dateDiffEarlierStr =
0076                 i18nc("date difference between time zones, e.g. in Brasilia it's 1 calendar day earlier than in Stockholm", "%1 earlier", dateDiffNumStr);
0077             const QString dateDiffSameStr =
0078                 i18nc("no date difference between time zones, e.g. in Stockholm it's the same calendar day as in Berlin", "no date difference");
0079             const QString dateDiffStr = dateDiff > 0 ? dateDiffLaterStr : dateDiff < 0 ? dateDiffEarlierStr : dateDiffSameStr;
0080 
0081             addMatch(QStringLiteral("%1: %2 (%3)").arg(zoneStr, dateStr, dateDiffStr),
0082                      dateStr,
0083                      ((qreal)(zoneStr.count(zoneTerm, Qt::CaseInsensitive)) * zoneTerm.length() - (qreal)zoneStr.indexOf(zoneTerm, Qt::CaseInsensitive))
0084                          / zoneStr.length(),
0085                      QStringLiteral("view-calendar-day"),
0086                      context);
0087         }
0088     } else if (term.compare(timeWord, Qt::CaseInsensitive) == 0) {
0089         // current time in local time zone
0090 
0091         const QTime time = QTime::currentTime();
0092         const QString timeStr = QLocale().toString(time);
0093         addMatch(i18n("Current time is %1", timeStr), timeStr, 1.0, QStringLiteral("clock"), context);
0094     } else if (term.startsWith(timeWord + QLatin1Char(' '), Qt::CaseInsensitive)) {
0095         // current time in remote time zone
0096 
0097         term = term.replace(conversionWordsRegex, QStringLiteral(" ")); // strip away conversion words
0098         const auto zoneTerm = QStringView(term).right(term.length() - timeWord.length() - 1);
0099         const auto zones = matchingTimeZones(zoneTerm);
0100         for (auto it = zones.constBegin(), itEnd = zones.constEnd(); it != itEnd; ++it) {
0101             const QTimeZone zone = it.value();
0102             const QString zoneStr = it.key();
0103             const QDateTime datetime = QDateTime::currentDateTime().toTimeZone(zone);
0104             const QString timeStr = QLocale().toString(datetime.time(), QLocale::ShortFormat);
0105 
0106             const qint64 dateDiff = QDateTime::currentDateTime().daysTo(QDateTime(datetime.date(), datetime.time())) * (24 * 60 * 60 * 1000); // full days in ms
0107             const QString dayDiffNumStr = KFormat().formatSpelloutDuration(abs(dateDiff));
0108             const QString timeDayLaterStr = i18nc(
0109                 "time difference with calendar date difference between time zones, e.g. 22:00 Brasilia time in Stockholm = "
0110                 "02:00 + 1 day, where %1 is the time and %2 is "
0111                 "the days later",
0112                 "%1 + %2",
0113                 timeStr,
0114                 dayDiffNumStr);
0115             const QString timeDayEarlierStr = i18nc(
0116                 "time difference with calendar date difference between time zones, e.g. 02:00 Stockholm time in Brasilia "
0117                 "= 22:00 - 1 day, where %1 is the time and %2 is "
0118                 "the days earlier",
0119                 "%1 - %2",
0120                 timeStr,
0121                 dayDiffNumStr);
0122             const QString timeDayStr = dateDiff > 0 ? timeDayLaterStr : dateDiff < 0 ? timeDayEarlierStr : timeStr;
0123 
0124             const qint64 timeDiff = round((double)QDateTime::currentDateTime().secsTo(QDateTime(datetime.date(), datetime.time())) / 60)
0125                 * (60 * 1000); // time in ms rounded to the nearest full
0126             const QString timeDiffNumStr = KFormat().formatSpelloutDuration(abs(timeDiff));
0127             const QString timeDiffLaterStr =
0128                 i18nc("time difference between time zones, e.g. in Stockholm it's 4 hours later than in Brasilia", "%1 later", timeDiffNumStr);
0129             const QString timeDiffEarlierStr =
0130                 i18nc("time difference between time zones, e.g. in Brasilia it's 4 hours earlier than in Stockholm", "%1 earlier", timeDiffNumStr);
0131             const QString timeDiffSameStr =
0132                 i18nc("no time difference between time zones, e.g. in Stockholm it's the same time as in Berlin", "no time difference");
0133             const QString timeDiffStr = timeDiff > 0 ? timeDiffLaterStr : timeDiff < 0 ? timeDiffEarlierStr : timeDiffSameStr;
0134 
0135             addMatch(QStringLiteral("%1: %2 (%3)").arg(zoneStr, timeDayStr, timeDiffStr),
0136                      timeStr,
0137                      ((qreal)(zoneStr.count(zoneTerm, Qt::CaseInsensitive)) * zoneTerm.length() - (qreal)zoneStr.indexOf(zoneTerm, Qt::CaseInsensitive))
0138                          / zoneStr.length(),
0139                      QStringLiteral("clock"),
0140                      context);
0141         }
0142     } else {
0143         // convert user-given time between user-given timezones
0144 
0145         QDate date;
0146         QTime time;
0147         QString dtTerm;
0148         // shortest possible string: only time without leading zeros
0149         const int minLen = QLocale().timeFormat(QLocale::ShortFormat).length();
0150         // longest possible string: date and time with more characters for two-digit numbers in each of the components and different AP format
0151         const int maxLen = QLocale().dateTimeFormat(QLocale::ShortFormat).length() + 8;
0152         // check all long and short enough initial substrings
0153         for (int n = minLen; n <= maxLen && n <= term.length() && time.isNull(); ++n) {
0154             dtTerm = term.mid(0, n);
0155             // try to parse query substring as datetime or time
0156             if (QDateTime dateTimeParse = QLocale().toDateTime(dtTerm, QLocale::ShortFormat); dateTimeParse.isValid()) {
0157                 date = dateTimeParse.date();
0158                 time = dateTimeParse.time();
0159             } else if (QTime timeParse = QLocale().toTime(dtTerm, QLocale::ShortFormat); timeParse.isValid()) {
0160                 time = timeParse;
0161             }
0162         }
0163         // unspecified date and/or time will later be initialized to current date/time in from time zone
0164 
0165         const QString zonesTerm = time.isValid() ? term.mid(dtTerm.length()) : term;
0166         const QRegularExpressionMatch convTermMatch = conversionWordsRegex.match(zonesTerm);
0167         if (!time.isValid() && !convTermMatch.hasMatch()) {
0168             return;
0169         }
0170         const int start = convTermMatch.hasMatch() ? convTermMatch.capturedStart() : zonesTerm.length() + 1;
0171         const int end = convTermMatch.hasMatch() ? convTermMatch.capturedEnd() : zonesTerm.length() + 1;
0172 
0173         // time zone to convert from: left of conversion word, if empty default to system time zone
0174         const QStringView fromZoneTerm = QStringView(zonesTerm).mid(0, start).trimmed();
0175         const QHash<QString, QTimeZone> fromZones = matchingTimeZones(fromZoneTerm, QDateTime(date, time));
0176 
0177         // time zone to convert to: right of conversion word, if empty default to system time zone
0178         const QStringView toZoneTerm = QStringView(zonesTerm).mid(end).trimmed();
0179         const QHash<QString, QTimeZone> toZones = matchingTimeZones(toZoneTerm, QDateTime(date, time));
0180 
0181         if (fromZoneTerm.isEmpty() && toZoneTerm.isEmpty()) {
0182             return;
0183         }
0184 
0185         for (auto it = fromZones.constBegin(), itEnd = fromZones.constEnd(); it != itEnd; ++it) {
0186             const QTimeZone fromZone = it.value();
0187             const QString fromZoneStr = it.key();
0188             const QDate fromDate = date.isValid() ? date : QDateTime::currentDateTimeUtc().toTimeZone(fromZone).date();
0189             const QTime fromTime = time.isValid() ? time : QDateTime::currentDateTimeUtc().toTimeZone(fromZone).time();
0190             const QDateTime fromDatetime = QDateTime(fromDate, fromTime, fromZone);
0191             const QString fromTimeStr = QLocale().toString(fromDatetime.time(), QLocale::ShortFormat);
0192 
0193             for (auto jt = toZones.constBegin(), itEnd = toZones.constEnd(); jt != itEnd; ++jt) {
0194                 const QTimeZone toZone = jt.value();
0195                 const QString toZoneStr = jt.key();
0196                 const QDateTime toDatetime = fromDatetime.toTimeZone(toZone);
0197                 const QString toTimeStr = QLocale().toString(toDatetime.time(), QLocale::ShortFormat);
0198 
0199                 const qint64 dateDiff = QDateTime(fromDatetime.date(), fromDatetime.time()).daysTo(QDateTime(toDatetime.date(), toDatetime.time()))
0200                     * (24 * 60 * 60 * 1000); // full days in ms
0201                 const QString dayDiffNumStr = KFormat().formatSpelloutDuration(abs(dateDiff));
0202                 const QString toTimeDayLaterStr = i18nc(
0203                     "time difference with calendar date difference between time zones, e.g. 22:00 Brasilia time in Stockholm = "
0204                     "02:00 + 1 day, where %1 is the time and %2 is "
0205                     "the days later",
0206                     "%1 + %2",
0207                     toTimeStr,
0208                     dayDiffNumStr);
0209                 const QString toTimeDayEarlierStr = i18nc(
0210                     "time difference with calendar date difference between time zones, e.g. 02:00 Stockholm time in Brasilia "
0211                     "= 22:00 - 1 day, where %1 is the time and %2 is "
0212                     "the days earlier",
0213                     "%1 - %2",
0214                     toTimeStr,
0215                     dayDiffNumStr);
0216                 const QString toTimeDayStr = dateDiff > 0 ? toTimeDayLaterStr : dateDiff < 0 ? toTimeDayEarlierStr : toTimeStr;
0217 
0218                 const qint64 timeDiff =
0219                     round((double)QDateTime(fromDatetime.date(), fromDatetime.time()).secsTo(QDateTime(toDatetime.date(), toDatetime.time())) / 60)
0220                     * (60 * 1000); // time in ms rounded to the nearest full minutes
0221                 const QString timeDiffNumStr = KFormat().formatSpelloutDuration(abs(timeDiff));
0222                 const QString timeDiffLaterStr =
0223                     i18nc("time difference between time zones, e.g. in Stockholm it's 4 hours later than in Brasilia", "%1 later", timeDiffNumStr);
0224                 const QString timeDiffEarlierStr =
0225                     i18nc("time difference between time zones, e.g. in Brasilia it's 4 hours earlier than in Stockholm", "%1 earlier", timeDiffNumStr);
0226                 const QString timeDiffSameStr =
0227                     i18nc("no time difference between time zones, e.g. in Stockholm it's the same time as in Berlin", "no time difference");
0228                 const QString timeDiffStr = timeDiff > 0 ? timeDiffLaterStr : timeDiff < 0 ? timeDiffEarlierStr : timeDiffSameStr;
0229 
0230                 const qreal toZoneRelevance = ((qreal)(toZoneStr.count(toZoneTerm, Qt::CaseInsensitive)) * toZoneTerm.length()
0231                                                - (qreal)toZoneStr.indexOf(toZoneTerm, Qt::CaseInsensitive))
0232                     / toZoneStr.length();
0233                 const qreal fromZoneRelevance = ((qreal)(fromZoneStr.count(fromZoneTerm, Qt::CaseInsensitive)) * fromZoneTerm.length()
0234                                                  - (qreal)fromZoneStr.indexOf(fromZoneTerm, Qt::CaseInsensitive))
0235                     / fromZoneStr.length();
0236                 const qreal relevance = toZoneRelevance / 2 + fromZoneRelevance / 2;
0237 
0238                 addMatch(QStringLiteral("%1: %2 (%3)<br>%4: %5").arg(toZoneStr, toTimeDayStr, timeDiffStr, fromZoneStr, fromTimeStr),
0239                          toTimeStr,
0240                          relevance,
0241                          QStringLiteral("clock"),
0242                          context);
0243             }
0244         }
0245     }
0246 }
0247 
0248 void DateTimeRunner::run(const RunnerContext &context, const QueryMatch &match)
0249 {
0250     const QString clipboardText = match.data().toString();
0251     context.requestQueryStringUpdate(clipboardText, clipboardText.length());
0252 }
0253 
0254 QHash<QString, QTimeZone> DateTimeRunner::matchingTimeZones(const QStringView &zoneTerm, const QDateTime referenceDatetime)
0255 {
0256     QHash<QString, QTimeZone> ret;
0257 
0258     if (zoneTerm.isEmpty()) {
0259         const QTimeZone systemTimeZone = QTimeZone::systemTimeZone().isValid() ? QTimeZone::systemTimeZone() : QTimeZone::utc(); // needed for FreeBSD CI
0260         const QDate atDate = referenceDatetime.date().isValid() ? referenceDatetime.date() : QDateTime::currentDateTime().date();
0261         const QTime atTime = referenceDatetime.time().isValid() ? referenceDatetime.time() : QDateTime::currentDateTime().time();
0262         const QDateTime atDatetime(atDate, atTime, systemTimeZone);
0263         ret[systemTimeZone.abbreviation(atDatetime)] = systemTimeZone;
0264         return ret;
0265     }
0266 
0267     const QList<QByteArray> timeZoneIds = QTimeZone::availableTimeZoneIds();
0268     for (const QByteArray &zoneId : timeZoneIds) {
0269         QTimeZone timeZone(zoneId);
0270         const QDate atDate = referenceDatetime.date().isValid() ? referenceDatetime.date() : QDateTime::currentDateTime().toTimeZone(timeZone).date();
0271         const QTime atTime = referenceDatetime.time().isValid() ? referenceDatetime.time() : QDateTime::currentDateTime().toTimeZone(timeZone).time();
0272         const QDateTime atDatetime(atDate, atTime, timeZone);
0273 
0274         if (zoneId.startsWith(QByteArrayView("UTC+")) || zoneId.startsWith(QByteArrayView("UTC-"))) {
0275             // Qt time zones are either of the form
0276             // (where {zone name} {long name} {abbreviation} {short name} {offset name} {country})
0277             // - "Europe/Stockholm" "Central European Standard Time" "CET" "GMT+1" "UTC+01:00" "Sweden"
0278             // - "UTC+01:00" "UTC+01:00" "UTC+01:00" "UTC+01:00" "UTC+01:00" "Default"
0279             // The latter are already covered by the offset name of the former, which we want to match exactly, so skip these
0280             continue;
0281         }
0282 
0283         const QString zoneName = QString::fromUtf8(zoneId);
0284         // eg "Stockholm"
0285         const QString city = zoneName.mid(zoneName.indexOf(QLatin1Char('/')) + 1).replace(QLatin1Char('_'), QLatin1Char(' '));
0286         if (city.contains(zoneTerm, Qt::CaseInsensitive)) {
0287             ret[city] = timeZone;
0288             continue;
0289         }
0290 
0291         // eg "Sweden"
0292         const QString country = QLocale::countryToString(timeZone.country());
0293         const QString comment = timeZone.comment();
0294         if (country.contains(zoneTerm, Qt::CaseInsensitive) || comment.contains(zoneTerm, Qt::CaseInsensitive)) {
0295             const QString regionName = comment.isEmpty() ? country : QLatin1String("%1 - %2").arg(country, comment);
0296             ret[regionName] = timeZone;
0297             continue;
0298         }
0299         // eg "Central European Standard Time"
0300         const QString longName = timeZone.displayName(atDatetime, QTimeZone::LongName);
0301         if (longName.contains(zoneTerm, Qt::CaseInsensitive)) {
0302             ret[longName] = timeZone;
0303             continue;
0304         }
0305 
0306         // eg "CET"
0307         // FIXME: This only includes the current abbreviation and not old abbreviation or other possible names.
0308         // Eg - depending on the current date, only CET or CEST will work
0309         const QString abbr = timeZone.abbreviation(atDatetime);
0310         if (abbr.contains(zoneTerm, Qt::CaseInsensitive)) {
0311             // Combine abbreviation with display name to disambiguate
0312             // Eg - Pacific Standard Time (PST) and Philippine Standard Time (PST)
0313             const QString abbrName = QString("%1 (%2)").arg(longName, abbr);
0314             ret[abbrName] = timeZone;
0315             continue;
0316         }
0317 
0318         // eg "GMT+1"
0319         const QString shortName = timeZone.displayName(atDatetime, QTimeZone::ShortName);
0320         if (shortName.compare(zoneTerm, Qt::CaseInsensitive) == 0) {
0321             ret[shortName] = timeZone;
0322             continue;
0323         }
0324 
0325         // eg "UTC+01:00"
0326         const QString offsetName = timeZone.displayName(atDatetime, QTimeZone::OffsetName);
0327         if (offsetName.compare(zoneTerm, Qt::CaseInsensitive) == 0) {
0328             ret[offsetName] = timeZone;
0329             continue;
0330         }
0331     }
0332 
0333     return ret;
0334 }
0335 
0336 void DateTimeRunner::addMatch(const QString &text, const QString &clipboardText, const qreal &relevance, const QString &iconName, RunnerContext &context)
0337 {
0338     QueryMatch match(this);
0339     match.setText(text);
0340     match.setData(clipboardText);
0341     match.setCategoryRelevance(QueryMatch::CategoryRelevance::Moderate);
0342     match.setRelevance(relevance);
0343     match.setIconName(iconName);
0344     match.setMultiLine(true);
0345 
0346     context.addMatch(match);
0347 }
0348 
0349 K_PLUGIN_CLASS_WITH_JSON(DateTimeRunner, "plasma-runner-datetime.json")
0350 
0351 #include "datetimerunner.moc"