File indexing completed on 2024-05-12 05:15:03

0001 /*
0002   This file is part of the kcalutils library.
0003 
0004   SPDX-FileCopyrightText: 2001 Cornelius Schumacher <schumacher@kde.org>
0005   SPDX-FileCopyrightText: 2004 Reinhold Kainhofer <reinhold@kainhofer.com>
0006   SPDX-FileCopyrightText: 2005 Rafal Rzepecki <divide@users.sourceforge.net>
0007   SPDX-FileCopyrightText: 2009-2010 Klarälvdalens Datakonsult AB, a KDAB Group company <info@kdab.net>
0008 
0009   SPDX-License-Identifier: LGPL-2.0-or-later
0010 */
0011 /**
0012   @file
0013   This file is part of the API for handling calendar data and provides
0014   static functions for formatting Incidences for various purposes.
0015 
0016   @brief
0017   Provides methods to format Incidences in various ways for display purposes.
0018 
0019   @author Cornelius Schumacher \<schumacher@kde.org\>
0020   @author Reinhold Kainhofer \<reinhold@kainhofer.com\>
0021   @author Allen Winter \<allen@kdab.com\>
0022 */
0023 #include "incidenceformatter.h"
0024 #include "grantleetemplatemanager_p.h"
0025 #include "stringify.h"
0026 
0027 #include <KCalendarCore/Event>
0028 #include <KCalendarCore/FreeBusy>
0029 #include <KCalendarCore/ICalFormat>
0030 #include <KCalendarCore/Journal>
0031 #include <KCalendarCore/Todo>
0032 #include <KCalendarCore/Visitor>
0033 using namespace KCalendarCore;
0034 
0035 #include <KIdentityManagementCore/Utils>
0036 
0037 #include <KEmailAddress>
0038 #include <ktexttohtml.h>
0039 
0040 #include "kcalutils_debug.h"
0041 #include <KIconLoader>
0042 #include <KLocalizedString>
0043 
0044 #include <QApplication>
0045 #include <QBitArray>
0046 #include <QLocale>
0047 #include <QMimeDatabase>
0048 #include <QPalette>
0049 #include <QRegularExpression>
0050 
0051 using namespace KCalUtils;
0052 using namespace IncidenceFormatter;
0053 
0054 /*******************
0055  *  General helpers
0056  *******************/
0057 
0058 static QVariantHash inviteButton(const QString &id, const QString &text, const QString &iconName, InvitationFormatterHelper *helper);
0059 
0060 //@cond PRIVATE
0061 static QString string2HTML(const QString &str)
0062 {
0063     // use convertToHtml so we get clickable links and other goodies
0064     return KTextToHTML::convertToHtml(str, KTextToHTML::HighlightText | KTextToHTML::ReplaceSmileys);
0065 }
0066 
0067 static bool thatIsMe(const QString &email)
0068 {
0069     return KIdentityManagementCore::thatIsMe(email);
0070 }
0071 
0072 static bool iamAttendee(const Attendee &attendee)
0073 {
0074     // Check if this attendee is the user
0075     return thatIsMe(attendee.email());
0076 }
0077 
0078 static QString htmlAddTag(const QString &tag, const QString &text)
0079 {
0080     int numLineBreaks = text.count(QLatin1Char('\n'));
0081     const QString str = QLatin1Char('<') + tag + QLatin1Char('>');
0082     QString tmpText = text;
0083     QString tmpStr = str;
0084     if (numLineBreaks >= 0) {
0085         if (numLineBreaks > 0) {
0086             QString tmp;
0087             for (int i = 0; i <= numLineBreaks; ++i) {
0088                 int pos = tmpText.indexOf(QLatin1Char('\n'));
0089                 tmp = tmpText.left(pos);
0090                 tmpText = tmpText.right(tmpText.length() - pos - 1);
0091                 tmpStr += tmp + QLatin1StringView("<br>");
0092             }
0093         } else {
0094             tmpStr += tmpText;
0095         }
0096     }
0097     tmpStr += QLatin1StringView("</") + tag + QLatin1Char('>');
0098     return tmpStr;
0099 }
0100 
0101 static QPair<QString, QString> searchNameAndUid(const QString &email, const QString &name, const QString &uid)
0102 {
0103     // Yes, this is a silly method now, but it's predecessor was quite useful in e35.
0104     // For now, please keep this sillyness until e35 is frozen to ease forward porting.
0105     // -Allen
0106     QPair<QString, QString> s;
0107     s.first = name;
0108     s.second = uid;
0109     if (!email.isEmpty() && (name.isEmpty() || uid.isEmpty())) {
0110         s.second.clear();
0111     }
0112     return s;
0113 }
0114 
0115 static QString searchName(const QString &email, const QString &name)
0116 {
0117     const QString printName = name.isEmpty() ? email : name;
0118     return printName;
0119 }
0120 
0121 static bool iamOrganizer(const Incidence::Ptr &incidence)
0122 {
0123     // Check if the user is the organizer for this incidence
0124 
0125     if (!incidence) {
0126         return false;
0127     }
0128 
0129     return thatIsMe(incidence->organizer().email());
0130 }
0131 
0132 static bool senderIsOrganizer(const Incidence::Ptr &incidence, const QString &sender)
0133 {
0134     // Check if the specified sender is the organizer
0135 
0136     if (!incidence || sender.isEmpty()) {
0137         return true;
0138     }
0139 
0140     bool isorg = true;
0141     QString senderName;
0142     QString senderEmail;
0143     if (KEmailAddress::extractEmailAddressAndName(sender, senderEmail, senderName)) {
0144         // for this heuristic, we say the sender is the organizer if either the name or the email match.
0145         if (incidence->organizer().email() != senderEmail && incidence->organizer().name() != senderName) {
0146             isorg = false;
0147         }
0148     }
0149     return isorg;
0150 }
0151 
0152 static bool attendeeIsOrganizer(const Incidence::Ptr &incidence, const Attendee &attendee)
0153 {
0154     if (incidence && !attendee.isNull() && (incidence->organizer().email() == attendee.email())) {
0155         return true;
0156     } else {
0157         return false;
0158     }
0159 }
0160 
0161 static QString organizerName(const Incidence::Ptr &incidence, const QString &defName)
0162 {
0163     QString tName;
0164     if (!defName.isEmpty()) {
0165         tName = defName;
0166     } else {
0167         tName = i18n("Organizer Unknown");
0168     }
0169 
0170     QString name;
0171     if (incidence) {
0172         name = incidence->organizer().name();
0173         if (name.isEmpty()) {
0174             name = incidence->organizer().email();
0175         }
0176     }
0177     if (name.isEmpty()) {
0178         name = tName;
0179     }
0180     return name;
0181 }
0182 
0183 static QString firstAttendeeName(const Incidence::Ptr &incidence, const QString &defName)
0184 {
0185     QString tName;
0186     if (!defName.isEmpty()) {
0187         tName = defName;
0188     } else {
0189         tName = i18n("Sender");
0190     }
0191 
0192     QString name;
0193     if (incidence) {
0194         const Attendee::List attendees = incidence->attendees();
0195         if (!attendees.isEmpty()) {
0196             const Attendee attendee = attendees.at(0);
0197             name = attendee.name();
0198             if (name.isEmpty()) {
0199                 name = attendee.email();
0200             }
0201         }
0202     }
0203     if (name.isEmpty()) {
0204         name = tName;
0205     }
0206     return name;
0207 }
0208 
0209 static QString rsvpStatusIconName(Attendee::PartStat status)
0210 {
0211     switch (status) {
0212     case Attendee::Accepted:
0213         return QStringLiteral("dialog-ok-apply");
0214     case Attendee::Declined:
0215         return QStringLiteral("dialog-cancel");
0216     case Attendee::NeedsAction:
0217         return QStringLiteral("help-about");
0218     case Attendee::InProcess:
0219         return QStringLiteral("help-about");
0220     case Attendee::Tentative:
0221         return QStringLiteral("dialog-ok");
0222     case Attendee::Delegated:
0223         return QStringLiteral("mail-forward");
0224     case Attendee::Completed:
0225         return QStringLiteral("mail-mark-read");
0226     default:
0227         return QString();
0228     }
0229 }
0230 
0231 //@endcond
0232 
0233 /*******************************************************************
0234  *  Helper functions for the extensive display (display viewer)
0235  *******************************************************************/
0236 
0237 //@cond PRIVATE
0238 static QVariantHash displayViewFormatPerson(const QString &email, const QString &name, const QString &uid, const QString &iconName)
0239 {
0240     // Search for new print name or uid, if needed.
0241     QPair<QString, QString> s = searchNameAndUid(email, name, uid);
0242     const QString printName = s.first;
0243     const QString printUid = s.second;
0244 
0245     QVariantHash personData;
0246     personData[QStringLiteral("icon")] = iconName;
0247     personData[QStringLiteral("uid")] = printUid;
0248     personData[QStringLiteral("name")] = printName;
0249     personData[QStringLiteral("email")] = email;
0250 
0251     // Make the mailto link
0252     if (!email.isEmpty()) {
0253         Person person(name, email);
0254         QString path = person.fullName().simplified();
0255         if (path.isEmpty() || path.startsWith(QLatin1Char('"'))) {
0256             path = email;
0257         }
0258         QUrl mailto;
0259         mailto.setScheme(QStringLiteral("mailto"));
0260         mailto.setPath(path);
0261 
0262         personData[QStringLiteral("mailto")] = mailto.url();
0263     }
0264 
0265     return personData;
0266 }
0267 
0268 static QVariantHash displayViewFormatPerson(const QString &email, const QString &name, const QString &uid, Attendee::PartStat status)
0269 {
0270     return displayViewFormatPerson(email, name, uid, rsvpStatusIconName(status));
0271 }
0272 
0273 static bool incOrganizerOwnsCalendar(const Calendar::Ptr &calendar, const Incidence::Ptr &incidence)
0274 {
0275     // PORTME!  Look at e35's CalHelper::incOrganizerOwnsCalendar
0276 
0277     // For now, use iamOrganizer() which is only part of the check
0278     Q_UNUSED(calendar)
0279     return iamOrganizer(incidence);
0280 }
0281 
0282 static QString displayViewFormatDescription(const Incidence::Ptr &incidence)
0283 {
0284     if (!incidence->description().isEmpty()) {
0285         if (!incidence->descriptionIsRich() && !incidence->description().startsWith(QLatin1StringView("<!DOCTYPE HTML"))) {
0286             return string2HTML(incidence->description());
0287         } else if (!incidence->description().startsWith(QLatin1StringView("<!DOCTYPE HTML"))) {
0288             return incidence->richDescription();
0289         } else {
0290             return incidence->description();
0291         }
0292     }
0293 
0294     return QString();
0295 }
0296 
0297 static QVariantList displayViewFormatAttendeeRoleList(const Incidence::Ptr &incidence, Attendee::Role role, bool showStatus)
0298 {
0299     QVariantList attendeeDataList;
0300     attendeeDataList.reserve(incidence->attendeeCount());
0301 
0302     const Attendee::List attendees = incidence->attendees();
0303     for (const auto &a : attendees) {
0304         if (a.role() != role) {
0305             // skip this role
0306             continue;
0307         }
0308         if (attendeeIsOrganizer(incidence, a)) {
0309             // skip attendee that is also the organizer
0310             continue;
0311         }
0312         QVariantHash attendeeData = displayViewFormatPerson(a.email(), a.name(), a.uid(), showStatus ? a.status() : Attendee::None);
0313         if (!a.delegator().isEmpty()) {
0314             attendeeData[QStringLiteral("delegator")] = a.delegator();
0315         }
0316         if (!a.delegate().isEmpty()) {
0317             attendeeData[QStringLiteral("delegate")] = a.delegate();
0318         }
0319         if (showStatus) {
0320             attendeeData[QStringLiteral("status")] = Stringify::attendeeStatus(a.status());
0321         }
0322 
0323         attendeeDataList << attendeeData;
0324     }
0325 
0326     return attendeeDataList;
0327 }
0328 
0329 static QVariantHash displayViewFormatOrganizer(const Incidence::Ptr &incidence)
0330 {
0331     // Add organizer link
0332     int attendeeCount = incidence->attendees().count();
0333     if (attendeeCount > 1 || (attendeeCount == 1 && !attendeeIsOrganizer(incidence, incidence->attendees().at(0)))) {
0334         QPair<QString, QString> s = searchNameAndUid(incidence->organizer().email(), incidence->organizer().name(), QString());
0335         return displayViewFormatPerson(incidence->organizer().email(), s.first, s.second, QStringLiteral("meeting-organizer"));
0336     }
0337 
0338     return QVariantHash();
0339 }
0340 
0341 static QVariantList displayViewFormatAttachments(const Incidence::Ptr &incidence)
0342 {
0343     const Attachment::List as = incidence->attachments();
0344 
0345     QVariantList dataList;
0346     dataList.reserve(as.count());
0347 
0348     for (auto it = as.cbegin(), end = as.cend(); it != end; ++it) {
0349         QVariantHash attData;
0350         if ((*it).isUri()) {
0351             QString name;
0352             if ((*it).uri().startsWith(QLatin1StringView("kmail:"))) {
0353                 name = i18n("Show mail");
0354             } else {
0355                 if ((*it).label().isEmpty()) {
0356                     name = (*it).uri();
0357                 } else {
0358                     name = (*it).label();
0359                 }
0360             }
0361             attData[QStringLiteral("uri")] = (*it).uri();
0362             attData[QStringLiteral("label")] = name;
0363         } else {
0364             attData[QStringLiteral("uri")] = QStringLiteral("ATTACH:%1").arg(QString::fromUtf8((*it).label().toUtf8().toBase64()));
0365             attData[QStringLiteral("label")] = (*it).label();
0366         }
0367         dataList << attData;
0368     }
0369     return dataList;
0370 }
0371 
0372 static QVariantHash displayViewFormatBirthday(const Event::Ptr &event)
0373 {
0374     if (!event) {
0375         return QVariantHash();
0376     }
0377 
0378     // It's callees duty to ensure this
0379     Q_ASSERT(event->customProperty("KABC", "BIRTHDAY") == QLatin1StringView("YES") || event->customProperty("KABC", "ANNIVERSARY") == QLatin1StringView("YES"));
0380 
0381     const QString uid_1 = event->customProperty("KABC", "UID-1");
0382     const QString name_1 = event->customProperty("KABC", "NAME-1");
0383     const QString email_1 = event->customProperty("KABC", "EMAIL-1");
0384     const KCalendarCore::Person p = Person::fromFullName(email_1);
0385     return displayViewFormatPerson(p.email(), name_1, uid_1, QString());
0386 }
0387 
0388 static QVariantHash incidenceTemplateHeader(const Incidence::Ptr &incidence)
0389 {
0390     QVariantHash incidenceData;
0391     if (incidence->customProperty("KABC", "BIRTHDAY") == QLatin1StringView("YES")) {
0392         incidenceData[QStringLiteral("icon")] = QStringLiteral("view-calendar-birthday");
0393     } else if (incidence->customProperty("KABC", "ANNIVERSARY") == QLatin1StringView("YES")) {
0394         incidenceData[QStringLiteral("icon")] = QStringLiteral("view-calendar-wedding-anniversary");
0395     } else {
0396         incidenceData[QStringLiteral("icon")] = incidence->iconName();
0397     }
0398 
0399     switch (incidence->type()) {
0400     case IncidenceBase::IncidenceType::TypeEvent:
0401         incidenceData[QStringLiteral("alarmIcon")] = QStringLiteral("appointment-reminder");
0402         incidenceData[QStringLiteral("recursIcon")] = QStringLiteral("appointment-recurring");
0403         break;
0404     case IncidenceBase::IncidenceType::TypeTodo:
0405         incidenceData[QStringLiteral("alarmIcon")] = QStringLiteral("task-reminder");
0406         incidenceData[QStringLiteral("recursIcon")] = QStringLiteral("task-recurring");
0407         break;
0408     default:
0409         // Others don't repeat and don't have reminders.
0410         break;
0411     }
0412 
0413     incidenceData[QStringLiteral("hasEnabledAlarms")] = incidence->hasEnabledAlarms();
0414     incidenceData[QStringLiteral("recurs")] = incidence->recurs();
0415     incidenceData[QStringLiteral("isReadOnly")] = incidence->isReadOnly();
0416     incidenceData[QStringLiteral("summary")] = incidence->summary();
0417     incidenceData[QStringLiteral("allDay")] = incidence->allDay();
0418 
0419     return incidenceData;
0420 }
0421 
0422 static QString displayViewFormatEvent(const Calendar::Ptr &calendar, const QString &sourceName, const Event::Ptr &event, QDate date)
0423 {
0424     if (!event) {
0425         return QString();
0426     }
0427 
0428     QVariantHash incidence = incidenceTemplateHeader(event);
0429 
0430     incidence[QStringLiteral("calendar")] = calendar ? resourceString(calendar, event) : sourceName;
0431     const QString richLocation = event->richLocation();
0432     if (richLocation.startsWith(QLatin1StringView("http:/")) || richLocation.startsWith(QLatin1StringView("https:/"))) {
0433         incidence[QStringLiteral("location")] = QStringLiteral("<a href=\"%1\">%1</a>").arg(richLocation);
0434     } else {
0435         incidence[QStringLiteral("location")] = richLocation;
0436     }
0437 
0438     const auto startDts = event->startDateTimesForDate(date, QTimeZone::systemTimeZone());
0439     const auto startDt = startDts.empty() ? event->dtStart().toLocalTime() : startDts[0].toLocalTime();
0440     const auto endDt = event->endDateForStart(startDt).toLocalTime();
0441 
0442     incidence[QStringLiteral("isAllDay")] = event->allDay();
0443     incidence[QStringLiteral("isMultiDay")] = event->isMultiDay();
0444     incidence[QStringLiteral("startDate")] = startDt.date();
0445     incidence[QStringLiteral("endDate")] = endDt.date();
0446     incidence[QStringLiteral("startTime")] = startDt.time();
0447     incidence[QStringLiteral("endTime")] = endDt.time();
0448     incidence[QStringLiteral("duration")] = durationString(event);
0449     incidence[QStringLiteral("isException")] = event->hasRecurrenceId();
0450     incidence[QStringLiteral("recurrence")] = recurrenceString(event);
0451 
0452     if (event->customProperty("KABC", "BIRTHDAY") == QLatin1StringView("YES")) {
0453         incidence[QStringLiteral("birthday")] = displayViewFormatBirthday(event);
0454     }
0455 
0456     if (event->customProperty("KABC", "ANNIVERSARY") == QLatin1StringView("YES")) {
0457         incidence[QStringLiteral("anniversary")] = displayViewFormatBirthday(event);
0458     }
0459 
0460     incidence[QStringLiteral("description")] = displayViewFormatDescription(event);
0461     // TODO: print comments?
0462 
0463     incidence[QStringLiteral("reminders")] = reminderStringList(event);
0464 
0465     incidence[QStringLiteral("organizer")] = displayViewFormatOrganizer(event);
0466     const bool showStatus = incOrganizerOwnsCalendar(calendar, event);
0467     incidence[QStringLiteral("chair")] = displayViewFormatAttendeeRoleList(event, Attendee::Chair, showStatus);
0468     incidence[QStringLiteral("requiredParticipants")] = displayViewFormatAttendeeRoleList(event, Attendee::ReqParticipant, showStatus);
0469     incidence[QStringLiteral("optionalParticipants")] = displayViewFormatAttendeeRoleList(event, Attendee::OptParticipant, showStatus);
0470     incidence[QStringLiteral("observers")] = displayViewFormatAttendeeRoleList(event, Attendee::NonParticipant, showStatus);
0471 
0472     incidence[QStringLiteral("categories")] = event->categories();
0473 
0474     incidence[QStringLiteral("attachments")] = displayViewFormatAttachments(event);
0475     incidence[QStringLiteral("creationDate")] = event->created().toLocalTime();
0476 
0477     return GrantleeTemplateManager::instance()->render(QStringLiteral(":/event.html"), incidence);
0478 }
0479 
0480 static QString displayViewFormatTodo(const Calendar::Ptr &calendar, const QString &sourceName, const Todo::Ptr &todo, QDate ocurrenceDueDate)
0481 {
0482     if (!todo) {
0483         qCDebug(KCALUTILS_LOG) << "IncidenceFormatter::displayViewFormatTodo was called without to-do, quitting";
0484         return QString();
0485     }
0486 
0487     QVariantHash incidence = incidenceTemplateHeader(todo);
0488 
0489     incidence[QStringLiteral("calendar")] = calendar ? resourceString(calendar, todo) : sourceName;
0490     incidence[QStringLiteral("location")] = todo->richLocation();
0491 
0492     const bool hastStartDate = todo->hasStartDate();
0493     const bool hasDueDate = todo->hasDueDate();
0494 
0495     if (hastStartDate) {
0496         QDateTime startDt = todo->dtStart(true /**first*/).toLocalTime();
0497         if (todo->recurs() && ocurrenceDueDate.isValid()) {
0498             if (hasDueDate) {
0499                 // In kdepim all recurring to-dos have due date.
0500                 const qint64 length = startDt.daysTo(todo->dtDue(true /**first*/));
0501                 if (length >= 0) {
0502                     startDt.setDate(ocurrenceDueDate.addDays(-length));
0503                 } else {
0504                     qCritical() << "DTSTART is bigger than DTDUE, todo->uid() is " << todo->uid();
0505                     startDt.setDate(ocurrenceDueDate);
0506                 }
0507             } else {
0508                 qCritical() << "To-do is recurring but has no DTDUE set, todo->uid() is " << todo->uid();
0509                 startDt.setDate(ocurrenceDueDate);
0510             }
0511         }
0512         incidence[QStringLiteral("startDate")] = startDt;
0513     }
0514 
0515     if (hasDueDate) {
0516         QDateTime dueDt = todo->dtDue().toLocalTime();
0517         if (todo->recurs()) {
0518             if (ocurrenceDueDate.isValid()) {
0519                 QDateTime kdt(ocurrenceDueDate, QTime(0, 0, 0), Qt::LocalTime);
0520                 kdt = kdt.addSecs(-1);
0521                 dueDt.setDate(todo->recurrence()->getNextDateTime(kdt).date());
0522             }
0523         }
0524         incidence[QStringLiteral("dueDate")] = dueDt;
0525     }
0526 
0527     incidence[QStringLiteral("duration")] = durationString(todo);
0528     incidence[QStringLiteral("isException")] = todo->hasRecurrenceId();
0529     if (todo->recurs()) {
0530         incidence[QStringLiteral("recurrence")] = recurrenceString(todo);
0531     }
0532 
0533     incidence[QStringLiteral("description")] = displayViewFormatDescription(todo);
0534 
0535     // TODO: print comments?
0536 
0537     incidence[QStringLiteral("reminders")] = reminderStringList(todo);
0538 
0539     incidence[QStringLiteral("organizer")] = displayViewFormatOrganizer(todo);
0540     const bool showStatus = incOrganizerOwnsCalendar(calendar, todo);
0541     incidence[QStringLiteral("chair")] = displayViewFormatAttendeeRoleList(todo, Attendee::Chair, showStatus);
0542     incidence[QStringLiteral("requiredParticipants")] = displayViewFormatAttendeeRoleList(todo, Attendee::ReqParticipant, showStatus);
0543     incidence[QStringLiteral("optionalParticipants")] = displayViewFormatAttendeeRoleList(todo, Attendee::OptParticipant, showStatus);
0544     incidence[QStringLiteral("observers")] = displayViewFormatAttendeeRoleList(todo, Attendee::NonParticipant, showStatus);
0545 
0546     incidence[QStringLiteral("categories")] = todo->categories();
0547     incidence[QStringLiteral("priority")] = todo->priority();
0548     if (todo->isCompleted()) {
0549         incidence[QStringLiteral("completedDate")] = todo->completed();
0550     } else {
0551         incidence[QStringLiteral("percent")] = todo->percentComplete();
0552     }
0553     incidence[QStringLiteral("attachments")] = displayViewFormatAttachments(todo);
0554     incidence[QStringLiteral("creationDate")] = todo->created().toLocalTime();
0555 
0556     return GrantleeTemplateManager::instance()->render(QStringLiteral(":/todo.html"), incidence);
0557 }
0558 
0559 static QString displayViewFormatJournal(const Calendar::Ptr &calendar, const QString &sourceName, const Journal::Ptr &journal)
0560 {
0561     if (!journal) {
0562         return QString();
0563     }
0564 
0565     QVariantHash incidence = incidenceTemplateHeader(journal);
0566     incidence[QStringLiteral("calendar")] = calendar ? resourceString(calendar, journal) : sourceName;
0567     incidence[QStringLiteral("date")] = journal->dtStart().toLocalTime();
0568     incidence[QStringLiteral("description")] = displayViewFormatDescription(journal);
0569     incidence[QStringLiteral("categories")] = journal->categories();
0570     incidence[QStringLiteral("creationDate")] = journal->created().toLocalTime();
0571 
0572     return GrantleeTemplateManager::instance()->render(QStringLiteral(":/journal.html"), incidence);
0573 }
0574 
0575 static QString displayViewFormatFreeBusy(const Calendar::Ptr &calendar, const QString &sourceName, const FreeBusy::Ptr &fb)
0576 {
0577     Q_UNUSED(calendar)
0578     Q_UNUSED(sourceName)
0579     if (!fb) {
0580         return QString();
0581     }
0582 
0583     QVariantHash fbData;
0584     fbData[QStringLiteral("organizer")] = fb->organizer().fullName();
0585     fbData[QStringLiteral("start")] = fb->dtStart().toLocalTime().date();
0586     fbData[QStringLiteral("end")] = fb->dtEnd().toLocalTime().date();
0587 
0588     Period::List periods = fb->busyPeriods();
0589     QVariantList periodsData;
0590     periodsData.reserve(periods.size());
0591     for (auto it = periods.cbegin(), end = periods.cend(); it != end; ++it) {
0592         const Period per = *it;
0593         QVariantHash periodData;
0594         if (per.hasDuration()) {
0595             int dur = per.duration().asSeconds();
0596             QString cont;
0597             if (dur >= 3600) {
0598                 cont += i18ncp("hours part of duration", "1 hour ", "%1 hours ", dur / 3600);
0599                 dur %= 3600;
0600             }
0601             if (dur >= 60) {
0602                 cont += i18ncp("minutes part duration", "1 minute ", "%1 minutes ", dur / 60);
0603                 dur %= 60;
0604             }
0605             if (dur > 0) {
0606                 cont += i18ncp("seconds part of duration", "1 second", "%1 seconds", dur);
0607             }
0608             periodData[QStringLiteral("dtStart")] = per.start().toLocalTime();
0609             periodData[QStringLiteral("duration")] = cont;
0610         } else {
0611             const QDateTime pStart = per.start().toLocalTime();
0612             const QDateTime pEnd = per.end().toLocalTime();
0613             if (per.start().date() == per.end().date()) {
0614                 periodData[QStringLiteral("date")] = pStart.date();
0615                 periodData[QStringLiteral("start")] = pStart.time();
0616                 periodData[QStringLiteral("end")] = pEnd.time();
0617             } else {
0618                 periodData[QStringLiteral("start")] = pStart;
0619                 periodData[QStringLiteral("end")] = pEnd;
0620             }
0621         }
0622 
0623         periodsData << periodData;
0624     }
0625 
0626     fbData[QStringLiteral("periods")] = periodsData;
0627 
0628     return GrantleeTemplateManager::instance()->render(QStringLiteral(":/freebusy.html"), fbData);
0629 }
0630 
0631 //@endcond
0632 
0633 //@cond PRIVATE
0634 class KCalUtils::IncidenceFormatter::EventViewerVisitor : public Visitor
0635 {
0636 public:
0637     EventViewerVisitor()
0638         : mCalendar(nullptr)
0639     {
0640     }
0641 
0642     ~EventViewerVisitor() override;
0643 
0644     bool act(const Calendar::Ptr &calendar, const IncidenceBase::Ptr &incidence, QDate date)
0645     {
0646         mCalendar = calendar;
0647         mSourceName.clear();
0648         mDate = date;
0649         mResult = QLatin1StringView("");
0650         return incidence->accept(*this, incidence);
0651     }
0652 
0653     bool act(const QString &sourceName, const IncidenceBase::Ptr &incidence, QDate date)
0654     {
0655         mSourceName = sourceName;
0656         mDate = date;
0657         mResult = QLatin1StringView("");
0658         return incidence->accept(*this, incidence);
0659     }
0660 
0661     [[nodiscard]] QString result() const
0662     {
0663         return mResult;
0664     }
0665 
0666 protected:
0667     bool visit(const Event::Ptr &event) override
0668     {
0669         mResult = displayViewFormatEvent(mCalendar, mSourceName, event, mDate);
0670         return !mResult.isEmpty();
0671     }
0672 
0673     bool visit(const Todo::Ptr &todo) override
0674     {
0675         mResult = displayViewFormatTodo(mCalendar, mSourceName, todo, mDate);
0676         return !mResult.isEmpty();
0677     }
0678 
0679     bool visit(const Journal::Ptr &journal) override
0680     {
0681         mResult = displayViewFormatJournal(mCalendar, mSourceName, journal);
0682         return !mResult.isEmpty();
0683     }
0684 
0685     bool visit(const FreeBusy::Ptr &fb) override
0686     {
0687         mResult = displayViewFormatFreeBusy(mCalendar, mSourceName, fb);
0688         return !mResult.isEmpty();
0689     }
0690 
0691 protected:
0692     Calendar::Ptr mCalendar;
0693     QString mSourceName;
0694     QDate mDate;
0695     QString mResult;
0696 };
0697 //@endcond
0698 
0699 EventViewerVisitor::~EventViewerVisitor()
0700 {
0701 }
0702 
0703 QString IncidenceFormatter::extensiveDisplayStr(const Calendar::Ptr &calendar, const IncidenceBase::Ptr &incidence, QDate date)
0704 {
0705     if (!incidence) {
0706         return QString();
0707     }
0708 
0709     EventViewerVisitor v;
0710     if (v.act(calendar, incidence, date)) {
0711         return v.result();
0712     } else {
0713         return QString();
0714     }
0715 }
0716 
0717 QString IncidenceFormatter::extensiveDisplayStr(const QString &sourceName, const IncidenceBase::Ptr &incidence, QDate date)
0718 {
0719     if (!incidence) {
0720         return QString();
0721     }
0722 
0723     EventViewerVisitor v;
0724     if (v.act(sourceName, incidence, date)) {
0725         return v.result();
0726     } else {
0727         return QString();
0728     }
0729 }
0730 
0731 /***********************************************************************
0732  *  Helper functions for the body part formatter of kmail (Invitations)
0733  ***********************************************************************/
0734 
0735 //@cond PRIVATE
0736 static QString cleanHtml(const QString &html)
0737 {
0738     static QRegularExpression rx = QRegularExpression(QStringLiteral("<body[^>]*>(.*)</body>"), QRegularExpression::CaseInsensitiveOption);
0739     QRegularExpressionMatch match = rx.match(html);
0740     if (match.hasMatch()) {
0741         QString body = match.captured(1);
0742         return body.remove(QRegularExpression(QStringLiteral("<[^>]*>"))).trimmed().toHtmlEscaped();
0743     }
0744     return html;
0745 }
0746 
0747 static QString invitationSummary(const Incidence::Ptr &incidence, bool noHtmlMode)
0748 {
0749     QString summaryStr = i18n("Summary unspecified");
0750     if (!incidence->summary().isEmpty()) {
0751         if (!incidence->summaryIsRich()) {
0752             summaryStr = incidence->summary().toHtmlEscaped();
0753         } else {
0754             summaryStr = incidence->richSummary();
0755             if (noHtmlMode) {
0756                 summaryStr = cleanHtml(summaryStr);
0757             }
0758         }
0759     }
0760     return summaryStr;
0761 }
0762 
0763 static QString invitationLocation(const Incidence::Ptr &incidence, bool noHtmlMode)
0764 {
0765     QString locationStr = i18n("Location unspecified");
0766     if (!incidence->location().isEmpty()) {
0767         if (!incidence->locationIsRich()) {
0768             locationStr = incidence->location().toHtmlEscaped();
0769         } else {
0770             locationStr = incidence->richLocation();
0771             if (noHtmlMode) {
0772                 locationStr = cleanHtml(locationStr);
0773             }
0774         }
0775     }
0776     return locationStr;
0777 }
0778 
0779 static QString diffColor()
0780 {
0781     // Color for printing comparison differences inside invitations.
0782 
0783     //  return  "#DE8519"; // hard-coded color from Outlook2007
0784     return QColor(Qt::red).name(); // krazy:exclude=qenums TODO make configurable
0785 }
0786 
0787 static QString noteColor()
0788 {
0789     // Color for printing notes inside invitations.
0790     return qApp->palette().color(QPalette::Active, QPalette::Highlight).name();
0791 }
0792 
0793 static QString htmlCompare(const QString &value, const QString &oldvalue)
0794 {
0795     // if 'value' is empty, then print nothing
0796     if (value.isEmpty()) {
0797         return QString();
0798     }
0799 
0800     // if 'value' is new or unchanged, then print normally
0801     if (oldvalue.isEmpty() || value == oldvalue) {
0802         return value;
0803     }
0804 
0805     // if 'value' has changed, then make a special print
0806     return QStringLiteral("<font color=\"%1\">%2</font> (<strike>%3</strike>)").arg(diffColor(), value, oldvalue);
0807 }
0808 
0809 static Attendee findDelegatedFromMyAttendee(const Incidence::Ptr &incidence)
0810 {
0811     // Return the first attendee that was delegated-from the user
0812 
0813     Attendee attendee;
0814     if (!incidence) {
0815         return attendee;
0816     }
0817 
0818     QString delegatorName;
0819     QString delegatorEmail;
0820     const Attendee::List attendees = incidence->attendees();
0821     for (const auto &a : attendees) {
0822         KEmailAddress::extractEmailAddressAndName(a.delegator(), delegatorEmail, delegatorName);
0823         if (thatIsMe(delegatorEmail)) {
0824             attendee = a;
0825             break;
0826         }
0827     }
0828 
0829     return attendee;
0830 }
0831 
0832 static Attendee findMyAttendee(const Incidence::Ptr &incidence)
0833 {
0834     // Return the attendee for the incidence that is probably the user
0835 
0836     Attendee attendee;
0837     if (!incidence) {
0838         return attendee;
0839     }
0840 
0841     const Attendee::List attendees = incidence->attendees();
0842     for (const auto &a : attendees) {
0843         if (iamAttendee(a)) {
0844             attendee = a;
0845             break;
0846         }
0847     }
0848 
0849     return attendee;
0850 }
0851 
0852 static Attendee findAttendee(const Incidence::Ptr &incidence, const QString &email)
0853 {
0854     // Search for an attendee by email address
0855 
0856     Attendee attendee;
0857     if (!incidence) {
0858         return attendee;
0859     }
0860 
0861     const Attendee::List attendees = incidence->attendees();
0862     for (const auto &a : attendees) {
0863         if (email == a.email()) {
0864             attendee = a;
0865             break;
0866         }
0867     }
0868     return attendee;
0869 }
0870 
0871 static bool rsvpRequested(const Incidence::Ptr &incidence)
0872 {
0873     if (!incidence) {
0874         return false;
0875     }
0876 
0877     // use a heuristic to determine if a response is requested.
0878 
0879     bool rsvp = true; // better send superfluously than not at all
0880     Attendee::List attendees = incidence->attendees();
0881     Attendee::List::ConstIterator it;
0882     const Attendee::List::ConstIterator end(attendees.constEnd());
0883     for (it = attendees.constBegin(); it != end; ++it) {
0884         if (it == attendees.constBegin()) {
0885             rsvp = (*it).RSVP(); // use what the first one has
0886         } else {
0887             if ((*it).RSVP() != rsvp) {
0888                 rsvp = true; // they differ, default
0889                 break;
0890             }
0891         }
0892     }
0893     return rsvp;
0894 }
0895 
0896 static QString rsvpRequestedStr(bool rsvpRequested, const QString &role)
0897 {
0898     if (rsvpRequested) {
0899         if (role.isEmpty()) {
0900             return i18n("Your response is requested.");
0901         } else {
0902             return i18n("Your response as <b>%1</b> is requested.", role);
0903         }
0904     } else {
0905         if (role.isEmpty()) {
0906             return i18n("No response is necessary.");
0907         } else {
0908             return i18n("No response as <b>%1</b> is necessary.", role);
0909         }
0910     }
0911 }
0912 
0913 static QString myStatusStr(const Incidence::Ptr &incidence)
0914 {
0915     QString ret;
0916     const Attendee a = findMyAttendee(incidence);
0917     if (!a.isNull() && a.status() != Attendee::NeedsAction && a.status() != Attendee::Delegated) {
0918         ret = i18n("(<b>Note</b>: the Organizer preset your response to <b>%1</b>)", Stringify::attendeeStatus(a.status()));
0919     }
0920     return ret;
0921 }
0922 
0923 static QVariantHash invitationNote(const QString &title, const QString &note, const QString &color)
0924 {
0925     QVariantHash noteHash;
0926     if (note.isEmpty()) {
0927         return noteHash;
0928     }
0929 
0930     noteHash[QStringLiteral("color")] = color;
0931     noteHash[QStringLiteral("title")] = title;
0932     noteHash[QStringLiteral("note")] = note;
0933     return noteHash;
0934 }
0935 
0936 static QString invitationDescriptionIncidence(const Incidence::Ptr &incidence, bool noHtmlMode)
0937 {
0938     if (!incidence->description().isEmpty()) {
0939         // use description too
0940         if (!incidence->descriptionIsRich() && !incidence->description().startsWith(QLatin1StringView("<!DOCTYPE HTML"))) {
0941             return string2HTML(incidence->description());
0942         } else {
0943             QString descr;
0944             if (!incidence->description().startsWith(QLatin1StringView("<!DOCTYPE HTML"))) {
0945                 descr = incidence->richDescription();
0946             } else {
0947                 descr = incidence->description();
0948             }
0949             if (noHtmlMode) {
0950                 descr = cleanHtml(descr);
0951             }
0952             return htmlAddTag(QStringLiteral("p"), descr);
0953         }
0954     }
0955 
0956     return QString();
0957 }
0958 
0959 static bool slicesInterval(const Event::Ptr &event, const QDateTime &startDt, const QDateTime &endDt)
0960 {
0961     QDateTime closestStart = event->dtStart();
0962     QDateTime closestEnd = event->dtEnd();
0963     if (event->recurs()) {
0964         if (!event->recurrence()->timesInInterval(startDt, endDt).isEmpty()) {
0965             // If there is a recurrence in this interval we know already that we slice.
0966             return true;
0967         }
0968         closestStart = event->recurrence()->getPreviousDateTime(startDt);
0969         if (event->hasEndDate()) {
0970             closestEnd = closestStart.addSecs(event->dtStart().secsTo(event->dtEnd()));
0971         }
0972     } else {
0973         if (!event->hasEndDate() && event->hasDuration()) {
0974             closestEnd = closestStart.addSecs(event->duration());
0975         }
0976     }
0977 
0978     if (!closestEnd.isValid()) {
0979         // All events without an ending still happen if they are
0980         // started.
0981         return closestStart <= startDt;
0982     }
0983 
0984     if (closestStart <= startDt) {
0985         // It starts before the interval and ends after the start of the interval.
0986         return closestEnd > startDt;
0987     }
0988 
0989     // Are start and end both in this interval?
0990     return (closestStart >= startDt && closestStart <= endDt) && (closestEnd >= startDt && closestEnd <= endDt);
0991 }
0992 
0993 static QVariantList eventsOnSameDays(InvitationFormatterHelper *helper, const Event::Ptr &event, bool noHtmlMode)
0994 {
0995     if (!event || !helper || !helper->calendar()) {
0996         return QVariantList();
0997     }
0998 
0999     QDateTime startDay = event->dtStart();
1000     QDateTime endDay = event->hasEndDate() ? event->dtEnd() : event->dtStart();
1001     startDay.setTime(QTime(0, 0, 0));
1002     endDay.setTime(QTime(23, 59, 59));
1003 
1004     Event::List matchingEvents = helper->calendar()->events(startDay.date(), endDay.date(), QTimeZone::systemTimeZone());
1005     if (matchingEvents.isEmpty()) {
1006         return QVariantList();
1007     }
1008 
1009     QVariantList events;
1010     int count = 0;
1011     for (auto it = matchingEvents.cbegin(), end = matchingEvents.cend(); it != end && count < 50; ++it) {
1012         if ((*it)->schedulingID() == event->uid()) {
1013             // Exclude the same event from the list.
1014             continue;
1015         }
1016         if (!slicesInterval(*it, startDay, endDay)) {
1017             /* Calendar::events includes events that have a recurrence that is
1018              * "active" in the specified interval. Whether or not the event is actually
1019              * happening ( has a recurrence that falls into the interval ).
1020              * This appears to be done deliberately and not to be a bug so we additionally
1021              * check if the event is actually happening here. */
1022             continue;
1023         }
1024         ++count;
1025         QVariantHash ev;
1026         ev[QStringLiteral("summary")] = invitationSummary(*it, noHtmlMode);
1027         ev[QStringLiteral("dateTime")] = IncidenceFormatter::formatStartEnd((*it)->dtStart(), (*it)->dtEnd(), (*it)->allDay());
1028         events.push_back(ev);
1029     }
1030     if (count == 50) {
1031         /* Abort after 50 entries to limit resource usage */
1032         events.push_back({});
1033     }
1034     return events;
1035 }
1036 
1037 static QVariantHash invitationDetailsEvent(InvitationFormatterHelper *helper, const Event::Ptr &event, bool noHtmlMode)
1038 {
1039     // Invitation details are formatted into an HTML table
1040     if (!event) {
1041         return QVariantHash();
1042     }
1043 
1044     QVariantHash incidence;
1045     incidence[QStringLiteral("iconName")] = QStringLiteral("view-pim-calendar");
1046     incidence[QStringLiteral("summary")] = invitationSummary(event, noHtmlMode);
1047     incidence[QStringLiteral("location")] = invitationLocation(event, noHtmlMode);
1048     incidence[QStringLiteral("recurs")] = event->recurs();
1049     incidence[QStringLiteral("recurrence")] = recurrenceString(event);
1050     incidence[QStringLiteral("isMultiDay")] = event->isMultiDay(QTimeZone::systemTimeZone());
1051     incidence[QStringLiteral("isAllDay")] = event->allDay();
1052     incidence[QStringLiteral("dateTime")] = IncidenceFormatter::formatStartEnd(event->dtStart(), event->dtEnd(), event->allDay());
1053     incidence[QStringLiteral("duration")] = durationString(event);
1054     incidence[QStringLiteral("description")] = invitationDescriptionIncidence(event, noHtmlMode);
1055 
1056     incidence[QStringLiteral("checkCalendarButton")] =
1057         inviteButton(QStringLiteral("check_calendar"), i18n("Check my calendar"), QStringLiteral("go-jump-today"), helper);
1058     incidence[QStringLiteral("eventsOnSameDays")] = eventsOnSameDays(helper, event, noHtmlMode);
1059 
1060     return incidence;
1061 }
1062 
1063 QString IncidenceFormatter::formatStartEnd(const QDateTime &start, const QDateTime &end, bool isAllDay)
1064 {
1065     QString tmpStr;
1066     // <startDate[time> [- <[endDate][Time]>]
1067     // The startDate is always printed.
1068     // If the event does float the time is omitted.
1069     //
1070     // If it has an end dateTime:
1071     // on the same day -> Only add end time.
1072     // if it floats also omit the time
1073     tmpStr += IncidenceFormatter::dateTimeToString(start, isAllDay, false);
1074 
1075     if (end.isValid()) {
1076         if (start.date() == end.date()) {
1077             // same day
1078             if (start.time().isValid()) {
1079                 tmpStr += QLatin1StringView(" - ") + IncidenceFormatter::timeToString(end.toLocalTime().time(), true);
1080             }
1081         } else {
1082             tmpStr += QLatin1StringView(" - ") + IncidenceFormatter::dateTimeToString(end, isAllDay, false);
1083         }
1084     }
1085     return tmpStr;
1086 }
1087 
1088 static QVariantHash invitationDetailsEvent(InvitationFormatterHelper *helper,
1089                                            const Event::Ptr &event,
1090                                            const Event::Ptr &oldevent,
1091                                            const ScheduleMessage::Ptr &message,
1092                                            bool noHtmlMode)
1093 {
1094     if (!oldevent) {
1095         return invitationDetailsEvent(helper, event, noHtmlMode);
1096     }
1097 
1098     QVariantHash incidence;
1099 
1100     // Print extra info typically dependent on the iTIP
1101     if (message->method() == iTIPDeclineCounter) {
1102         incidence[QStringLiteral("note")] = invitationNote(QString(), i18n("Please respond again to the original proposal."), noteColor());
1103     }
1104 
1105     incidence[QStringLiteral("isDiff")] = true;
1106     incidence[QStringLiteral("iconName")] = QStringLiteral("view-pim-calendar");
1107     incidence[QStringLiteral("summary")] = htmlCompare(invitationSummary(event, noHtmlMode), invitationSummary(oldevent, noHtmlMode));
1108     incidence[QStringLiteral("location")] = htmlCompare(invitationLocation(event, noHtmlMode), invitationLocation(oldevent, noHtmlMode));
1109     incidence[QStringLiteral("recurs")] = event->recurs() || oldevent->recurs();
1110     incidence[QStringLiteral("recurrence")] = htmlCompare(recurrenceString(event), recurrenceString(oldevent));
1111     incidence[QStringLiteral("dateTime")] = htmlCompare(IncidenceFormatter::formatStartEnd(event->dtStart(), event->dtEnd(), event->allDay()),
1112                                                         IncidenceFormatter::formatStartEnd(oldevent->dtStart(), oldevent->dtEnd(), oldevent->allDay()));
1113     incidence[QStringLiteral("duration")] = htmlCompare(durationString(event), durationString(oldevent));
1114     incidence[QStringLiteral("description")] = invitationDescriptionIncidence(event, noHtmlMode);
1115 
1116     incidence[QStringLiteral("checkCalendarButton")] =
1117         inviteButton(QStringLiteral("check_calendar"), i18n("Check my calendar"), QStringLiteral("go-jump-today"), helper);
1118     incidence[QStringLiteral("eventsOnSameDays")] = eventsOnSameDays(helper, event, noHtmlMode);
1119 
1120     return incidence;
1121 }
1122 
1123 static QVariantHash invitationDetailsTodo(const Todo::Ptr &todo, bool noHtmlMode)
1124 {
1125     // To-do details are formatted into an HTML table
1126     if (!todo) {
1127         return QVariantHash();
1128     }
1129 
1130     QVariantHash incidence;
1131     incidence[QStringLiteral("iconName")] = QStringLiteral("view-pim-tasks");
1132     incidence[QStringLiteral("summary")] = invitationSummary(todo, noHtmlMode);
1133     incidence[QStringLiteral("location")] = invitationLocation(todo, noHtmlMode);
1134     incidence[QStringLiteral("isAllDay")] = todo->allDay();
1135     incidence[QStringLiteral("hasStartDate")] = todo->hasStartDate();
1136     bool isMultiDay = false;
1137     if (todo->hasStartDate()) {
1138         if (todo->allDay()) {
1139             incidence[QStringLiteral("dtStartStr")] = dateToString(todo->dtStart().toLocalTime().date(), true);
1140         } else {
1141             incidence[QStringLiteral("dtStartStr")] = dateTimeToString(todo->dtStart(), false, true);
1142         }
1143         isMultiDay = todo->dtStart().date() != todo->dtDue().date();
1144     }
1145     if (todo->allDay()) {
1146         incidence[QStringLiteral("dtDueStr")] = dateToString(todo->dtDue().toLocalTime().date(), true);
1147     } else {
1148         incidence[QStringLiteral("dtDueStr")] = dateTimeToString(todo->dtDue(), false, true);
1149     }
1150     incidence[QStringLiteral("isMultiDay")] = isMultiDay;
1151     incidence[QStringLiteral("duration")] = durationString(todo);
1152     if (todo->percentComplete() > 0) {
1153         incidence[QStringLiteral("percentComplete")] = i18n("%1%", todo->percentComplete());
1154     }
1155     incidence[QStringLiteral("recurs")] = todo->recurs();
1156     incidence[QStringLiteral("recurrence")] = recurrenceString(todo);
1157     incidence[QStringLiteral("description")] = invitationDescriptionIncidence(todo, noHtmlMode);
1158 
1159     return incidence;
1160 }
1161 
1162 static QVariantHash invitationDetailsTodo(const Todo::Ptr &todo, const Todo::Ptr &oldtodo, const ScheduleMessage::Ptr &message, bool noHtmlMode)
1163 {
1164     if (!oldtodo) {
1165         return invitationDetailsTodo(todo, noHtmlMode);
1166     }
1167 
1168     QVariantHash incidence;
1169 
1170     // Print extra info typically dependent on the iTIP
1171     if (message->method() == iTIPDeclineCounter) {
1172         incidence[QStringLiteral("note")] = invitationNote(QString(), i18n("Please respond again to the original proposal."), noteColor());
1173     }
1174 
1175     incidence[QStringLiteral("iconName")] = QStringLiteral("view-pim-tasks");
1176     incidence[QStringLiteral("isDiff")] = true;
1177     incidence[QStringLiteral("summary")] = htmlCompare(invitationSummary(todo, noHtmlMode), invitationSummary(oldtodo, noHtmlMode));
1178     incidence[QStringLiteral("location")] = htmlCompare(invitationLocation(todo, noHtmlMode), invitationLocation(oldtodo, noHtmlMode));
1179     incidence[QStringLiteral("isAllDay")] = todo->allDay();
1180     incidence[QStringLiteral("hasStartDate")] = todo->hasStartDate();
1181     incidence[QStringLiteral("dtStartStr")] = htmlCompare(dateTimeToString(todo->dtStart(), false, false), dateTimeToString(oldtodo->dtStart(), false, false));
1182     incidence[QStringLiteral("dtDueStr")] = htmlCompare(dateTimeToString(todo->dtDue(), false, false), dateTimeToString(oldtodo->dtDue(), false, false));
1183     incidence[QStringLiteral("duration")] = htmlCompare(durationString(todo), durationString(oldtodo));
1184     incidence[QStringLiteral("percentComplete")] = htmlCompare(i18n("%1%", todo->percentComplete()), i18n("%1%", oldtodo->percentComplete()));
1185 
1186     incidence[QStringLiteral("recurs")] = todo->recurs() || oldtodo->recurs();
1187     incidence[QStringLiteral("recurrence")] = htmlCompare(recurrenceString(todo), recurrenceString(oldtodo));
1188     incidence[QStringLiteral("description")] = invitationDescriptionIncidence(todo, noHtmlMode);
1189 
1190     return incidence;
1191 }
1192 
1193 static QVariantHash invitationDetailsJournal(const Journal::Ptr &journal, bool noHtmlMode)
1194 {
1195     if (!journal) {
1196         return QVariantHash();
1197     }
1198 
1199     QVariantHash incidence;
1200     incidence[QStringLiteral("iconName")] = QStringLiteral("view-pim-journal");
1201     incidence[QStringLiteral("summary")] = invitationSummary(journal, noHtmlMode);
1202     incidence[QStringLiteral("date")] = journal->dtStart();
1203     incidence[QStringLiteral("description")] = invitationDescriptionIncidence(journal, noHtmlMode);
1204 
1205     return incidence;
1206 }
1207 
1208 static QVariantHash invitationDetailsJournal(const Journal::Ptr &journal, const Journal::Ptr &oldjournal, bool noHtmlMode)
1209 {
1210     if (!oldjournal) {
1211         return invitationDetailsJournal(journal, noHtmlMode);
1212     }
1213 
1214     QVariantHash incidence;
1215     incidence[QStringLiteral("iconName")] = QStringLiteral("view-pim-journal");
1216     incidence[QStringLiteral("summary")] = htmlCompare(invitationSummary(journal, noHtmlMode), invitationSummary(oldjournal, noHtmlMode));
1217     incidence[QStringLiteral("dateStr")] =
1218         htmlCompare(dateToString(journal->dtStart().toLocalTime().date(), false), dateToString(oldjournal->dtStart().toLocalTime().date(), false));
1219     incidence[QStringLiteral("description")] = invitationDescriptionIncidence(journal, noHtmlMode);
1220 
1221     return incidence;
1222 }
1223 
1224 static QVariantHash invitationDetailsFreeBusy(const FreeBusy::Ptr &fb, bool noHtmlMode)
1225 {
1226     Q_UNUSED(noHtmlMode)
1227 
1228     if (!fb) {
1229         return QVariantHash();
1230     }
1231 
1232     QVariantHash incidence;
1233     incidence[QStringLiteral("organizer")] = fb->organizer().fullName();
1234     incidence[QStringLiteral("dtStart")] = fb->dtStart();
1235     incidence[QStringLiteral("dtEnd")] = fb->dtEnd();
1236 
1237     QVariantList periodsList;
1238     const Period::List periods = fb->busyPeriods();
1239     for (auto it = periods.cbegin(), end = periods.cend(); it != end; ++it) {
1240         QVariantHash period;
1241         period[QStringLiteral("hasDuration")] = it->hasDuration();
1242         if (it->hasDuration()) {
1243             int dur = it->duration().asSeconds();
1244             QString cont;
1245             if (dur >= 3600) {
1246                 cont += i18ncp("hours part of duration", "1 hour ", "%1 hours ", dur / 3600);
1247                 dur %= 3600;
1248             }
1249             if (dur >= 60) {
1250                 cont += i18ncp("minutes part of duration", "1 minute", "%1 minutes ", dur / 60);
1251                 dur %= 60;
1252             }
1253             if (dur > 0) {
1254                 cont += i18ncp("seconds part of duration", "1 second", "%1 seconds", dur);
1255             }
1256             period[QStringLiteral("duration")] = cont;
1257         }
1258         period[QStringLiteral("start")] = it->start();
1259         period[QStringLiteral("end")] = it->end();
1260 
1261         periodsList.push_back(period);
1262     }
1263     incidence[QStringLiteral("periods")] = periodsList;
1264 
1265     return incidence;
1266 }
1267 
1268 static QVariantHash invitationDetailsFreeBusy(const FreeBusy::Ptr &fb, const FreeBusy::Ptr &oldfb, bool noHtmlMode)
1269 {
1270     Q_UNUSED(oldfb)
1271     return invitationDetailsFreeBusy(fb, noHtmlMode);
1272 }
1273 
1274 static bool replyMeansCounter(const Incidence::Ptr &incidence)
1275 {
1276     Q_UNUSED(incidence)
1277     return false;
1278     /**
1279       see kolab/issue 3665 for an example of when we might use this for something
1280 
1281       bool status = false;
1282       if ( incidence ) {
1283         // put code here that looks at the incidence and determines that
1284         // the reply is meant to be a counter proposal.  We think this happens
1285         // with Outlook counter proposals, but we aren't sure how yet.
1286         if ( condition ) {
1287           status = true;
1288         }
1289       }
1290       return status;
1291     */
1292 }
1293 
1294 static QString invitationHeaderEvent(const Event::Ptr &event, const Incidence::Ptr &existingIncidence, const ScheduleMessage::Ptr &msg, const QString &sender)
1295 {
1296     if (!msg || !event) {
1297         return QString();
1298     }
1299 
1300     switch (msg->method()) {
1301     case iTIPPublish:
1302         return i18n("This invitation has been published.");
1303     case iTIPRequest:
1304         if (existingIncidence && event->revision() > 0) {
1305             QString orgStr = organizerName(event, sender);
1306             if (senderIsOrganizer(event, sender)) {
1307                 return i18n("This invitation has been updated by the organizer %1.", orgStr);
1308             } else {
1309                 return i18n("This invitation has been updated by %1 as a representative of %2.", sender, orgStr);
1310             }
1311         }
1312         if (iamOrganizer(event)) {
1313             return i18n("I created this invitation.");
1314         } else {
1315             QString orgStr = organizerName(event, sender);
1316             if (senderIsOrganizer(event, sender)) {
1317                 return i18n("You received an invitation from %1.", orgStr);
1318             } else {
1319                 return i18n("You received an invitation from %1 as a representative of %2.", sender, orgStr);
1320             }
1321         }
1322     case iTIPRefresh:
1323         return i18n("This invitation was refreshed.");
1324     case iTIPCancel:
1325         if (iamOrganizer(event)) {
1326             return i18n("This invitation has been canceled.");
1327         } else {
1328             return i18n("The organizer has revoked the invitation.");
1329         }
1330     case iTIPAdd:
1331         return i18n("Addition to the invitation.");
1332     case iTIPReply: {
1333         if (replyMeansCounter(event)) {
1334             return i18n("%1 makes this counter proposal.", firstAttendeeName(event, sender));
1335         }
1336 
1337         Attendee::List attendees = event->attendees();
1338         if (attendees.isEmpty()) {
1339             qCDebug(KCALUTILS_LOG) << "No attendees in the iCal reply!";
1340             return QString();
1341         }
1342         if (attendees.count() != 1) {
1343             qCDebug(KCALUTILS_LOG) << "Warning: attendeecount in the reply should be 1"
1344                                    << "but is" << attendees.count();
1345         }
1346         QString attendeeName = firstAttendeeName(event, sender);
1347 
1348         QString delegatorName;
1349         QString dummy;
1350         const Attendee attendee = *attendees.begin();
1351         KEmailAddress::extractEmailAddressAndName(attendee.delegator(), dummy, delegatorName);
1352         if (delegatorName.isEmpty()) {
1353             delegatorName = attendee.delegator();
1354         }
1355 
1356         switch (attendee.status()) {
1357         case Attendee::NeedsAction:
1358             return i18n("%1 indicates this invitation still needs some action.", attendeeName);
1359         case Attendee::Accepted:
1360             if (event->revision() > 0) {
1361                 if (!sender.isEmpty()) {
1362                     return i18n("This invitation has been updated by attendee %1.", sender);
1363                 } else {
1364                     return i18n("This invitation has been updated by an attendee.");
1365                 }
1366             } else {
1367                 if (delegatorName.isEmpty()) {
1368                     return i18n("%1 accepts this invitation.", attendeeName);
1369                 } else {
1370                     return i18n("%1 accepts this invitation on behalf of %2.", attendeeName, delegatorName);
1371                 }
1372             }
1373         case Attendee::Tentative:
1374             if (delegatorName.isEmpty()) {
1375                 return i18n("%1 tentatively accepts this invitation.", attendeeName);
1376             } else {
1377                 return i18n("%1 tentatively accepts this invitation on behalf of %2.", attendeeName, delegatorName);
1378             }
1379         case Attendee::Declined:
1380             if (delegatorName.isEmpty()) {
1381                 return i18n("%1 declines this invitation.", attendeeName);
1382             } else {
1383                 return i18n("%1 declines this invitation on behalf of %2.", attendeeName, delegatorName);
1384             }
1385         case Attendee::Delegated: {
1386             QString delegate;
1387             QString dummy;
1388             KEmailAddress::extractEmailAddressAndName(attendee.delegate(), dummy, delegate);
1389             if (delegate.isEmpty()) {
1390                 delegate = attendee.delegate();
1391             }
1392             if (!delegate.isEmpty()) {
1393                 return i18n("%1 has delegated this invitation to %2.", attendeeName, delegate);
1394             } else {
1395                 return i18n("%1 has delegated this invitation.", attendeeName);
1396             }
1397         }
1398         case Attendee::Completed:
1399             return i18n("This invitation is now completed.");
1400         case Attendee::InProcess:
1401             return i18n("%1 is still processing the invitation.", attendeeName);
1402         case Attendee::None:
1403             return i18n("Unknown response to this invitation.");
1404         }
1405         break;
1406     }
1407     case iTIPCounter:
1408         return i18n("%1 makes this counter proposal.", firstAttendeeName(event, i18n("Sender")));
1409 
1410     case iTIPDeclineCounter: {
1411         QString orgStr = organizerName(event, sender);
1412         if (senderIsOrganizer(event, sender)) {
1413             return i18n("%1 declines your counter proposal.", orgStr);
1414         } else {
1415             return i18n("%1 declines your counter proposal on behalf of %2.", sender, orgStr);
1416         }
1417     }
1418 
1419     case iTIPNoMethod:
1420         return i18n("Error: Event iTIP message with unknown method.");
1421     }
1422     qCritical() << "encountered an iTIP method that we do not support.";
1423     return QString();
1424 }
1425 
1426 static QString invitationHeaderTodo(const Todo::Ptr &todo, const Incidence::Ptr &existingIncidence, const ScheduleMessage::Ptr &msg, const QString &sender)
1427 {
1428     if (!msg || !todo) {
1429         return QString();
1430     }
1431 
1432     switch (msg->method()) {
1433     case iTIPPublish:
1434         return i18n("This to-do has been published.");
1435     case iTIPRequest:
1436         if (existingIncidence && todo->revision() > 0) {
1437             QString orgStr = organizerName(todo, sender);
1438             if (senderIsOrganizer(todo, sender)) {
1439                 return i18n("This to-do has been updated by the organizer %1.", orgStr);
1440             } else {
1441                 return i18n("This to-do has been updated by %1 as a representative of %2.", sender, orgStr);
1442             }
1443         } else {
1444             if (iamOrganizer(todo)) {
1445                 return i18n("I created this to-do.");
1446             } else {
1447                 QString orgStr = organizerName(todo, sender);
1448                 if (senderIsOrganizer(todo, sender)) {
1449                     return i18n("You have been assigned this to-do by %1.", orgStr);
1450                 } else {
1451                     return i18n("You have been assigned this to-do by %1 as a representative of %2.", sender, orgStr);
1452                 }
1453             }
1454         }
1455     case iTIPRefresh:
1456         return i18n("This to-do was refreshed.");
1457     case iTIPCancel:
1458         if (iamOrganizer(todo)) {
1459             return i18n("This to-do was canceled.");
1460         } else {
1461             return i18n("The organizer has revoked this to-do.");
1462         }
1463     case iTIPAdd:
1464         return i18n("Addition to the to-do.");
1465     case iTIPReply: {
1466         if (replyMeansCounter(todo)) {
1467             return i18n("%1 makes this counter proposal.", firstAttendeeName(todo, sender));
1468         }
1469 
1470         Attendee::List attendees = todo->attendees();
1471         if (attendees.isEmpty()) {
1472             qCDebug(KCALUTILS_LOG) << "No attendees in the iCal reply!";
1473             return QString();
1474         }
1475         if (attendees.count() != 1) {
1476             qCDebug(KCALUTILS_LOG) << "Warning: attendeecount in the reply should be 1."
1477                                    << "but is" << attendees.count();
1478         }
1479         QString attendeeName = firstAttendeeName(todo, sender);
1480 
1481         QString delegatorName;
1482         QString dummy;
1483         const Attendee attendee = *attendees.begin();
1484         KEmailAddress::extractEmailAddressAndName(attendee.delegate(), dummy, delegatorName);
1485         if (delegatorName.isEmpty()) {
1486             delegatorName = attendee.delegator();
1487         }
1488 
1489         switch (attendee.status()) {
1490         case Attendee::NeedsAction:
1491             return i18n("%1 indicates this to-do assignment still needs some action.", attendeeName);
1492         case Attendee::Accepted:
1493             if (todo->revision() > 0) {
1494                 if (!sender.isEmpty()) {
1495                     if (todo->isCompleted()) {
1496                         return i18n("This to-do has been completed by assignee %1.", sender);
1497                     } else {
1498                         return i18n("This to-do has been updated by assignee %1.", sender);
1499                     }
1500                 } else {
1501                     if (todo->isCompleted()) {
1502                         return i18n("This to-do has been completed by an assignee.");
1503                     } else {
1504                         return i18n("This to-do has been updated by an assignee.");
1505                     }
1506                 }
1507             } else {
1508                 if (delegatorName.isEmpty()) {
1509                     return i18n("%1 accepts this to-do.", attendeeName);
1510                 } else {
1511                     return i18n("%1 accepts this to-do on behalf of %2.", attendeeName, delegatorName);
1512                 }
1513             }
1514         case Attendee::Tentative:
1515             if (delegatorName.isEmpty()) {
1516                 return i18n("%1 tentatively accepts this to-do.", attendeeName);
1517             } else {
1518                 return i18n("%1 tentatively accepts this to-do on behalf of %2.", attendeeName, delegatorName);
1519             }
1520         case Attendee::Declined:
1521             if (delegatorName.isEmpty()) {
1522                 return i18n("%1 declines this to-do.", attendeeName);
1523             } else {
1524                 return i18n("%1 declines this to-do on behalf of %2.", attendeeName, delegatorName);
1525             }
1526         case Attendee::Delegated: {
1527             QString delegate;
1528             QString dummy;
1529             KEmailAddress::extractEmailAddressAndName(attendee.delegate(), dummy, delegate);
1530             if (delegate.isEmpty()) {
1531                 delegate = attendee.delegate();
1532             }
1533             if (!delegate.isEmpty()) {
1534                 return i18n("%1 has delegated this to-do to %2.", attendeeName, delegate);
1535             } else {
1536                 return i18n("%1 has delegated this to-do.", attendeeName);
1537             }
1538         }
1539         case Attendee::Completed:
1540             return i18n("The request for this to-do is now completed.");
1541         case Attendee::InProcess:
1542             return i18n("%1 is still processing the to-do.", attendeeName);
1543         case Attendee::None:
1544             return i18n("Unknown response to this to-do.");
1545         }
1546         break;
1547     }
1548     case iTIPCounter:
1549         return i18n("%1 makes this counter proposal.", firstAttendeeName(todo, sender));
1550 
1551     case iTIPDeclineCounter: {
1552         const QString orgStr = organizerName(todo, sender);
1553         if (senderIsOrganizer(todo, sender)) {
1554             return i18n("%1 declines the counter proposal.", orgStr);
1555         } else {
1556             return i18n("%1 declines the counter proposal on behalf of %2.", sender, orgStr);
1557         }
1558     }
1559 
1560     case iTIPNoMethod:
1561         return i18n("Error: To-do iTIP message with unknown method.");
1562     }
1563     qCritical() << "encountered an iTIP method that we do not support";
1564     return QString();
1565 }
1566 
1567 static QString invitationHeaderJournal(const Journal::Ptr &journal, const ScheduleMessage::Ptr &msg)
1568 {
1569     if (!msg || !journal) {
1570         return QString();
1571     }
1572 
1573     switch (msg->method()) {
1574     case iTIPPublish:
1575         return i18n("This journal has been published.");
1576     case iTIPRequest:
1577         return i18n("You have been assigned this journal.");
1578     case iTIPRefresh:
1579         return i18n("This journal was refreshed.");
1580     case iTIPCancel:
1581         return i18n("This journal was canceled.");
1582     case iTIPAdd:
1583         return i18n("Addition to the journal.");
1584     case iTIPReply: {
1585         if (replyMeansCounter(journal)) {
1586             return i18n("Sender makes this counter proposal.");
1587         }
1588 
1589         Attendee::List attendees = journal->attendees();
1590         if (attendees.isEmpty()) {
1591             qCDebug(KCALUTILS_LOG) << "No attendees in the iCal reply!";
1592             return QString();
1593         }
1594         if (attendees.count() != 1) {
1595             qCDebug(KCALUTILS_LOG) << "Warning: attendeecount in the reply should be 1 "
1596                                    << "but is " << attendees.count();
1597         }
1598         const Attendee attendee = *attendees.begin();
1599 
1600         switch (attendee.status()) {
1601         case Attendee::NeedsAction:
1602             return i18n("Sender indicates this journal assignment still needs some action.");
1603         case Attendee::Accepted:
1604             return i18n("Sender accepts this journal.");
1605         case Attendee::Tentative:
1606             return i18n("Sender tentatively accepts this journal.");
1607         case Attendee::Declined:
1608             return i18n("Sender declines this journal.");
1609         case Attendee::Delegated:
1610             return i18n("Sender has delegated this request for the journal.");
1611         case Attendee::Completed:
1612             return i18n("The request for this journal is now completed.");
1613         case Attendee::InProcess:
1614             return i18n("Sender is still processing the invitation.");
1615         case Attendee::None:
1616             return i18n("Unknown response to this journal.");
1617         }
1618         break;
1619     }
1620     case iTIPCounter:
1621         return i18n("Sender makes this counter proposal.");
1622     case iTIPDeclineCounter:
1623         return i18n("Sender declines the counter proposal.");
1624     case iTIPNoMethod:
1625         return i18n("Error: Journal iTIP message with unknown method.");
1626     }
1627     qCritical() << "encountered an iTIP method that we do not support";
1628     return QString();
1629 }
1630 
1631 static QString invitationHeaderFreeBusy(const FreeBusy::Ptr &fb, const ScheduleMessage::Ptr &msg)
1632 {
1633     if (!msg || !fb) {
1634         return QString();
1635     }
1636 
1637     switch (msg->method()) {
1638     case iTIPPublish:
1639         return i18n("This free/busy list has been published.");
1640     case iTIPRequest:
1641         return i18n("The free/busy list has been requested.");
1642     case iTIPRefresh:
1643         return i18n("This free/busy list was refreshed.");
1644     case iTIPCancel:
1645         return i18n("This free/busy list was canceled.");
1646     case iTIPAdd:
1647         return i18n("Addition to the free/busy list.");
1648     case iTIPReply:
1649         return i18n("Reply to the free/busy list.");
1650     case iTIPCounter:
1651         return i18n("Sender makes this counter proposal.");
1652     case iTIPDeclineCounter:
1653         return i18n("Sender declines the counter proposal.");
1654     case iTIPNoMethod:
1655         return i18n("Error: Free/Busy iTIP message with unknown method.");
1656     }
1657     qCritical() << "encountered an iTIP method that we do not support";
1658     return QString();
1659 }
1660 
1661 //@endcond
1662 
1663 static QVariantList invitationAttendeeList(const Incidence::Ptr &incidence)
1664 {
1665     if (!incidence) {
1666         return QVariantList();
1667     }
1668 
1669     QVariantList attendees;
1670     const Attendee::List lstAttendees = incidence->attendees();
1671     for (const Attendee &a : lstAttendees) {
1672         if (iamAttendee(a)) {
1673             continue;
1674         }
1675 
1676         QVariantHash attendee;
1677         attendee[QStringLiteral("name")] = a.name();
1678         attendee[QStringLiteral("email")] = a.email();
1679         attendee[QStringLiteral("delegator")] = a.delegator();
1680         attendee[QStringLiteral("delegate")] = a.delegate();
1681         attendee[QStringLiteral("isOrganizer")] = attendeeIsOrganizer(incidence, a);
1682         attendee[QStringLiteral("status")] = Stringify::attendeeStatus(a.status());
1683         attendee[QStringLiteral("icon")] = rsvpStatusIconName(a.status());
1684 
1685         attendees.push_back(attendee);
1686     }
1687 
1688     return attendees;
1689 }
1690 
1691 static QVariantList invitationRsvpList(const Incidence::Ptr &incidence, const Attendee &sender)
1692 {
1693     if (!incidence) {
1694         return QVariantList();
1695     }
1696 
1697     QVariantList attendees;
1698     const Attendee::List lstAttendees = incidence->attendees();
1699     for (const Attendee &a_ : lstAttendees) {
1700         Attendee a = a_;
1701         if (!attendeeIsOrganizer(incidence, a)) {
1702             continue;
1703         }
1704         QVariantHash attendee;
1705         attendee[QStringLiteral("status")] = Stringify::attendeeStatus(a.status());
1706         if (!sender.isNull() && (a.email() == sender.email())) {
1707             // use the attendee taken from the response incidence,
1708             // rather than the attendee from the calendar incidence.
1709             if (a.status() != sender.status()) {
1710                 attendee[QStringLiteral("status")] = i18n("%1 (<i>unrecorded</i>", Stringify::attendeeStatus(sender.status()));
1711             }
1712             a = sender;
1713         }
1714 
1715         attendee[QStringLiteral("name")] = a.name();
1716         attendee[QStringLiteral("email")] = a.email();
1717         attendee[QStringLiteral("delegator")] = a.delegator();
1718         attendee[QStringLiteral("delegate")] = a.delegate();
1719         attendee[QStringLiteral("isOrganizer")] = attendeeIsOrganizer(incidence, a);
1720         attendee[QStringLiteral("isMyself")] = iamAttendee(a);
1721         attendee[QStringLiteral("icon")] = rsvpStatusIconName(a.status());
1722 
1723         attendees.push_back(attendee);
1724     }
1725 
1726     return attendees;
1727 }
1728 
1729 static QVariantList invitationAttachments(const Incidence::Ptr &incidence, InvitationFormatterHelper *helper)
1730 {
1731     if (!incidence) {
1732         return QVariantList();
1733     }
1734 
1735     if (incidence->type() == Incidence::TypeFreeBusy) {
1736         // A FreeBusy does not have a valid attachment due to the static-cast from IncidenceBase
1737         return QVariantList();
1738     }
1739 
1740     QVariantList attachments;
1741     const Attachment::List lstAttachments = incidence->attachments();
1742     for (const Attachment &a : lstAttachments) {
1743         QVariantHash attachment;
1744         QMimeDatabase mimeDb;
1745         auto mimeType = mimeDb.mimeTypeForName(a.mimeType());
1746         attachment[QStringLiteral("icon")] = (mimeType.isValid() ? mimeType.iconName() : QStringLiteral("application-octet-stream"));
1747         attachment[QStringLiteral("name")] = a.label();
1748         const QString attachementStr = helper->generateLinkURL(QStringLiteral("ATTACH:%1").arg(QString::fromLatin1(a.label().toUtf8().toBase64())));
1749         attachment[QStringLiteral("uri")] = attachementStr;
1750         attachments.push_back(attachment);
1751     }
1752 
1753     return attachments;
1754 }
1755 
1756 //@cond PRIVATE
1757 template<typename T>
1758 class KCalUtils::IncidenceFormatter::ScheduleMessageVisitor : public Visitor
1759 {
1760 public:
1761     bool act(const IncidenceBase::Ptr &incidence, const Incidence::Ptr &existingIncidence, const ScheduleMessage::Ptr &msg, const QString &sender)
1762     {
1763         mExistingIncidence = existingIncidence;
1764         mMessage = msg;
1765         mSender = sender;
1766         return incidence->accept(*this, incidence);
1767     }
1768 
1769     [[nodiscard]] T result() const
1770     {
1771         return mResult;
1772     }
1773 
1774 protected:
1775     T mResult;
1776     Incidence::Ptr mExistingIncidence;
1777     ScheduleMessage::Ptr mMessage;
1778     QString mSender;
1779 };
1780 
1781 class KCalUtils::IncidenceFormatter::InvitationHeaderVisitor : public IncidenceFormatter::ScheduleMessageVisitor<QString>
1782 {
1783 protected:
1784     bool visit(const Event::Ptr &event) override
1785     {
1786         mResult = invitationHeaderEvent(event, mExistingIncidence, mMessage, mSender);
1787         return !mResult.isEmpty();
1788     }
1789 
1790     bool visit(const Todo::Ptr &todo) override
1791     {
1792         mResult = invitationHeaderTodo(todo, mExistingIncidence, mMessage, mSender);
1793         return !mResult.isEmpty();
1794     }
1795 
1796     bool visit(const Journal::Ptr &journal) override
1797     {
1798         mResult = invitationHeaderJournal(journal, mMessage);
1799         return !mResult.isEmpty();
1800     }
1801 
1802     bool visit(const FreeBusy::Ptr &fb) override
1803     {
1804         mResult = invitationHeaderFreeBusy(fb, mMessage);
1805         return !mResult.isEmpty();
1806     }
1807 };
1808 
1809 class KCalUtils::IncidenceFormatter::InvitationBodyVisitor : public IncidenceFormatter::ScheduleMessageVisitor<QVariantHash>
1810 {
1811 public:
1812     InvitationBodyVisitor(InvitationFormatterHelper *helper, bool noHtmlMode)
1813         : ScheduleMessageVisitor()
1814         , mHelper(helper)
1815         , mNoHtmlMode(noHtmlMode)
1816     {
1817     }
1818 
1819 protected:
1820     bool visit(const Event::Ptr &event) override
1821     {
1822         Event::Ptr oldevent = mExistingIncidence.dynamicCast<Event>();
1823         mResult = invitationDetailsEvent(mHelper, event, oldevent, mMessage, mNoHtmlMode);
1824         return !mResult.isEmpty();
1825     }
1826 
1827     bool visit(const Todo::Ptr &todo) override
1828     {
1829         Todo::Ptr oldtodo = mExistingIncidence.dynamicCast<Todo>();
1830         mResult = invitationDetailsTodo(todo, oldtodo, mMessage, mNoHtmlMode);
1831         return !mResult.isEmpty();
1832     }
1833 
1834     bool visit(const Journal::Ptr &journal) override
1835     {
1836         Journal::Ptr oldjournal = mExistingIncidence.dynamicCast<Journal>();
1837         mResult = invitationDetailsJournal(journal, oldjournal, mNoHtmlMode);
1838         return !mResult.isEmpty();
1839     }
1840 
1841     bool visit(const FreeBusy::Ptr &fb) override
1842     {
1843         mResult = invitationDetailsFreeBusy(fb, FreeBusy::Ptr(), mNoHtmlMode);
1844         return !mResult.isEmpty();
1845     }
1846 
1847 private:
1848     InvitationFormatterHelper *mHelper;
1849     bool mNoHtmlMode;
1850 };
1851 //@endcond
1852 
1853 class KCalUtils::InvitationFormatterHelperPrivate
1854 {
1855 };
1856 
1857 InvitationFormatterHelper::InvitationFormatterHelper()
1858     : d(nullptr)
1859 {
1860 }
1861 
1862 InvitationFormatterHelper::~InvitationFormatterHelper()
1863 {
1864 }
1865 
1866 QString InvitationFormatterHelper::generateLinkURL(const QString &id)
1867 {
1868     return id;
1869 }
1870 
1871 QString InvitationFormatterHelper::makeLink(const QString &id, const QString &text)
1872 {
1873     if (!id.startsWith(QLatin1StringView("ATTACH:"))) {
1874         const QString res = QStringLiteral("<a href=\"%1\"><font size=\"-1\"><b>%2</b></font></a>").arg(generateLinkURL(id), text);
1875         return res;
1876     } else {
1877         // draw the attachment links in non-bold face
1878         const QString res = QStringLiteral("<a href=\"%1\">%2</a>").arg(generateLinkURL(id), text);
1879         return res;
1880     }
1881 }
1882 
1883 // Check if the given incidence is likely one that we own instead one from
1884 // a shared calendar (Kolab-specific)
1885 static bool incidenceOwnedByMe(const Calendar::Ptr &calendar, const Incidence::Ptr &incidence)
1886 {
1887     Q_UNUSED(calendar)
1888     Q_UNUSED(incidence)
1889     return true;
1890 }
1891 
1892 static QVariantHash inviteButton(const QString &id, const QString &text, const QString &iconName, InvitationFormatterHelper *helper)
1893 {
1894     QVariantHash button;
1895     button[QStringLiteral("uri")] = helper->generateLinkURL(id);
1896     button[QStringLiteral("icon")] = iconName;
1897     button[QStringLiteral("label")] = text;
1898     return button;
1899 }
1900 
1901 static QVariantList responseButtons(const Incidence::Ptr &incidence,
1902                                     bool rsvpReq,
1903                                     bool rsvpRec,
1904                                     InvitationFormatterHelper *helper,
1905                                     const Incidence::Ptr &existingInc = Incidence::Ptr())
1906 {
1907     bool hideAccept = false;
1908     bool hideTentative = false;
1909     bool hideDecline = false;
1910 
1911     if (existingInc) {
1912         const Attendee ea = findMyAttendee(existingInc);
1913         if (!ea.isNull()) {
1914             // If this is an update of an already accepted incidence
1915             // to not show the buttons that confirm the status.
1916             hideAccept = ea.status() == Attendee::Accepted;
1917             hideDecline = ea.status() == Attendee::Declined;
1918             hideTentative = ea.status() == Attendee::Tentative;
1919         }
1920     }
1921 
1922     QVariantList buttons;
1923     if (!rsvpReq && (incidence && incidence->revision() == 0)) {
1924         // Record only
1925         buttons << inviteButton(QStringLiteral("record"), i18n("Record"), QStringLiteral("dialog-ok"), helper);
1926 
1927         // Move to trash
1928         buttons << inviteButton(QStringLiteral("delete"), i18n("Move to Trash"), QStringLiteral("edittrash"), helper);
1929     } else {
1930         // Accept
1931         if (!hideAccept) {
1932             buttons << inviteButton(QStringLiteral("accept"), i18nc("accept invitation", "Accept"), QStringLiteral("dialog-ok-apply"), helper);
1933         }
1934 
1935         // Tentative
1936         if (!hideTentative) {
1937             buttons << inviteButton(QStringLiteral("accept_conditionally"),
1938                                     i18nc("Accept invitation conditionally", "Tentative"),
1939                                     QStringLiteral("dialog-ok"),
1940                                     helper);
1941         }
1942 
1943         // Decline
1944         if (!hideDecline) {
1945             buttons << inviteButton(QStringLiteral("decline"), i18nc("decline invitation", "Decline"), QStringLiteral("dialog-cancel"), helper);
1946         }
1947 
1948         // Counter proposal
1949         buttons << inviteButton(QStringLiteral("counter"), i18nc("invitation counter proposal", "Counter proposal ..."), QStringLiteral("edit-undo"), helper);
1950     }
1951 
1952     if (!rsvpRec || (incidence && incidence->revision() > 0)) {
1953         // Delegate
1954         buttons << inviteButton(QStringLiteral("delegate"), i18nc("delegate invitation to another", "Delegate ..."), QStringLiteral("mail-forward"), helper);
1955     }
1956     return buttons;
1957 }
1958 
1959 static QVariantList counterButtons(InvitationFormatterHelper *helper)
1960 {
1961     QVariantList buttons;
1962 
1963     // Accept proposal
1964     buttons << inviteButton(QStringLiteral("accept_counter"), i18n("Accept"), QStringLiteral("dialog-ok-apply"), helper);
1965 
1966     // Decline proposal
1967     buttons << inviteButton(QStringLiteral("decline_counter"), i18n("Decline"), QStringLiteral("dialog-cancel"), helper);
1968 
1969     return buttons;
1970 }
1971 
1972 static QVariantList recordButtons(const Incidence::Ptr &incidence, InvitationFormatterHelper *helper)
1973 {
1974     QVariantList buttons;
1975     if (incidence) {
1976         buttons << inviteButton(QStringLiteral("reply"),
1977                                 incidence->type() == Incidence::TypeTodo ? i18n("Record invitation in my to-do list")
1978                                                                          : i18n("Record invitation in my calendar"),
1979                                 QStringLiteral("dialog-ok"),
1980                                 helper);
1981     }
1982     return buttons;
1983 }
1984 
1985 static QVariantList recordResponseButtons(const Incidence::Ptr &incidence, InvitationFormatterHelper *helper)
1986 {
1987     QVariantList buttons;
1988 
1989     if (incidence) {
1990         buttons << inviteButton(QStringLiteral("reply"),
1991                                 incidence->type() == Incidence::TypeTodo ? i18n("Record response in my to-do list") : i18n("Record response in my calendar"),
1992                                 QStringLiteral("dialog-ok"),
1993                                 helper);
1994     }
1995     return buttons;
1996 }
1997 
1998 static QVariantList cancelButtons(const Incidence::Ptr &incidence, InvitationFormatterHelper *helper)
1999 {
2000     QVariantList buttons;
2001 
2002     // Remove invitation
2003     if (incidence) {
2004         buttons << inviteButton(QStringLiteral("cancel"),
2005                                 incidence->type() == Incidence::TypeTodo ? i18n("Remove invitation from my to-do list")
2006                                                                          : i18n("Remove invitation from my calendar"),
2007                                 QStringLiteral("dialog-cancel"),
2008                                 helper);
2009     }
2010 
2011     return buttons;
2012 }
2013 
2014 static QVariantHash invitationStyle()
2015 {
2016     QVariantHash style;
2017     QPalette p;
2018     p.setCurrentColorGroup(QPalette::Normal);
2019     style[QStringLiteral("buttonBg")] = p.color(QPalette::Button).name();
2020     style[QStringLiteral("buttonBorder")] = p.shadow().color().name();
2021     style[QStringLiteral("buttonFg")] = p.color(QPalette::ButtonText).name();
2022     return style;
2023 }
2024 
2025 Calendar::Ptr InvitationFormatterHelper::calendar() const
2026 {
2027     return Calendar::Ptr();
2028 }
2029 
2030 static QString
2031 formatICalInvitationHelper(const QString &invitation, const Calendar::Ptr &mCalendar, InvitationFormatterHelper *helper, bool noHtmlMode, const QString &sender)
2032 {
2033     if (invitation.isEmpty()) {
2034         return QString();
2035     }
2036 
2037     ICalFormat format;
2038     // parseScheduleMessage takes the tz from the calendar,
2039     // no need to set it manually here for the format!
2040     ScheduleMessage::Ptr msg = format.parseScheduleMessage(mCalendar, invitation);
2041 
2042     if (!msg) {
2043         qCDebug(KCALUTILS_LOG) << "Failed to parse the scheduling message";
2044         Q_ASSERT(format.exception());
2045         qCDebug(KCALUTILS_LOG) << Stringify::errorMessage(*format.exception());
2046         return QString();
2047     }
2048 
2049     IncidenceBase::Ptr incBase = msg->event();
2050 
2051     incBase->shiftTimes(mCalendar->timeZone(), QTimeZone::systemTimeZone());
2052 
2053     // Determine if this incidence is in my calendar (and owned by me)
2054     Incidence::Ptr existingIncidence;
2055     if (incBase && helper->calendar()) {
2056         existingIncidence = helper->calendar()->incidence(incBase->uid(), incBase->recurrenceId());
2057 
2058         if (!incidenceOwnedByMe(helper->calendar(), existingIncidence)) {
2059             existingIncidence.clear();
2060         }
2061         if (!existingIncidence) {
2062             const Incidence::List list = helper->calendar()->incidences();
2063             for (Incidence::List::ConstIterator it = list.begin(), end = list.end(); it != end; ++it) {
2064                 if ((*it)->schedulingID() == incBase->uid() && incidenceOwnedByMe(helper->calendar(), *it)
2065                     && (*it)->recurrenceId() == incBase->recurrenceId()) {
2066                     existingIncidence = *it;
2067                     break;
2068                 }
2069             }
2070         }
2071     }
2072 
2073     Incidence::Ptr inc = incBase.staticCast<Incidence>(); // the incidence in the invitation email
2074 
2075     // If the IncidenceBase is a FreeBusy, then we cannot access the revision number in
2076     // the static-casted Incidence; so for sake of nothing better use 0 as the revision.
2077     int incRevision = 0;
2078     if (inc && inc->type() != Incidence::TypeFreeBusy) {
2079         incRevision = inc->revision();
2080     }
2081 
2082     IncidenceFormatter::InvitationHeaderVisitor headerVisitor;
2083     // The InvitationHeaderVisitor returns false if the incidence is somehow invalid, or not handled
2084     if (!headerVisitor.act(inc, existingIncidence, msg, sender)) {
2085         return QString();
2086     }
2087 
2088     QVariantHash incidence;
2089 
2090     // use the Outlook 2007 Comparison Style
2091     IncidenceFormatter::InvitationBodyVisitor bodyVisitor(helper, noHtmlMode);
2092     bool bodyOk;
2093     if (msg->method() == iTIPRequest || msg->method() == iTIPReply || msg->method() == iTIPDeclineCounter) {
2094         if (inc && existingIncidence && incRevision < existingIncidence->revision()) {
2095             bodyOk = bodyVisitor.act(existingIncidence, inc, msg, sender);
2096         } else {
2097             bodyOk = bodyVisitor.act(inc, existingIncidence, msg, sender);
2098         }
2099     } else {
2100         bodyOk = bodyVisitor.act(inc, Incidence::Ptr(), msg, sender);
2101     }
2102     if (!bodyOk) {
2103         return QString();
2104     }
2105 
2106     incidence = bodyVisitor.result();
2107     incidence[QStringLiteral("style")] = invitationStyle();
2108     incidence[QStringLiteral("head")] = headerVisitor.result();
2109 
2110     // determine if I am the organizer for this invitation
2111     bool myInc = iamOrganizer(inc);
2112 
2113     // determine if the invitation response has already been recorded
2114     bool rsvpRec = false;
2115     Attendee ea;
2116     if (!myInc) {
2117         Incidence::Ptr rsvpIncidence = existingIncidence;
2118         if (!rsvpIncidence && inc && incRevision > 0) {
2119             rsvpIncidence = inc;
2120         }
2121         if (rsvpIncidence) {
2122             ea = findMyAttendee(rsvpIncidence);
2123         }
2124         if (!ea.isNull() && (ea.status() == Attendee::Accepted || ea.status() == Attendee::Declined || ea.status() == Attendee::Tentative)) {
2125             rsvpRec = true;
2126         }
2127     }
2128 
2129     // determine invitation role
2130     QString role;
2131     bool isDelegated = false;
2132     Attendee a = findMyAttendee(inc);
2133     if (a.isNull() && inc) {
2134         if (!inc->attendees().isEmpty()) {
2135             a = inc->attendees().at(0);
2136         }
2137     }
2138     if (!a.isNull()) {
2139         isDelegated = (a.status() == Attendee::Delegated);
2140         role = Stringify::attendeeRole(a.role());
2141     }
2142 
2143     // determine if RSVP needed, not-needed, or response already recorded
2144     bool rsvpReq = rsvpRequested(inc);
2145     if (!rsvpReq && !a.isNull() && a.status() == Attendee::NeedsAction) {
2146         rsvpReq = true;
2147     }
2148 
2149     QString eventInfo;
2150     if (!myInc && !a.isNull()) {
2151         if (rsvpRec && inc) {
2152             if (incRevision == 0) {
2153                 eventInfo = i18n("Your <b>%1</b> response has been recorded.", Stringify::attendeeStatus(ea.status()));
2154             } else {
2155                 eventInfo = i18n("Your status for this invitation is <b>%1</b>.", Stringify::attendeeStatus(ea.status()));
2156             }
2157             rsvpReq = false;
2158         } else if (msg->method() == iTIPCancel) {
2159             eventInfo = i18n("This invitation was canceled.");
2160         } else if (msg->method() == iTIPAdd) {
2161             eventInfo = i18n("This invitation was accepted.");
2162         } else if (msg->method() == iTIPDeclineCounter) {
2163             rsvpReq = true;
2164             eventInfo = rsvpRequestedStr(rsvpReq, role);
2165         } else {
2166             if (!isDelegated) {
2167                 eventInfo = rsvpRequestedStr(rsvpReq, role);
2168             } else {
2169                 eventInfo = i18n("Awaiting delegation response.");
2170             }
2171         }
2172     }
2173     incidence[QStringLiteral("eventInfo")] = eventInfo;
2174 
2175     // Print if the organizer gave you a preset status
2176     QString myStatus;
2177     if (!myInc) {
2178         if (inc && incRevision == 0) {
2179             myStatus = myStatusStr(inc);
2180         }
2181     }
2182     incidence[QStringLiteral("myStatus")] = myStatus;
2183 
2184     // Add groupware links
2185     QVariantList buttons;
2186     switch (msg->method()) {
2187     case iTIPPublish:
2188     case iTIPRequest:
2189     case iTIPRefresh:
2190     case iTIPAdd:
2191         if (inc && incRevision > 0 && (existingIncidence || !helper->calendar())) {
2192             buttons += recordButtons(inc, helper);
2193         }
2194 
2195         if (!myInc) {
2196             if (!a.isNull()) {
2197                 buttons += responseButtons(inc, rsvpReq, rsvpRec, helper);
2198             } else {
2199                 buttons += responseButtons(inc, false, false, helper);
2200             }
2201         }
2202         break;
2203 
2204     case iTIPCancel:
2205         buttons = cancelButtons(inc, helper);
2206         break;
2207 
2208     case iTIPReply: {
2209         // Record invitation response
2210         Attendee a;
2211         Attendee ea;
2212         if (inc) {
2213             // First, determine if this reply is really a counter in disguise.
2214             if (replyMeansCounter(inc)) {
2215                 buttons = counterButtons(helper);
2216                 break;
2217             }
2218 
2219             // Next, maybe this is a declined reply that was delegated from me?
2220             // find first attendee who is delegated-from me
2221             // look a their PARTSTAT response, if the response is declined,
2222             // then we need to start over which means putting all the action
2223             // buttons and NOT putting on the [Record response..] button
2224             a = findDelegatedFromMyAttendee(inc);
2225             if (!a.isNull()) {
2226                 if (a.status() != Attendee::Accepted || a.status() != Attendee::Tentative) {
2227                     buttons = responseButtons(inc, rsvpReq, rsvpRec, helper);
2228                     break;
2229                 }
2230             }
2231 
2232             // Finally, simply allow a Record of the reply
2233             if (!inc->attendees().isEmpty()) {
2234                 a = inc->attendees().at(0);
2235             }
2236             if (!a.isNull() && helper->calendar()) {
2237                 ea = findAttendee(existingIncidence, a.email());
2238             }
2239         }
2240         if (!ea.isNull() && (ea.status() != Attendee::NeedsAction) && (ea.status() == a.status())) {
2241             const QString tStr = i18n("The <b>%1</b> response has been recorded", Stringify::attendeeStatus(ea.status()));
2242             buttons << inviteButton(QString(), tStr, QString(), helper);
2243         } else {
2244             if (inc) {
2245                 buttons = recordResponseButtons(inc, helper);
2246             }
2247         }
2248         break;
2249     }
2250 
2251     case iTIPCounter:
2252         // Counter proposal
2253         buttons = counterButtons(helper);
2254         break;
2255 
2256     case iTIPDeclineCounter:
2257         buttons << responseButtons(inc, rsvpReq, rsvpRec, helper);
2258         break;
2259 
2260     case iTIPNoMethod:
2261         break;
2262     }
2263 
2264     incidence[QStringLiteral("buttons")] = buttons;
2265 
2266     // Add the attendee list
2267     if (inc->type() == Incidence::TypeTodo) {
2268         incidence[QStringLiteral("attendeesTitle")] = i18n("Assignees:");
2269     } else {
2270         incidence[QStringLiteral("attendeesTitle")] = i18n("Participants:");
2271     }
2272     if (myInc) {
2273         incidence[QStringLiteral("attendees")] = invitationRsvpList(existingIncidence, a);
2274     } else {
2275         incidence[QStringLiteral("attendees")] = invitationAttendeeList(inc);
2276     }
2277 
2278     // Add the attachment list
2279     incidence[QStringLiteral("attachments")] = invitationAttachments(inc, helper);
2280 
2281     if (!inc->comments().isEmpty()) {
2282         incidence[QStringLiteral("comments")] = inc->comments();
2283     }
2284 
2285     QString templateName;
2286     switch (inc->type()) {
2287     case KCalendarCore::IncidenceBase::TypeEvent:
2288         templateName = QStringLiteral(":/itip_event.html");
2289         break;
2290     case KCalendarCore::IncidenceBase::TypeTodo:
2291         templateName = QStringLiteral(":/itip_todo.html");
2292         break;
2293     case KCalendarCore::IncidenceBase::TypeJournal:
2294         templateName = QStringLiteral(":/itip_journal.html");
2295         break;
2296     case KCalendarCore::IncidenceBase::TypeFreeBusy:
2297         templateName = QStringLiteral(":/itip_freebusy.html");
2298         break;
2299     case KCalendarCore::IncidenceBase::TypeUnknown:
2300         return QString();
2301     }
2302 
2303     return GrantleeTemplateManager::instance()->render(templateName, incidence);
2304 }
2305 
2306 //@endcond
2307 
2308 QString IncidenceFormatter::formatICalInvitation(const QString &invitation, const Calendar::Ptr &calendar, InvitationFormatterHelper *helper)
2309 {
2310     return formatICalInvitationHelper(invitation, calendar, helper, false, QString());
2311 }
2312 
2313 QString IncidenceFormatter::formatICalInvitationNoHtml(const QString &invitation,
2314                                                        const Calendar::Ptr &calendar,
2315                                                        InvitationFormatterHelper *helper,
2316                                                        const QString &sender)
2317 {
2318     return formatICalInvitationHelper(invitation, calendar, helper, true, sender);
2319 }
2320 
2321 /*******************************************************************
2322  *  Helper functions for the Incidence tooltips
2323  *******************************************************************/
2324 
2325 //@cond PRIVATE
2326 class KCalUtils::IncidenceFormatter::ToolTipVisitor : public Visitor
2327 {
2328 public:
2329     ToolTipVisitor() = default;
2330 
2331     bool act(const Calendar::Ptr &calendar, const IncidenceBase::Ptr &incidence, QDate date = QDate(), bool richText = true)
2332     {
2333         mCalendar = calendar;
2334         mLocation.clear();
2335         mDate = date;
2336         mRichText = richText;
2337         mResult = QLatin1StringView("");
2338         return incidence ? incidence->accept(*this, incidence) : false;
2339     }
2340 
2341     bool act(const QString &location, const IncidenceBase::Ptr &incidence, QDate date = QDate(), bool richText = true)
2342     {
2343         mLocation = location;
2344         mDate = date;
2345         mRichText = richText;
2346         mResult = QLatin1StringView("");
2347         return incidence ? incidence->accept(*this, incidence) : false;
2348     }
2349 
2350     [[nodiscard]] QString result() const
2351     {
2352         return mResult;
2353     }
2354 
2355 protected:
2356     bool visit(const Event::Ptr &event) override;
2357     bool visit(const Todo::Ptr &todo) override;
2358     bool visit(const Journal::Ptr &journal) override;
2359     bool visit(const FreeBusy::Ptr &fb) override;
2360 
2361     QString dateRangeText(const Event::Ptr &event, QDate date);
2362     QString dateRangeText(const Todo::Ptr &todo, QDate asOfDate);
2363     QString dateRangeText(const Journal::Ptr &journal);
2364     QString dateRangeText(const FreeBusy::Ptr &fb);
2365 
2366     QString generateToolTip(const Incidence::Ptr &incidence, const QString &dtRangeText);
2367 
2368 protected:
2369     Calendar::Ptr mCalendar;
2370     QString mLocation;
2371     QDate mDate;
2372     bool mRichText = true;
2373     QString mResult;
2374 };
2375 
2376 QString IncidenceFormatter::ToolTipVisitor::dateRangeText(const Event::Ptr &event, QDate date)
2377 {
2378     // FIXME: support mRichText==false
2379     QString ret;
2380     QString tmp;
2381 
2382     const auto startDts = event->startDateTimesForDate(date, QTimeZone::systemTimeZone());
2383     const auto startDt = startDts.empty() ? event->dtStart().toLocalTime() : startDts[0].toLocalTime();
2384     const auto endDt = event->endDateForStart(startDt).toLocalTime();
2385 
2386     if (event->isMultiDay()) {
2387         tmp = dateToString(startDt.date(), true);
2388         ret += QLatin1StringView("<br>") + i18nc("Event start", "<i>From:</i> %1", tmp);
2389 
2390         tmp = dateToString(endDt.date(), true);
2391         ret += QLatin1StringView("<br>") + i18nc("Event end", "<i>To:</i> %1", tmp);
2392     } else {
2393         ret += QLatin1StringView("<br>") + i18n("<i>Date:</i> %1", dateToString(startDt.date(), false));
2394         if (!event->allDay()) {
2395             const QString dtStartTime = timeToString(startDt.time(), true);
2396             const QString dtEndTime = timeToString(endDt.time(), true);
2397             if (dtStartTime == dtEndTime) {
2398                 // to prevent 'Time: 17:00 - 17:00'
2399                 tmp = QLatin1StringView("<br>") + i18nc("time for event", "<i>Time:</i> %1", dtStartTime);
2400             } else {
2401                 tmp = QLatin1StringView("<br>") + i18nc("time range for event", "<i>Time:</i> %1 - %2", dtStartTime, dtEndTime);
2402             }
2403             ret += tmp;
2404         }
2405     }
2406     return ret.replace(QLatin1Char(' '), QLatin1StringView("&nbsp;"));
2407 }
2408 
2409 QString IncidenceFormatter::ToolTipVisitor::dateRangeText(const Todo::Ptr &todo, QDate asOfDate)
2410 {
2411     // FIXME: support mRichText==false
2412     // FIXME: doesn't handle to-dos that occur more than once per day.
2413 
2414     QDateTime startDt{todo->dtStart(false)};
2415     QDateTime dueDt{todo->dtDue(false)};
2416 
2417     if (todo->recurs() && asOfDate.isValid()) {
2418         const QDateTime limit{asOfDate.addDays(1), QTime(0, 0, 0), Qt::LocalTime};
2419         startDt = todo->recurrence()->getPreviousDateTime(limit);
2420         if (startDt.isValid() && todo->hasDueDate()) {
2421             if (todo->allDay()) {
2422                 // Days, not seconds, because not all days are 24 hours long.
2423                 const auto duration{todo->dtStart(true).daysTo(todo->dtDue(true))};
2424                 dueDt = startDt.addDays(duration);
2425             } else {
2426                 const auto duration{todo->dtStart(true).secsTo(todo->dtDue(true))};
2427                 dueDt = startDt.addSecs(duration);
2428             }
2429         }
2430     }
2431 
2432     QString ret;
2433     if (startDt.isValid()) {
2434         ret = QLatin1StringView("<br>") % i18nc("To-do's start date", "<i>Start:</i> %1", dateTimeToString(startDt, todo->allDay(), false));
2435     }
2436     if (dueDt.isValid()) {
2437         ret += QLatin1StringView("<br>") % i18nc("To-do's due date", "<i>Due:</i> %1", dateTimeToString(dueDt, todo->allDay(), false));
2438     }
2439 
2440     // Print priority and completed info here, for lack of a better place
2441 
2442     if (todo->priority() > 0) {
2443         ret += QLatin1StringView("<br>") % i18nc("To-do's priority number", "<i>Priority:</i> %1", QString::number(todo->priority()));
2444     }
2445 
2446     ret += QLatin1StringView("<br>");
2447     if (todo->hasCompletedDate()) {
2448         ret += i18nc("To-do's completed date", "<i>Completed:</i> %1", dateTimeToString(todo->completed(), false, false));
2449     } else {
2450         int pct = todo->percentComplete();
2451         if (todo->recurs() && asOfDate.isValid()) {
2452             const QDate recurrenceDate = todo->dtRecurrence().date();
2453             if (recurrenceDate < startDt.date()) {
2454                 pct = 0;
2455             } else if (recurrenceDate > startDt.date()) {
2456                 pct = 100;
2457             }
2458         }
2459         ret += i18nc("To-do's percent complete:", "<i>Percent Done:</i> %1%", pct);
2460     }
2461 
2462     return ret.replace(QLatin1Char(' '), QLatin1StringView("&nbsp;"));
2463 }
2464 
2465 QString IncidenceFormatter::ToolTipVisitor::dateRangeText(const Journal::Ptr &journal)
2466 {
2467     // FIXME: support mRichText==false
2468     QString ret;
2469     if (journal->dtStart().isValid()) {
2470         ret += QLatin1StringView("<br>") + i18n("<i>Date:</i> %1", dateToString(journal->dtStart().toLocalTime().date(), false));
2471     }
2472     return ret.replace(QLatin1Char(' '), QLatin1StringView("&nbsp;"));
2473 }
2474 
2475 QString IncidenceFormatter::ToolTipVisitor::dateRangeText(const FreeBusy::Ptr &fb)
2476 {
2477     // FIXME: support mRichText==false
2478     QString ret = QLatin1StringView("<br>") + i18n("<i>Period start:</i> %1", QLocale().toString(fb->dtStart(), QLocale::ShortFormat));
2479     ret += QLatin1StringView("<br>") + i18n("<i>Period start:</i> %1", QLocale().toString(fb->dtEnd(), QLocale::ShortFormat));
2480     return ret.replace(QLatin1Char(' '), QLatin1StringView("&nbsp;"));
2481 }
2482 
2483 bool IncidenceFormatter::ToolTipVisitor::visit(const Event::Ptr &event)
2484 {
2485     mResult = generateToolTip(event, dateRangeText(event, mDate));
2486     return !mResult.isEmpty();
2487 }
2488 
2489 bool IncidenceFormatter::ToolTipVisitor::visit(const Todo::Ptr &todo)
2490 {
2491     mResult = generateToolTip(todo, dateRangeText(todo, mDate));
2492     return !mResult.isEmpty();
2493 }
2494 
2495 bool IncidenceFormatter::ToolTipVisitor::visit(const Journal::Ptr &journal)
2496 {
2497     mResult = generateToolTip(journal, dateRangeText(journal));
2498     return !mResult.isEmpty();
2499 }
2500 
2501 bool IncidenceFormatter::ToolTipVisitor::visit(const FreeBusy::Ptr &fb)
2502 {
2503     // FIXME: support mRichText==false
2504     mResult = QLatin1StringView("<qt><b>") + i18n("Free/Busy information for %1", fb->organizer().fullName()) + QLatin1StringView("</b>");
2505     mResult += dateRangeText(fb);
2506     mResult += QLatin1StringView("</qt>");
2507     return !mResult.isEmpty();
2508 }
2509 
2510 static QString tooltipPerson(const QString &email, const QString &name, Attendee::PartStat status)
2511 {
2512     // Search for a new print name, if needed.
2513     const QString printName = searchName(email, name);
2514 
2515     // Get the icon corresponding to the attendee participation status.
2516     const QString iconPath = KIconLoader::global()->iconPath(rsvpStatusIconName(status), KIconLoader::Small);
2517 
2518     // Make the return string.
2519     QString personString;
2520     if (!iconPath.isEmpty()) {
2521         personString += QLatin1StringView(R"(<img valign="top" src=")") + iconPath + QLatin1StringView("\">") + QLatin1StringView("&nbsp;");
2522     }
2523     if (status != Attendee::None) {
2524         personString += i18nc("attendee name (attendee status)", "%1 (%2)", printName.isEmpty() ? email : printName, Stringify::attendeeStatus(status));
2525     } else {
2526         personString += i18n("%1", printName.isEmpty() ? email : printName);
2527     }
2528     return personString;
2529 }
2530 
2531 static QString tooltipFormatOrganizer(const QString &email, const QString &name)
2532 {
2533     // Search for a new print name, if needed
2534     const QString printName = searchName(email, name);
2535 
2536     // Get the icon for organizer
2537     // TODO fixme laurent: use another icon. It doesn't exist in breeze.
2538     const QString iconPath = KIconLoader::global()->iconPath(QStringLiteral("meeting-organizer"), KIconLoader::Small, true);
2539 
2540     // Make the return string.
2541     QString personString;
2542     if (!iconPath.isEmpty()) {
2543         personString += QLatin1StringView(R"(<img valign="top" src=")") + iconPath + QLatin1StringView("\">") + QLatin1StringView("&nbsp;");
2544     }
2545     personString += (printName.isEmpty() ? email : printName);
2546     return personString;
2547 }
2548 
2549 static QString tooltipFormatAttendeeRoleList(const Incidence::Ptr &incidence, Attendee::Role role, bool showStatus)
2550 {
2551     int maxNumAtts = 8; // maximum number of people to print per attendee role
2552     const QString etc = i18nc("ellipsis", "...");
2553 
2554     int i = 0;
2555     QString tmpStr;
2556     const Attendee::List attendees = incidence->attendees();
2557     for (const auto &a : attendees) {
2558         if (a.role() != role) {
2559             // skip not this role
2560             continue;
2561         }
2562         if (attendeeIsOrganizer(incidence, a)) {
2563             // skip attendee that is also the organizer
2564             continue;
2565         }
2566         if (i == maxNumAtts) {
2567             tmpStr += QLatin1StringView("&nbsp;&nbsp;") + etc;
2568             break;
2569         }
2570         tmpStr += QLatin1StringView("&nbsp;&nbsp;") + tooltipPerson(a.email(), a.name(), showStatus ? a.status() : Attendee::None);
2571         if (!a.delegator().isEmpty()) {
2572             tmpStr += i18n(" (delegated by %1)", a.delegator());
2573         }
2574         if (!a.delegate().isEmpty()) {
2575             tmpStr += i18n(" (delegated to %1)", a.delegate());
2576         }
2577         tmpStr += QLatin1StringView("<br>");
2578         i++;
2579     }
2580     if (tmpStr.endsWith(QLatin1StringView("<br>"))) {
2581         tmpStr.chop(4);
2582     }
2583     return tmpStr;
2584 }
2585 
2586 static QString tooltipFormatAttendees(const Calendar::Ptr &calendar, const Incidence::Ptr &incidence)
2587 {
2588     QString tmpStr;
2589     QString str;
2590 
2591     // Add organizer link
2592     const int attendeeCount = incidence->attendees().count();
2593     if (attendeeCount > 1 || (attendeeCount == 1 && !attendeeIsOrganizer(incidence, incidence->attendees().at(0)))) {
2594         tmpStr += QLatin1StringView("<i>") + i18n("Organizer:") + QLatin1StringView("</i>") + QLatin1StringView("<br>");
2595         tmpStr += QLatin1StringView("&nbsp;&nbsp;") + tooltipFormatOrganizer(incidence->organizer().email(), incidence->organizer().name());
2596     }
2597 
2598     // Show the attendee status if the incidence's organizer owns the resource calendar,
2599     // which means they are running the show and have all the up-to-date response info.
2600     const bool showStatus = attendeeCount > 0 && incOrganizerOwnsCalendar(calendar, incidence);
2601 
2602     // Add "chair"
2603     str = tooltipFormatAttendeeRoleList(incidence, Attendee::Chair, showStatus);
2604     if (!str.isEmpty()) {
2605         tmpStr += QLatin1StringView("<br><i>") + i18n("Chair:") + QLatin1StringView("</i>") + QLatin1StringView("<br>");
2606         tmpStr += str;
2607     }
2608 
2609     // Add required participants
2610     str = tooltipFormatAttendeeRoleList(incidence, Attendee::ReqParticipant, showStatus);
2611     if (!str.isEmpty()) {
2612         tmpStr += QLatin1StringView("<br><i>") + i18n("Required Participants:") + QLatin1StringView("</i>") + QLatin1StringView("<br>");
2613         tmpStr += str;
2614     }
2615 
2616     // Add optional participants
2617     str = tooltipFormatAttendeeRoleList(incidence, Attendee::OptParticipant, showStatus);
2618     if (!str.isEmpty()) {
2619         tmpStr += QLatin1StringView("<br><i>") + i18n("Optional Participants:") + QLatin1StringView("</i>") + QLatin1StringView("<br>");
2620         tmpStr += str;
2621     }
2622 
2623     // Add observers
2624     str = tooltipFormatAttendeeRoleList(incidence, Attendee::NonParticipant, showStatus);
2625     if (!str.isEmpty()) {
2626         tmpStr += QLatin1StringView("<br><i>") + i18n("Observers:") + QLatin1StringView("</i>") + QLatin1StringView("<br>");
2627         tmpStr += str;
2628     }
2629 
2630     return tmpStr;
2631 }
2632 
2633 QString IncidenceFormatter::ToolTipVisitor::generateToolTip(const Incidence::Ptr &incidence, const QString &dtRangeText)
2634 {
2635     // FIXME: support mRichText==false
2636     if (!incidence) {
2637         return QString();
2638     }
2639 
2640     QString tmp = QStringLiteral("<qt>");
2641 
2642     // header
2643     tmp += QLatin1StringView("<b>") + incidence->richSummary() + QLatin1StringView("</b>");
2644     tmp += QLatin1StringView("<hr>");
2645 
2646     QString calStr = mLocation;
2647     if (mCalendar) {
2648         calStr = resourceString(mCalendar, incidence);
2649     }
2650     if (!calStr.isEmpty()) {
2651         tmp += QLatin1StringView("<i>") + i18n("Calendar:") + QLatin1StringView("</i>") + QLatin1StringView("&nbsp;");
2652         tmp += calStr;
2653     }
2654 
2655     tmp += dtRangeText;
2656 
2657     if (!incidence->location().isEmpty()) {
2658         tmp += QLatin1StringView("<br>");
2659         tmp += QLatin1StringView("<i>") + i18n("Location:") + QLatin1StringView("</i>") + QLatin1StringView("&nbsp;");
2660         tmp += incidence->richLocation();
2661     }
2662 
2663     QString durStr = durationString(incidence);
2664     if (!durStr.isEmpty()) {
2665         tmp += QLatin1StringView("<br>");
2666         tmp += QLatin1StringView("<i>") + i18n("Duration:") + QLatin1StringView("</i>") + QLatin1StringView("&nbsp;");
2667         tmp += durStr;
2668     }
2669 
2670     if (incidence->recurs()) {
2671         tmp += QLatin1StringView("<br>");
2672         tmp += QLatin1StringView("<i>") + i18n("Recurrence:") + QLatin1StringView("</i>") + QLatin1StringView("&nbsp;");
2673         tmp += recurrenceString(incidence);
2674     }
2675 
2676     if (incidence->hasRecurrenceId()) {
2677         tmp += QLatin1StringView("<br>");
2678         tmp += QLatin1StringView("<i>") + i18n("Recurrence:") + QLatin1StringView("</i>") + QLatin1StringView("&nbsp;");
2679         tmp += i18n("Exception");
2680     }
2681 
2682     if (!incidence->description().isEmpty()) {
2683         QString desc(incidence->description());
2684         if (!incidence->descriptionIsRich()) {
2685             int maxDescLen = 120; // maximum description chars to print (before ellipsis)
2686             if (desc.length() > maxDescLen) {
2687                 desc = desc.left(maxDescLen) + i18nc("ellipsis", "...");
2688             }
2689             desc = desc.toHtmlEscaped().replace(QLatin1Char('\n'), QLatin1StringView("<br>"));
2690         } else {
2691             // TODO: truncate the description when it's rich text
2692         }
2693         tmp += QLatin1StringView("<hr>");
2694         tmp += QLatin1StringView("<i>") + i18n("Description:") + QLatin1StringView("</i>") + QLatin1StringView("<br>");
2695         tmp += desc;
2696     }
2697 
2698     bool needAnHorizontalLine = true;
2699     const int reminderCount = incidence->alarms().count();
2700     if (reminderCount > 0 && incidence->hasEnabledAlarms()) {
2701         if (needAnHorizontalLine) {
2702             tmp += QLatin1StringView("<hr>");
2703             needAnHorizontalLine = false;
2704         }
2705         tmp += QLatin1StringView("<br>");
2706         tmp += QLatin1StringView("<i>") + i18np("Reminder:", "Reminders:", reminderCount) + QLatin1StringView("</i>") + QLatin1StringView("&nbsp;");
2707         tmp += reminderStringList(incidence).join(QLatin1StringView(", "));
2708     }
2709 
2710     const QString attendees = tooltipFormatAttendees(mCalendar, incidence);
2711     if (!attendees.isEmpty()) {
2712         if (needAnHorizontalLine) {
2713             tmp += QLatin1StringView("<hr>");
2714             needAnHorizontalLine = false;
2715         }
2716         tmp += QLatin1StringView("<br>");
2717         tmp += attendees;
2718     }
2719 
2720     int categoryCount = incidence->categories().count();
2721     if (categoryCount > 0) {
2722         if (needAnHorizontalLine) {
2723             tmp += QLatin1StringView("<hr>");
2724         }
2725         tmp += QLatin1StringView("<br>");
2726         tmp += QLatin1StringView("<i>") + i18np("Tag:", "Tags:", categoryCount) + QLatin1StringView("</i>") + QLatin1StringView("&nbsp;");
2727         tmp += incidence->categories().join(QLatin1StringView(", "));
2728     }
2729 
2730     tmp += QLatin1StringView("</qt>");
2731     return tmp;
2732 }
2733 
2734 //@endcond
2735 
2736 QString IncidenceFormatter::toolTipStr(const QString &sourceName, const IncidenceBase::Ptr &incidence, QDate date, bool richText)
2737 {
2738     ToolTipVisitor v;
2739     if (incidence && v.act(sourceName, incidence, date, richText)) {
2740         return v.result();
2741     } else {
2742         return QString();
2743     }
2744 }
2745 
2746 /*******************************************************************
2747  *  Helper functions for the Incidence tooltips
2748  *******************************************************************/
2749 
2750 //@cond PRIVATE
2751 static QString mailBodyIncidence(const Incidence::Ptr &incidence)
2752 {
2753     QString body;
2754     if (!incidence->summary().trimmed().isEmpty()) {
2755         body += i18n("Summary: %1\n", incidence->richSummary());
2756     }
2757     if (!incidence->organizer().isEmpty()) {
2758         body += i18n("Organizer: %1\n", incidence->organizer().fullName());
2759     }
2760     if (!incidence->location().trimmed().isEmpty()) {
2761         body += i18n("Location: %1\n", incidence->richLocation());
2762     }
2763     return body;
2764 }
2765 
2766 //@endcond
2767 
2768 //@cond PRIVATE
2769 class KCalUtils::IncidenceFormatter::MailBodyVisitor : public Visitor
2770 {
2771 public:
2772     bool act(const IncidenceBase::Ptr &incidence)
2773     {
2774         mResult = QLatin1StringView("");
2775         return incidence ? incidence->accept(*this, incidence) : false;
2776     }
2777 
2778     [[nodiscard]] QString result() const
2779     {
2780         return mResult;
2781     }
2782 
2783 protected:
2784     bool visit(const Event::Ptr &event) override;
2785     bool visit(const Todo::Ptr &todo) override;
2786     bool visit(const Journal::Ptr &journal) override;
2787     bool visit(const FreeBusy::Ptr &) override
2788     {
2789         mResult = i18n("This is a Free Busy Object");
2790         return true;
2791     }
2792 
2793 protected:
2794     QString mResult;
2795 };
2796 
2797 bool IncidenceFormatter::MailBodyVisitor::visit(const Event::Ptr &event)
2798 {
2799     QString recurrence[] = {i18nc("no recurrence", "None"),
2800                             i18nc("event recurs by minutes", "Minutely"),
2801                             i18nc("event recurs by hours", "Hourly"),
2802                             i18nc("event recurs by days", "Daily"),
2803                             i18nc("event recurs by weeks", "Weekly"),
2804                             i18nc("event recurs same position (e.g. first monday) each month", "Monthly Same Position"),
2805                             i18nc("event recurs same day each month", "Monthly Same Day"),
2806                             i18nc("event recurs same month each year", "Yearly Same Month"),
2807                             i18nc("event recurs same day each year", "Yearly Same Day"),
2808                             i18nc("event recurs same position (e.g. first monday) each year", "Yearly Same Position")};
2809 
2810     mResult = mailBodyIncidence(event);
2811     mResult += i18n("Start Date: %1\n", dateToString(event->dtStart().toLocalTime().date(), true));
2812     if (!event->allDay()) {
2813         mResult += i18n("Start Time: %1\n", timeToString(event->dtStart().toLocalTime().time(), true));
2814     }
2815     if (event->dtStart() != event->dtEnd()) {
2816         mResult += i18n("End Date: %1\n", dateToString(event->dtEnd().toLocalTime().date(), true));
2817     }
2818     if (!event->allDay()) {
2819         mResult += i18n("End Time: %1\n", timeToString(event->dtEnd().toLocalTime().time(), true));
2820     }
2821     if (event->recurs()) {
2822         Recurrence *recur = event->recurrence();
2823         // TODO: Merge these two to one of the form "Recurs every 3 days"
2824         mResult += i18n("Recurs: %1\n", recurrence[recur->recurrenceType()]);
2825         mResult += i18n("Frequency: %1\n", event->recurrence()->frequency());
2826 
2827         if (recur->duration() > 0) {
2828             mResult += i18np("Repeats once", "Repeats %1 times", recur->duration());
2829             mResult += QLatin1Char('\n');
2830         } else {
2831             if (recur->duration() != -1) {
2832                 // TODO_Recurrence: What to do with all-day
2833                 QString endstr;
2834                 if (event->allDay()) {
2835                     endstr = QLocale().toString(recur->endDate());
2836                 } else {
2837                     endstr = QLocale().toString(recur->endDateTime(), QLocale::ShortFormat);
2838                 }
2839                 mResult += i18n("Repeat until: %1\n", endstr);
2840             } else {
2841                 mResult += i18n("Repeats forever\n");
2842             }
2843         }
2844     }
2845 
2846     if (!event->description().isEmpty()) {
2847         QString descStr;
2848         if (event->descriptionIsRich() || event->description().startsWith(QLatin1StringView("<!DOCTYPE HTML"))) {
2849             descStr = cleanHtml(event->description());
2850         } else {
2851             descStr = event->description();
2852         }
2853         if (!descStr.isEmpty()) {
2854             mResult += i18n("Details:\n%1\n", descStr);
2855         }
2856     }
2857     return !mResult.isEmpty();
2858 }
2859 
2860 bool IncidenceFormatter::MailBodyVisitor::visit(const Todo::Ptr &todo)
2861 {
2862     mResult = mailBodyIncidence(todo);
2863 
2864     if (todo->hasStartDate() && todo->dtStart().isValid()) {
2865         mResult += i18n("Start Date: %1\n", dateToString(todo->dtStart(false).toLocalTime().date(), true));
2866         if (!todo->allDay()) {
2867             mResult += i18n("Start Time: %1\n", timeToString(todo->dtStart(false).toLocalTime().time(), true));
2868         }
2869     }
2870     if (todo->hasDueDate() && todo->dtDue().isValid()) {
2871         mResult += i18n("Due Date: %1\n", dateToString(todo->dtDue().toLocalTime().date(), true));
2872         if (!todo->allDay()) {
2873             mResult += i18n("Due Time: %1\n", timeToString(todo->dtDue().toLocalTime().time(), true));
2874         }
2875     }
2876     QString details = todo->richDescription();
2877     if (!details.isEmpty()) {
2878         mResult += i18n("Details:\n%1\n", details);
2879     }
2880     return !mResult.isEmpty();
2881 }
2882 
2883 bool IncidenceFormatter::MailBodyVisitor::visit(const Journal::Ptr &journal)
2884 {
2885     mResult = mailBodyIncidence(journal);
2886     mResult += i18n("Date: %1\n", dateToString(journal->dtStart().toLocalTime().date(), true));
2887     if (!journal->allDay()) {
2888         mResult += i18n("Time: %1\n", timeToString(journal->dtStart().toLocalTime().time(), true));
2889     }
2890     if (!journal->description().isEmpty()) {
2891         mResult += i18n("Text of the journal:\n%1\n", journal->richDescription());
2892     }
2893     return true;
2894 }
2895 
2896 //@endcond
2897 
2898 QString IncidenceFormatter::mailBodyStr(const IncidenceBase::Ptr &incidence)
2899 {
2900     if (!incidence) {
2901         return QString();
2902     }
2903 
2904     MailBodyVisitor v;
2905     if (v.act(incidence)) {
2906         return v.result();
2907     }
2908     return QString();
2909 }
2910 
2911 //@cond PRIVATE
2912 static QString recurEnd(const Incidence::Ptr &incidence)
2913 {
2914     QString endstr;
2915     if (incidence->allDay()) {
2916         endstr = QLocale().toString(incidence->recurrence()->endDate());
2917     } else {
2918         endstr = QLocale().toString(incidence->recurrence()->endDateTime().toLocalTime(), QLocale::ShortFormat);
2919     }
2920     return endstr;
2921 }
2922 
2923 //@endcond
2924 
2925 /************************************
2926  *  More static formatting functions
2927  ************************************/
2928 
2929 QString IncidenceFormatter::recurrenceString(const Incidence::Ptr &incidence)
2930 {
2931     if (incidence->hasRecurrenceId()) {
2932         return QStringLiteral("Recurrence exception");
2933     }
2934 
2935     if (!incidence->recurs()) {
2936         return i18n("No recurrence");
2937     }
2938     static QStringList dayList;
2939     if (dayList.isEmpty()) {
2940         dayList.append(i18n("31st Last"));
2941         dayList.append(i18n("30th Last"));
2942         dayList.append(i18n("29th Last"));
2943         dayList.append(i18n("28th Last"));
2944         dayList.append(i18n("27th Last"));
2945         dayList.append(i18n("26th Last"));
2946         dayList.append(i18n("25th Last"));
2947         dayList.append(i18n("24th Last"));
2948         dayList.append(i18n("23rd Last"));
2949         dayList.append(i18n("22nd Last"));
2950         dayList.append(i18n("21st Last"));
2951         dayList.append(i18n("20th Last"));
2952         dayList.append(i18n("19th Last"));
2953         dayList.append(i18n("18th Last"));
2954         dayList.append(i18n("17th Last"));
2955         dayList.append(i18n("16th Last"));
2956         dayList.append(i18n("15th Last"));
2957         dayList.append(i18n("14th Last"));
2958         dayList.append(i18n("13th Last"));
2959         dayList.append(i18n("12th Last"));
2960         dayList.append(i18n("11th Last"));
2961         dayList.append(i18n("10th Last"));
2962         dayList.append(i18n("9th Last"));
2963         dayList.append(i18n("8th Last"));
2964         dayList.append(i18n("7th Last"));
2965         dayList.append(i18n("6th Last"));
2966         dayList.append(i18n("5th Last"));
2967         dayList.append(i18n("4th Last"));
2968         dayList.append(i18n("3rd Last"));
2969         dayList.append(i18n("2nd Last"));
2970         dayList.append(i18nc("last day of the month", "Last"));
2971         dayList.append(i18nc("unknown day of the month", "unknown")); //#31 - zero offset from UI
2972         dayList.append(i18n("1st"));
2973         dayList.append(i18n("2nd"));
2974         dayList.append(i18n("3rd"));
2975         dayList.append(i18n("4th"));
2976         dayList.append(i18n("5th"));
2977         dayList.append(i18n("6th"));
2978         dayList.append(i18n("7th"));
2979         dayList.append(i18n("8th"));
2980         dayList.append(i18n("9th"));
2981         dayList.append(i18n("10th"));
2982         dayList.append(i18n("11th"));
2983         dayList.append(i18n("12th"));
2984         dayList.append(i18n("13th"));
2985         dayList.append(i18n("14th"));
2986         dayList.append(i18n("15th"));
2987         dayList.append(i18n("16th"));
2988         dayList.append(i18n("17th"));
2989         dayList.append(i18n("18th"));
2990         dayList.append(i18n("19th"));
2991         dayList.append(i18n("20th"));
2992         dayList.append(i18n("21st"));
2993         dayList.append(i18n("22nd"));
2994         dayList.append(i18n("23rd"));
2995         dayList.append(i18n("24th"));
2996         dayList.append(i18n("25th"));
2997         dayList.append(i18n("26th"));
2998         dayList.append(i18n("27th"));
2999         dayList.append(i18n("28th"));
3000         dayList.append(i18n("29th"));
3001         dayList.append(i18n("30th"));
3002         dayList.append(i18n("31st"));
3003     }
3004 
3005     const int weekStart = QLocale().firstDayOfWeek();
3006     QString dayNames;
3007 
3008     Recurrence *recur = incidence->recurrence();
3009 
3010     QString recurStr;
3011     static QString noRecurrence = i18n("No recurrence");
3012     switch (recur->recurrenceType()) {
3013     case Recurrence::rNone:
3014         return noRecurrence;
3015 
3016     case Recurrence::rMinutely:
3017         if (recur->duration() != -1) {
3018             recurStr = i18np("Recurs every minute until %2", "Recurs every %1 minutes until %2", recur->frequency(), recurEnd(incidence));
3019             if (recur->duration() > 0) {
3020                 recurStr += i18nc("number of occurrences", " (%1 occurrences)", QString::number(recur->duration()));
3021             }
3022         } else {
3023             recurStr = i18np("Recurs every minute", "Recurs every %1 minutes", recur->frequency());
3024         }
3025         break;
3026 
3027     case Recurrence::rHourly:
3028         if (recur->duration() != -1) {
3029             recurStr = i18np("Recurs hourly until %2", "Recurs every %1 hours until %2", recur->frequency(), recurEnd(incidence));
3030             if (recur->duration() > 0) {
3031                 recurStr += i18nc("number of occurrences", " (%1 occurrences)", QString::number(recur->duration()));
3032             }
3033         } else {
3034             recurStr = i18np("Recurs hourly", "Recurs every %1 hours", recur->frequency());
3035         }
3036         break;
3037 
3038     case Recurrence::rDaily:
3039         if (recur->duration() != -1) {
3040             recurStr = i18np("Recurs daily until %2", "Recurs every %1 days until %2", recur->frequency(), recurEnd(incidence));
3041             if (recur->duration() > 0) {
3042                 recurStr += i18nc("number of occurrences", " (%1 occurrences)", QString::number(recur->duration()));
3043             }
3044         } else {
3045             recurStr = i18np("Recurs daily", "Recurs every %1 days", recur->frequency());
3046         }
3047         break;
3048 
3049     case Recurrence::rWeekly: {
3050         bool addSpace = false;
3051         for (int i = 0; i < 7; ++i) {
3052             if (recur->days().testBit((i + weekStart + 6) % 7)) {
3053                 if (addSpace) {
3054                     dayNames.append(i18nc("separator for list of days", ", "));
3055                 }
3056                 dayNames.append(QLocale().dayName(((i + weekStart + 6) % 7) + 1, QLocale::ShortFormat));
3057                 addSpace = true;
3058             }
3059         }
3060         if (dayNames.isEmpty()) {
3061             dayNames = i18nc("Recurs weekly on no days", "no days");
3062         }
3063         if (recur->duration() != -1) {
3064             recurStr = i18ncp("Recurs weekly on [list of days] until end-date",
3065                               "Recurs weekly on %2 until %3",
3066                               "Recurs every %1 weeks on %2 until %3",
3067                               recur->frequency(),
3068                               dayNames,
3069                               recurEnd(incidence));
3070             if (recur->duration() > 0) {
3071                 recurStr += i18nc("number of occurrences", " (%1 occurrences)", recur->duration());
3072             }
3073         } else {
3074             recurStr = i18ncp("Recurs weekly on [list of days]", "Recurs weekly on %2", "Recurs every %1 weeks on %2", recur->frequency(), dayNames);
3075         }
3076         break;
3077     }
3078     case Recurrence::rMonthlyPos:
3079         if (!recur->monthPositions().isEmpty()) {
3080             RecurrenceRule::WDayPos rule = recur->monthPositions().at(0);
3081             if (recur->duration() != -1) {
3082                 recurStr = i18ncp(
3083                     "Recurs every N months on the [2nd|3rd|...]"
3084                     " weekdayname until end-date",
3085                     "Recurs every month on the %2 %3 until %4",
3086                     "Recurs every %1 months on the %2 %3 until %4",
3087                     recur->frequency(),
3088                     dayList[rule.pos() + 31],
3089                     QLocale().dayName(rule.day(), QLocale::LongFormat),
3090                     recurEnd(incidence));
3091                 if (recur->duration() > 0) {
3092                     recurStr += xi18nc("number of occurrences", " (%1 occurrences)", recur->duration());
3093                 }
3094             } else {
3095                 recurStr = i18ncp("Recurs every N months on the [2nd|3rd|...] weekdayname",
3096                                   "Recurs every month on the %2 %3",
3097                                   "Recurs every %1 months on the %2 %3",
3098                                   recur->frequency(),
3099                                   dayList[rule.pos() + 31],
3100                                   QLocale().dayName(rule.day(), QLocale::LongFormat));
3101             }
3102         }
3103         break;
3104     case Recurrence::rMonthlyDay:
3105         if (!recur->monthDays().isEmpty()) {
3106             int days = recur->monthDays().at(0);
3107             if (recur->duration() != -1) {
3108                 recurStr = i18ncp("Recurs monthly on the [1st|2nd|...] day until end-date",
3109                                   "Recurs monthly on the %2 day until %3",
3110                                   "Recurs every %1 months on the %2 day until %3",
3111                                   recur->frequency(),
3112                                   dayList[days + 31],
3113                                   recurEnd(incidence));
3114                 if (recur->duration() > 0) {
3115                     recurStr += xi18nc("number of occurrences", " (%1 occurrences)", recur->duration());
3116                 }
3117             } else {
3118                 recurStr = i18ncp("Recurs monthly on the [1st|2nd|...] day",
3119                                   "Recurs monthly on the %2 day",
3120                                   "Recurs every %1 month on the %2 day",
3121                                   recur->frequency(),
3122                                   dayList[days + 31]);
3123             }
3124         }
3125         break;
3126     case Recurrence::rYearlyMonth:
3127         if (recur->duration() != -1) {
3128             if (!recur->yearDates().isEmpty() && !recur->yearMonths().isEmpty()) {
3129                 recurStr = i18ncp(
3130                     "Recurs Every N years on month-name [1st|2nd|...]"
3131                     " until end-date",
3132                     "Recurs yearly on %2 %3 until %4",
3133                     "Recurs every %1 years on %2 %3 until %4",
3134                     recur->frequency(),
3135                     QLocale().monthName(recur->yearMonths().at(0), QLocale::LongFormat),
3136                     dayList.at(recur->yearDates().at(0) + 31),
3137                     recurEnd(incidence));
3138                 if (recur->duration() > 0) {
3139                     recurStr += i18nc("number of occurrences", " (%1 occurrences)", recur->duration());
3140                 }
3141             }
3142         } else {
3143             if (!recur->yearDates().isEmpty() && !recur->yearMonths().isEmpty()) {
3144                 recurStr = i18ncp("Recurs Every N years on month-name [1st|2nd|...]",
3145                                   "Recurs yearly on %2 %3",
3146                                   "Recurs every %1 years on %2 %3",
3147                                   recur->frequency(),
3148                                   QLocale().monthName(recur->yearMonths().at(0), QLocale::LongFormat),
3149                                   dayList[recur->yearDates().at(0) + 31]);
3150             } else {
3151                 if (!recur->yearMonths().isEmpty()) {
3152                     recurStr = i18nc("Recurs Every year on month-name [1st|2nd|...]",
3153                                      "Recurs yearly on %1 %2",
3154                                      QLocale().monthName(recur->yearMonths().at(0), QLocale::LongFormat),
3155                                      dayList[recur->startDate().day() + 31]);
3156                 } else {
3157                     recurStr = i18nc("Recurs Every year on month-name [1st|2nd|...]",
3158                                      "Recurs yearly on %1 %2",
3159                                      QLocale().monthName(recur->startDate().month(), QLocale::LongFormat),
3160                                      dayList[recur->startDate().day() + 31]);
3161                 }
3162             }
3163         }
3164         break;
3165     case Recurrence::rYearlyDay:
3166         if (!recur->yearDays().isEmpty()) {
3167             if (recur->duration() != -1) {
3168                 recurStr = i18ncp("Recurs every N years on day N until end-date",
3169                                   "Recurs every year on day %2 until %3",
3170                                   "Recurs every %1 years"
3171                                   " on day %2 until %3",
3172                                   recur->frequency(),
3173                                   QString::number(recur->yearDays().at(0)),
3174                                   recurEnd(incidence));
3175                 if (recur->duration() > 0) {
3176                     recurStr += i18nc("number of occurrences", " (%1 occurrences)", QString::number(recur->duration()));
3177                 }
3178             } else {
3179                 recurStr = i18ncp("Recurs every N YEAR[S] on day N",
3180                                   "Recurs every year on day %2",
3181                                   "Recurs every %1 years"
3182                                   " on day %2",
3183                                   recur->frequency(),
3184                                   QString::number(recur->yearDays().at(0)));
3185             }
3186         }
3187         break;
3188     case Recurrence::rYearlyPos:
3189         if (!recur->yearMonths().isEmpty() && !recur->yearPositions().isEmpty()) {
3190             RecurrenceRule::WDayPos rule = recur->yearPositions().at(0);
3191             if (recur->duration() != -1) {
3192                 recurStr = i18ncp(
3193                     "Every N years on the [2nd|3rd|...] weekdayname "
3194                     "of monthname until end-date",
3195                     "Every year on the %2 %3 of %4 until %5",
3196                     "Every %1 years on the %2 %3 of %4"
3197                     " until %5",
3198                     recur->frequency(),
3199                     dayList[rule.pos() + 31],
3200                     QLocale().dayName(rule.day(), QLocale::LongFormat),
3201                     QLocale().monthName(recur->yearMonths().at(0), QLocale::LongFormat),
3202                     recurEnd(incidence));
3203                 if (recur->duration() > 0) {
3204                     recurStr += i18nc("number of occurrences", " (%1 occurrences)", recur->duration());
3205                 }
3206             } else {
3207                 recurStr = xi18ncp(
3208                     "Every N years on the [2nd|3rd|...] weekdayname "
3209                     "of monthname",
3210                     "Every year on the %2 %3 of %4",
3211                     "Every %1 years on the %2 %3 of %4",
3212                     recur->frequency(),
3213                     dayList[rule.pos() + 31],
3214                     QLocale().dayName(rule.day(), QLocale::LongFormat),
3215                     QLocale().monthName(recur->yearMonths().at(0), QLocale::LongFormat));
3216             }
3217         }
3218         break;
3219     }
3220 
3221     if (recurStr.isEmpty()) {
3222         recurStr = i18n("Incidence recurs");
3223     }
3224 
3225     // Now, append the EXDATEs
3226     const auto l = recur->exDateTimes();
3227     QStringList exStr;
3228     for (auto il = l.cbegin(), end = l.cend(); il != end; ++il) {
3229         switch (recur->recurrenceType()) {
3230         case Recurrence::rMinutely:
3231             exStr << i18n("minute %1", (*il).time().minute());
3232             break;
3233         case Recurrence::rHourly:
3234             exStr << QLocale().toString((*il).time(), QLocale::ShortFormat);
3235             break;
3236         case Recurrence::rWeekly:
3237             exStr << QLocale().dayName((*il).date().dayOfWeek(), QLocale::ShortFormat);
3238             break;
3239         case Recurrence::rYearlyMonth:
3240             exStr << QString::number((*il).date().year());
3241             break;
3242         case Recurrence::rDaily:
3243         case Recurrence::rMonthlyPos:
3244         case Recurrence::rMonthlyDay:
3245         case Recurrence::rYearlyDay:
3246         case Recurrence::rYearlyPos:
3247             exStr << QLocale().toString((*il).date(), QLocale::ShortFormat);
3248             break;
3249         }
3250     }
3251 
3252     DateList d = recur->exDates();
3253     DateList::ConstIterator dl;
3254     const DateList::ConstIterator dlEdnd(d.constEnd());
3255     for (dl = d.constBegin(); dl != dlEdnd; ++dl) {
3256         switch (recur->recurrenceType()) {
3257         case Recurrence::rDaily:
3258             exStr << QLocale().toString((*dl), QLocale::ShortFormat);
3259             break;
3260         case Recurrence::rWeekly:
3261             // exStr << calSys->weekDayName( (*dl), KCalendarSystem::ShortDayName );
3262             // kolab/issue4735, should be ( excluding 3 days ), instead of excluding( Fr,Fr,Fr )
3263             if (exStr.isEmpty()) {
3264                 exStr << i18np("1 day", "%1 days", recur->exDates().count());
3265             }
3266             break;
3267         case Recurrence::rMonthlyPos:
3268             exStr << QLocale().toString((*dl), QLocale::ShortFormat);
3269             break;
3270         case Recurrence::rMonthlyDay:
3271             exStr << QLocale().toString((*dl), QLocale::ShortFormat);
3272             break;
3273         case Recurrence::rYearlyMonth:
3274             exStr << QString::number((*dl).year());
3275             break;
3276         case Recurrence::rYearlyDay:
3277             exStr << QLocale().toString((*dl), QLocale::ShortFormat);
3278             break;
3279         case Recurrence::rYearlyPos:
3280             exStr << QLocale().toString((*dl), QLocale::ShortFormat);
3281             break;
3282         }
3283     }
3284 
3285     if (!exStr.isEmpty()) {
3286         recurStr = i18n("%1 (excluding %2)", recurStr, exStr.join(QLatin1Char(',')));
3287     }
3288 
3289     return recurStr;
3290 }
3291 
3292 QString IncidenceFormatter::timeToString(QTime time, bool shortfmt)
3293 {
3294     return QLocale().toString(time, shortfmt ? QLocale::ShortFormat : QLocale::LongFormat);
3295 }
3296 
3297 QString IncidenceFormatter::dateToString(QDate date, bool shortfmt)
3298 {
3299     return QLocale().toString(date, (shortfmt ? QLocale::ShortFormat : QLocale::LongFormat));
3300 }
3301 
3302 QString IncidenceFormatter::dateTimeToString(const QDateTime &date, bool allDay, bool shortfmt)
3303 {
3304     if (allDay) {
3305         return dateToString(date.toLocalTime().date(), shortfmt);
3306     }
3307 
3308     return QLocale().toString(date.toLocalTime(), (shortfmt ? QLocale::ShortFormat : QLocale::LongFormat));
3309 }
3310 
3311 QString IncidenceFormatter::resourceString(const Calendar::Ptr &calendar, const Incidence::Ptr &incidence)
3312 {
3313     Q_UNUSED(calendar)
3314     Q_UNUSED(incidence)
3315     return QString();
3316 }
3317 
3318 static QString secs2Duration(qint64 secs)
3319 {
3320     QString tmp;
3321     qint64 days = secs / 86400;
3322     if (days > 0) {
3323         tmp += i18np("1 day", "%1 days", days);
3324         tmp += QLatin1Char(' ');
3325         secs -= (days * 86400);
3326     }
3327     qint64 hours = secs / 3600;
3328     if (hours > 0) {
3329         tmp += i18np("1 hour", "%1 hours", hours);
3330         tmp += QLatin1Char(' ');
3331         secs -= (hours * 3600);
3332     }
3333     qint64 mins = secs / 60;
3334     if (mins > 0) {
3335         tmp += i18np("1 minute", "%1 minutes", mins);
3336     }
3337     return tmp;
3338 }
3339 
3340 QString IncidenceFormatter::durationString(const Incidence::Ptr &incidence)
3341 {
3342     QString tmp;
3343     if (incidence->type() == Incidence::TypeEvent) {
3344         Event::Ptr event = incidence.staticCast<Event>();
3345         if (event->hasEndDate()) {
3346             if (!event->allDay()) {
3347                 tmp = secs2Duration(event->dtStart().secsTo(event->dtEnd()));
3348             } else {
3349                 tmp = i18np("1 day", "%1 days", event->dtStart().date().daysTo(event->dtEnd().date()) + 1);
3350             }
3351         } else {
3352             tmp = i18n("forever");
3353         }
3354     } else if (incidence->type() == Incidence::TypeTodo) {
3355         Todo::Ptr todo = incidence.staticCast<Todo>();
3356         if (todo->hasDueDate()) {
3357             if (todo->hasStartDate()) {
3358                 if (!todo->allDay()) {
3359                     tmp = secs2Duration(todo->dtStart().secsTo(todo->dtDue()));
3360                 } else {
3361                     tmp = i18np("1 day", "%1 days", todo->dtStart().date().daysTo(todo->dtDue().date()) + 1);
3362                 }
3363             }
3364         }
3365     }
3366     return tmp;
3367 }
3368 
3369 QStringList IncidenceFormatter::reminderStringList(const Incidence::Ptr &incidence, bool shortfmt)
3370 {
3371     // TODO: implement shortfmt=false
3372     Q_UNUSED(shortfmt)
3373 
3374     QStringList reminderStringList;
3375 
3376     if (incidence) {
3377         Alarm::List alarms = incidence->alarms();
3378         Alarm::List::ConstIterator it;
3379         const Alarm::List::ConstIterator end(alarms.constEnd());
3380         reminderStringList.reserve(alarms.count());
3381         for (it = alarms.constBegin(); it != end; ++it) {
3382             Alarm::Ptr alarm = *it;
3383             int offset = 0;
3384             QString remStr;
3385             QString atStr;
3386             QString offsetStr;
3387             if (alarm->hasTime()) {
3388                 offset = 0;
3389                 if (alarm->time().isValid()) {
3390                     atStr = QLocale().toString(alarm->time().toLocalTime(), QLocale::ShortFormat);
3391                 }
3392             } else if (alarm->hasStartOffset()) {
3393                 offset = alarm->startOffset().asSeconds();
3394                 if (offset < 0) {
3395                     offset = -offset;
3396                     offsetStr = i18nc("N days/hours/minutes before the start datetime", "%1 before the start", secs2Duration(offset));
3397                 } else if (offset > 0) {
3398                     offsetStr = i18nc("N days/hours/minutes after the start datetime", "%1 after the start", secs2Duration(offset));
3399                 } else { // offset is 0
3400                     if (incidence->dtStart().isValid()) {
3401                         atStr = QLocale().toString(incidence->dtStart().toLocalTime(), QLocale::ShortFormat);
3402                     }
3403                 }
3404             } else if (alarm->hasEndOffset()) {
3405                 offset = alarm->endOffset().asSeconds();
3406                 if (offset < 0) {
3407                     offset = -offset;
3408                     if (incidence->type() == Incidence::TypeTodo) {
3409                         offsetStr = i18nc("N days/hours/minutes before the due datetime", "%1 before the to-do is due", secs2Duration(offset));
3410                     } else {
3411                         offsetStr = i18nc("N days/hours/minutes before the end datetime", "%1 before the end", secs2Duration(offset));
3412                     }
3413                 } else if (offset > 0) {
3414                     if (incidence->type() == Incidence::TypeTodo) {
3415                         offsetStr = i18nc("N days/hours/minutes after the due datetime", "%1 after the to-do is due", secs2Duration(offset));
3416                     } else {
3417                         offsetStr = i18nc("N days/hours/minutes after the end datetime", "%1 after the end", secs2Duration(offset));
3418                     }
3419                 } else { // offset is 0
3420                     if (incidence->type() == Incidence::TypeTodo) {
3421                         Todo::Ptr t = incidence.staticCast<Todo>();
3422                         if (t->dtDue().isValid()) {
3423                             atStr = QLocale().toString(t->dtDue().toLocalTime(), QLocale::ShortFormat);
3424                         }
3425                     } else {
3426                         Event::Ptr e = incidence.staticCast<Event>();
3427                         if (e->dtEnd().isValid()) {
3428                             atStr = QLocale().toString(e->dtEnd().toLocalTime(), QLocale::ShortFormat);
3429                         }
3430                     }
3431                 }
3432             }
3433             if (offset == 0) {
3434                 if (!atStr.isEmpty()) {
3435                     remStr = i18nc("reminder occurs at datetime", "at %1", atStr);
3436                 }
3437             } else {
3438                 remStr = offsetStr;
3439             }
3440 
3441             if (alarm->repeatCount() > 0) {
3442                 QString countStr = i18np("repeats once", "repeats %1 times", alarm->repeatCount());
3443                 QString intervalStr = i18nc("interval is N days/hours/minutes", "interval is %1", secs2Duration(alarm->snoozeTime().asSeconds()));
3444                 QString repeatStr = i18nc("(repeat string, interval string)", "(%1, %2)", countStr, intervalStr);
3445                 remStr = remStr + QLatin1Char(' ') + repeatStr;
3446             }
3447             reminderStringList << remStr;
3448         }
3449     }
3450 
3451     return reminderStringList;
3452 }