aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/calendarstore.cpp263
-rw-r--r--tests/test_calendarstore.cpp152
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"