diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-24 16:27:44 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-24 16:27:44 +0200 |
| commit | 2c3741619a3e0f11911af7100e3f763ded9077cf (patch) | |
| tree | 15b2540d2423bc8b6b38539febae96ed31e23349 | |
| parent | 9c8afe411721fcb7faa4f2d669c2cd7b289f1f00 (diff) | |
| download | qtmaildir-2c3741619a3e0f11911af7100e3f763ded9077cf.tar.gz qtmaildir-2c3741619a3e0f11911af7100e3f763ded9077cf.zip | |
feat: edit a calendar event in place and write new ones
| -rw-r--r-- | src/calendarstore.cpp | 263 | ||||
| -rw-r--r-- | tests/test_calendarstore.cpp | 152 |
2 files changed, 412 insertions, 3 deletions
diff --git a/src/calendarstore.cpp b/src/calendarstore.cpp index 7970fdf..92a7b74 100644 --- a/src/calendarstore.cpp +++ b/src/calendarstore.cpp @@ -24,6 +24,7 @@ #include <QFile> #include <QFileInfo> #include <QTimeZone> +#include <QUuid> #include <algorithm> @@ -192,6 +193,205 @@ QColor hashedColour(const QString &dir) } // namespace +namespace { + +/// How a component's times are written, read off its DTSTART and reused for +/// every time written back, so an edit keeps the event's zone. +struct TimeForm +{ + enum Kind { Date, Utc, Zoned, Floating } kind = Floating; + QByteArray tzid; + icaltimezone *zone = nullptr; ///< Embedded or built-in; not owned. +}; + +TimeForm formOf(icalcomponent *root, icalcomponent *c) +{ + TimeForm form; + icalproperty *prop = icalcomponent_get_first_property(c, ICAL_DTSTART_PROPERTY); + const icaltimetype t = icalcomponent_get_dtstart(c); + if (t.is_date) { + form.kind = TimeForm::Date; + } else if (icaltime_is_utc(t)) { + form.kind = TimeForm::Utc; + } else if (!tzidOf(prop).isEmpty()) { + form.kind = TimeForm::Zoned; + form.tzid = tzidOf(prop); + form.zone = icalcomponent_get_timezone(root, form.tzid.constData()); + if (!form.zone) + form.zone = icaltimezone_get_builtin_timezone(form.tzid.constData()); + } + return form; +} + +TimeForm zonedForm(const QByteArray &tzid) +{ + TimeForm form; + form.kind = TimeForm::Zoned; + form.tzid = tzid; + form.zone = icaltimezone_get_builtin_timezone(tzid.constData()); + return form; +} + +/// A QDateTime as the wall-clock icaltimetype `form` writes, zone left unset: +/// the TZID travels as a parameter, so the output never depends on whether +/// the file carried a VTIMEZONE. +icaltimetype wallTime(const QDateTime &dt, const TimeForm &form) +{ + if (form.kind == TimeForm::Date) { + icaltimetype t = icaltime_null_date(); + const QDate d = dt.date(); + t.year = d.year(); t.month = d.month(); t.day = d.day(); + return t; + } + QDateTime local; + if (form.kind == TimeForm::Utc) { + local = dt.toUTC(); + } else if (form.kind == TimeForm::Zoned && form.zone) { + icaltimetype t = icaltime_from_timet_with_zone(dt.toSecsSinceEpoch(), 0, form.zone); + t.zone = nullptr; + return t; + } else if (form.kind == TimeForm::Zoned && QTimeZone(form.tzid).isValid()) { + local = dt.toTimeZone(QTimeZone(form.tzid)); + } else { + local = dt.toLocalTime(); + } + icaltimetype t = icaltime_null_time(); + t.is_date = 0; + t.year = local.date().year(); t.month = local.date().month(); t.day = local.date().day(); + t.hour = local.time().hour(); t.minute = local.time().minute(); t.second = local.time().second(); + if (form.kind == TimeForm::Utc) + t = icaltime_convert_to_zone(t, icaltimezone_get_utc_timezone()); + return t; +} + +/// Replaces every `kind` property on `c` with one holding `dt` in `form`. +/// DTSTART, DTEND and RECURRENCE-ID only: EXDATE is ADDITIVE, and replacing +/// it would drop every exception but the newest (deleteOccurrence adds its +/// own). +void setTime(icalcomponent *c, icalproperty_kind kind, const QDateTime &dt, const TimeForm &form) +{ + while (icalproperty *old = icalcomponent_get_first_property(c, kind)) { + icalcomponent_remove_property(c, old); + icalproperty_free(old); + } + const icaltimetype t = wallTime(dt, form); + icalproperty *prop = kind == ICAL_DTSTART_PROPERTY ? icalproperty_new_dtstart(t) + : kind == ICAL_DTEND_PROPERTY ? icalproperty_new_dtend(t) + : icalproperty_new_recurrenceid(t); + if (form.kind == TimeForm::Zoned) + icalproperty_add_parameter(prop, icalparameter_new_tzid(form.tzid.constData())); + icalcomponent_add_property(c, prop); +} + +void removeAll(icalcomponent *c, icalproperty_kind kind) +{ + while (icalproperty *p = icalcomponent_get_first_property(c, kind)) { + icalcomponent_remove_property(c, p); + icalproperty_free(p); + } +} + +void setText(icalcomponent *c, icalproperty_kind kind, const QString &value) +{ + removeAll(c, kind); + if (value.isEmpty()) + return; + const QByteArray utf8 = value.toUtf8(); + icalproperty *p = kind == ICAL_SUMMARY_PROPERTY ? icalproperty_new_summary(utf8.constData()) + : kind == ICAL_LOCATION_PROPERTY ? icalproperty_new_location(utf8.constData()) + : icalproperty_new_description(utf8.constData()); + icalcomponent_add_property(c, p); +} + +/// Embeds the VTIMEZONE for `tzid` from libical's built-in database unless +/// the calendar already carries one, so what this application writes is +/// RFC-correct even where what it read was not. +void ensureVtimezone(icalcomponent *root, const QByteArray &tzid) +{ + if (tzid.isEmpty() || icalcomponent_get_timezone(root, tzid.constData())) + return; + icaltimezone *zone = icaltimezone_get_builtin_timezone(tzid.constData()); + if (!zone) + return; + icalcomponent *vtz = icalcomponent_new_clone(icaltimezone_get_component(zone)); + // libical's built-in components carry a "/freeassociation.sourceforge.net/..." + // style TZID; rewrite it to the plain IANA name the events reference. + removeAll(vtz, ICAL_TZID_PROPERTY); + icalcomponent_add_property(vtz, icalproperty_new_tzid(tzid.constData())); + icalcomponent_add_component(root, vtz); +} + +void stamp(icalcomponent *c, bool bumpSequence) +{ + const icaltimetype now = icaltime_current_time_with_zone(icaltimezone_get_utc_timezone()); + removeAll(c, ICAL_DTSTAMP_PROPERTY); + icalcomponent_add_property(c, icalproperty_new_dtstamp(now)); + removeAll(c, ICAL_LASTMODIFIED_PROPERTY); + icalcomponent_add_property(c, icalproperty_new_lastmodified(now)); + if (bumpSequence) + icalcomponent_set_sequence(c, icalcomponent_get_sequence(c) + 1); +} + +/// The form a written time takes after an edit: all-day is a date; a timed +/// event keeps its zone, or takes the system zone when it was all-day. +TimeForm editedForm(icalcomponent *root, icalcomponent *c, bool allDay) +{ + if (allDay) { + TimeForm date; + date.kind = TimeForm::Date; + return date; + } + TimeForm form = formOf(root, c); + if (form.kind == TimeForm::Date) { + form = zonedForm(QTimeZone::systemTimeZoneId()); + ensureVtimezone(root, form.tzid); + } + return form; +} + +/// Writes the fields the form owns onto `c`. RRULE only for a master. +void writeFields(icalcomponent *root, icalcomponent *c, const EventEdit &edit, bool master) +{ + setText(c, ICAL_SUMMARY_PROPERTY, edit.summary); + setText(c, ICAL_LOCATION_PROPERTY, edit.location); + setText(c, ICAL_DESCRIPTION_PROPERTY, edit.description); + const TimeForm form = editedForm(root, c, edit.allDay); + removeAll(c, ICAL_DURATION_PROPERTY); + setTime(c, ICAL_DTSTART_PROPERTY, edit.start, form); + setTime(c, ICAL_DTEND_PROPERTY, edit.end, form); + + if (!master || edit.repeat.custom) + return; // a custom RRULE is never rewritten (spec, repeat control) + removeAll(c, ICAL_RRULE_PROPERTY); + if (edit.repeat.freq == RepeatRule::Freq::None) + return; + // UNTIL follows DTSTART's type: a DATE for all-day, else UTC (RFC 5545). + QString until; + if (edit.repeat.end == RepeatRule::End::Until) { + until = edit.allDay + ? edit.repeat.until.toString(QStringLiteral("yyyyMMdd")) + : QDateTime(edit.repeat.until, QTime(23, 59, 59), + form.kind == TimeForm::Zoned && QTimeZone(form.tzid).isValid() + ? QTimeZone(form.tzid) : QTimeZone::systemTimeZone()) + .toUTC().toString(QStringLiteral("yyyyMMdd'T'HHmmss'Z'")); + } + const QByteArray rule = "RRULE:" + edit.repeat.toRRule(until).toUtf8(); + icalcomponent_add_property(c, icalproperty_new_from_string(rule.constData())); +} + +QByteArray serialise(icalcomponent *root) +{ + IcalString text(icalcomponent_as_ical_string_r(root)); + return text ? QByteArray(text.get()) : QByteArray(); +} + +icalcomponent *overrideFor(icalcomponent *, icalcomponent *master, const QDateTime &) +{ + return master; // replaced in Task 7 +} + +} // namespace + namespace CalendarStore { CalEvent parseEvent(const QByteArray &text, const QString &filePath, @@ -354,9 +554,66 @@ bool isEditable(const CalEvent &event, const CalCollection &collection, return true; return ownAddresses.contains(event.organizer.address, Qt::CaseInsensitive); } -QByteArray applyEdit(const QByteArray &, const EventEdit &, Scope, const QDateTime &) { return {}; } -QByteArray newEvent(const EventEdit &, const QByteArray &) { return {}; } +QByteArray applyEdit(const QByteArray &text, const EventEdit &edit, + Scope scope, const QDateTime &recurrenceId) +{ + IcalComponent root = parseRoot(text); + icalcomponent *master = root ? masterOf(root.get()) : nullptr; + if (!master) + return {}; + icalcomponent *target = master; + if (scope == Scope::ThisOccurrence) + target = overrideFor(root.get(), master, recurrenceId); // Task 7 + writeFields(root.get(), target, edit, target == master); + stamp(target, true); + return serialise(root.get()); +} + +QByteArray newEvent(const EventEdit &edit, const QByteArray &zoneId) +{ + IcalComponent root(icalcomponent_new(ICAL_VCALENDAR_COMPONENT)); + icalcomponent_add_property(root.get(), icalproperty_new_version("2.0")); + icalcomponent_add_property(root.get(), icalproperty_new_prodid("-//qtmaildir//EN")); + icalcomponent *event = icalcomponent_new(ICAL_VEVENT_COMPONENT); + icalcomponent_add_component(root.get(), event); + icalcomponent_set_uid(event, QUuid::createUuid() + .toString(QUuid::WithoutBraces).toUtf8().constData()); + // A DTSTART must exist before editedForm() reads it; a date one makes the + // form fall through to the system zone for a timed event, which is + // exactly the rule for new events, so pass the requested zone instead. + if (!edit.allDay) { + ensureVtimezone(root.get(), zoneId); + setTime(event, ICAL_DTSTART_PROPERTY, edit.start, zonedForm(zoneId)); + } else { + TimeForm date; + date.kind = TimeForm::Date; + setTime(event, ICAL_DTSTART_PROPERTY, edit.start, date); + } + writeFields(root.get(), event, edit, true); + icalcomponent_set_sequence(event, 0); + stamp(event, false); + return serialise(root.get()); +} + QByteArray deleteOccurrence(const QByteArray &, const QDateTime &) { return {}; } -bool sameMeaning(const QByteArray &, const QByteArray &) { return false; } + +bool sameMeaning(const QByteArray &a, const QByteArray &b) +{ + bool ignored = false; + const CalEvent x = parseEvent(a, {}, {}, &ignored); + const CalEvent y = parseEvent(b, {}, {}, &ignored); + if (x.uid.isEmpty() || y.uid.isEmpty()) + return x.uid.isEmpty() && y.uid.isEmpty(); + if (x.overrides.size() != y.overrides.size()) + return false; + for (int i = 0; i < x.overrides.size(); ++i) { + const CalOverride &p = x.overrides[i], &q = y.overrides[i]; + if (p.recurrenceId != q.recurrenceId || p.start != q.start + || p.end != q.end || p.summary != q.summary || p.cancelled != q.cancelled) + return false; + } + return x.summary == y.summary && x.start == y.start && x.end == y.end + && x.allDay == y.allDay && x.repeat == y.repeat && x.exdates == y.exdates; +} } // namespace CalendarStore diff --git a/tests/test_calendarstore.cpp b/tests/test_calendarstore.cpp index 1df8447..977f96b 100644 --- a/tests/test_calendarstore.cpp +++ b/tests/test_calendarstore.cpp @@ -78,6 +78,34 @@ QDateTime utc(int y, int m, int d, int h = 0, int min = 0) return QDateTime(QDate(y, m, d), QTime(h, min), QTimeZone::utc()); } +EventEdit editOf(const CalEvent &e) +{ + EventEdit edit; + edit.summary = e.summary; + edit.location = e.location; + edit.description = e.description; + edit.start = e.start; + edit.end = e.end; + edit.allDay = e.allDay; + edit.repeat = e.repeat; + edit.collectionDir = e.collectionDir; + return edit; +} + +CalEvent reparse(const QByteArray &text) +{ + bool unknown = false; + return CalendarStore::parseEvent(text, QStringLiteral("/x/e.ics"), QStringLiteral("x"), &unknown); +} + +const QString kRichEvent = QStringLiteral( + "UID:rich@example.org\r\nDTSTAMP:20260901T000000Z\r\nSEQUENCE:2\r\n" + "DTSTART;TZID=Europe/Rome:20260922T100000\r\nDTEND;TZID=Europe/Rome:20260922T110000\r\n" + "SUMMARY:Review\r\nORGANIZER;CN=Me:mailto:me@example.org\r\n" + "ATTENDEE;CN=Other;PARTSTAT=ACCEPTED:mailto:other@example.org\r\n" + "X-EXAMPLE-FLAG:keep me\r\n" + "BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n"); + } // namespace class TestCalendarStore : public QObject @@ -104,6 +132,13 @@ private slots: void expandsADenseOldSeriesToTheWindow(); void anAllDayOverrideKeepsItsOwnAllDayFlag(); void editableOnlyWhenTheUserOrganisesIt(); + + void anEditKeepsWhatTheFormDoesNotOwn(); + void anEditKeepsTheEventsZone(); + void anEditRewritesTheRepeatRuleButNeverACustomOne(); + void anEditCanTurnAnEventAllDayAndBack(); + void aNewEventIsCompleteAndEmbedsItsZone(); + void sameMeaningIgnoresFormatting(); }; void TestCalendarStore::parsesAUtcEvent() @@ -397,5 +432,122 @@ void TestCalendarStore::editableOnlyWhenTheUserOrganisesIt() QCOMPARE(mine.attendees[0].address, QStringLiteral("other@example.org")); } +void TestCalendarStore::anEditKeepsWhatTheFormDoesNotOwn() +{ + const CalEvent before = parse(vevent(kRichEvent)); + EventEdit edit = editOf(before); + edit.summary = QStringLiteral("Review, moved"); + const QByteArray after = CalendarStore::applyEdit( + before.rawText, edit, CalendarStore::Scope::All, {}); + QVERIFY(!after.isEmpty()); + + const CalEvent e = reparse(after); + QCOMPARE(e.summary, QStringLiteral("Review, moved")); + QCOMPARE(e.uid, before.uid); + QCOMPARE(e.sequence, 3); // bumped + // Value for value, not byte for byte: libical re-serialises (plan ruling 2). + QVERIFY(e.hasAlarm); + QVERIFY(after.contains("TRIGGER:-PT15M")); + QVERIFY(after.contains("X-EXAMPLE-FLAG:keep me")); + QCOMPARE(e.attendees.size(), 1); + QCOMPARE(e.attendees[0].partstat, QStringLiteral("ACCEPTED")); + QVERIFY(after.contains("LAST-MODIFIED:")); +} + +void TestCalendarStore::anEditKeepsTheEventsZone() +{ + // The source has NO VTIMEZONE, as 120 of the user's files do: the TZID + // must still be written back, not converted to UTC or floating. + const CalEvent before = parse(vevent(kRichEvent)); + EventEdit edit = editOf(before); + edit.start = before.start.addSecs(3600); + edit.end = before.end.addSecs(3600); + const QByteArray after = CalendarStore::applyEdit(before.rawText, edit, CalendarStore::Scope::All, {}); + QVERIFY2(after.contains("DTSTART;TZID=Europe/Rome:20260922T110000"), after.constData()); + QCOMPARE(reparse(after).start, edit.start); +} + +void TestCalendarStore::anEditRewritesTheRepeatRuleButNeverACustomOne() +{ + const CalEvent weekly = parse(vevent(QStringLiteral( + "UID:r@example.org\r\nDTSTART:20260921T080000Z\r\nRRULE:FREQ=WEEKLY;WKST=MO\r\n"))); + EventEdit edit = editOf(weekly); + edit.repeat = RepeatRule::fromRRule(QStringLiteral("FREQ=MONTHLY;BYDAY=-1FR"), weekly.start.date()); + QByteArray after = CalendarStore::applyEdit(weekly.rawText, edit, CalendarStore::Scope::All, {}); + QCOMPARE(reparse(after).repeat.toRRule(), QStringLiteral("FREQ=MONTHLY;BYDAY=-1FR")); + QCOMPARE(after.count("RRULE"), 1); + + const CalEvent custom = parse(vevent(QStringLiteral( + "UID:c@example.org\r\nDTSTART:20260921T080000Z\r\n" + "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1\r\n"))); + edit = editOf(custom); + edit.summary = QStringLiteral("Renamed"); + after = CalendarStore::applyEdit(custom.rawText, edit, CalendarStore::Scope::All, {}); + QVERIFY(after.contains("BYSETPOS=-1")); + + edit.repeat = RepeatRule(); // Does not repeat + after = CalendarStore::applyEdit(custom.rawText, edit, CalendarStore::Scope::All, {}); + QVERIFY(!after.contains("RRULE")); +} + +void TestCalendarStore::anEditCanTurnAnEventAllDayAndBack() +{ + const CalEvent timed = parse(vevent(kRichEvent)); + EventEdit edit = editOf(timed); + edit.allDay = true; + edit.start = QDateTime(QDate(2026, 9, 22), QTime(0, 0)); + edit.end = QDateTime(QDate(2026, 9, 23), QTime(0, 0)); + const QByteArray allDay = CalendarStore::applyEdit(timed.rawText, edit, CalendarStore::Scope::All, {}); + QVERIFY(allDay.contains("DTSTART;VALUE=DATE:20260922")); + QVERIFY(allDay.contains("DTEND;VALUE=DATE:20260923")); + + const CalEvent back = reparse(allDay); + edit = editOf(back); + edit.allDay = false; + edit.start = QDateTime(QDate(2026, 9, 22), QTime(9, 0), QTimeZone("Europe/Rome")); + edit.end = edit.start.addSecs(3600); + const QByteArray timedAgain = CalendarStore::applyEdit(allDay, edit, CalendarStore::Scope::All, {}); + const CalEvent e = reparse(timedAgain); + QVERIFY(!e.allDay); + QCOMPARE(e.start, edit.start); +} + +void TestCalendarStore::aNewEventIsCompleteAndEmbedsItsZone() +{ + EventEdit edit; + edit.summary = QStringLiteral("Dentist"); + edit.start = QDateTime(QDate(2026, 9, 24), QTime(15, 0), QTimeZone("Europe/Rome")); + edit.end = edit.start.addSecs(1800); + const QByteArray text = CalendarStore::newEvent(edit, "Europe/Rome"); + QVERIFY(text.contains("BEGIN:VCALENDAR")); + QVERIFY(text.contains("PRODID:-//qtmaildir//EN")); + QVERIFY(text.contains("BEGIN:VTIMEZONE")); + QVERIFY(text.contains("TZID:Europe/Rome")); + QVERIFY(text.contains("SEQUENCE:0")); + QVERIFY(text.contains("DTSTAMP:")); + const CalEvent e = reparse(text); + QVERIFY(!e.uid.isEmpty()); + QCOMPARE(e.summary, QStringLiteral("Dentist")); + QCOMPARE(e.start, edit.start); + QCOMPARE(e.end, edit.end); + + // Two new events never share a UID. + QVERIFY(reparse(CalendarStore::newEvent(edit, "Europe/Rome")).uid != e.uid); +} + +void TestCalendarStore::sameMeaningIgnoresFormatting() +{ + const QByteArray a = ics(vevent(QStringLiteral( + "UID:s@example.org\r\nDTSTART:20260922T080000Z\r\nSUMMARY:Standup\r\n"))); + // Folded, properties reordered, DTSTAMP added: what a server may send back. + const QByteArray b = ics(vevent(QStringLiteral( + "SUMMARY:Stand\r\n up\r\nDTSTAMP:20260930T000000Z\r\n" + "DTSTART:20260922T080000Z\r\nUID:s@example.org\r\n"))); + const QByteArray moved = ics(vevent(QStringLiteral( + "UID:s@example.org\r\nDTSTART:20260922T090000Z\r\nSUMMARY:Standup\r\n"))); + QVERIFY(CalendarStore::sameMeaning(a, b)); + QVERIFY(!CalendarStore::sameMeaning(a, moved)); +} + QTEST_MAIN(TestCalendarStore) #include "test_calendarstore.moc" |
