summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-24 16:33:11 +0200
committerDanilo M. <danix@danix.xyz>2026-09-24 16:33:11 +0200
commit8af4edebd2caa9516ec3901857e4abf84c2bfdf0 (patch)
tree6aa7de838a554210bc85d8df663e46713aff5780
parent27f9e90b26622beb47d4f00e7f21a56b365ccfaf (diff)
downloadqtmaildir-8af4edebd2caa9516ec3901857e4abf84c2bfdf0.tar.gz
qtmaildir-8af4edebd2caa9516ec3901857e4abf84c2bfdf0.zip
feat: edit or delete one occurrence of a repeating event
-rw-r--r--src/calendarstore.cpp51
-rw-r--r--tests/test_calendarstore.cpp74
2 files changed, 122 insertions, 3 deletions
diff --git a/src/calendarstore.cpp b/src/calendarstore.cpp
index c388cd6..ef67eba 100644
--- a/src/calendarstore.cpp
+++ b/src/calendarstore.cpp
@@ -385,9 +385,26 @@ QByteArray serialise(icalcomponent *root)
return text ? QByteArray(text.get()) : QByteArray();
}
-icalcomponent *overrideFor(icalcomponent *, icalcomponent *master, const QDateTime &)
+/// The override component for `recurrenceId`, created when absent: a copy of
+/// the master without RRULE, RDATE or EXDATE, carrying a RECURRENCE-ID in the
+/// master's own time form.
+icalcomponent *overrideFor(icalcomponent *root, icalcomponent *master,
+ const QDateTime &recurrenceId)
{
- return master; // replaced in Task 7
+ for (icalcomponent *c = icalcomponent_get_first_component(root, ICAL_VEVENT_COMPONENT);
+ c; c = icalcomponent_get_next_component(root, ICAL_VEVENT_COMPONENT)) {
+ icalproperty *rid = icalcomponent_get_first_property(c, ICAL_RECURRENCEID_PROPERTY);
+ if (c != master && rid
+ && toDateTime(icalcomponent_get_recurrenceid(c), tzidOf(rid), nullptr) == recurrenceId)
+ return c;
+ }
+ icalcomponent *copy = icalcomponent_new_clone(master);
+ removeAll(copy, ICAL_RRULE_PROPERTY);
+ removeAll(copy, ICAL_RDATE_PROPERTY);
+ removeAll(copy, ICAL_EXDATE_PROPERTY);
+ setTime(copy, ICAL_RECURRENCEID_PROPERTY, recurrenceId, formOf(root, master));
+ icalcomponent_add_component(root, copy);
+ return copy;
}
} // namespace
@@ -595,7 +612,35 @@ QByteArray newEvent(const EventEdit &edit, const QByteArray &zoneId)
return serialise(root.get());
}
-QByteArray deleteOccurrence(const QByteArray &, const QDateTime &) { return {}; }
+QByteArray deleteOccurrence(const QByteArray &text, const QDateTime &recurrenceId)
+{
+ IcalComponent root = parseRoot(text);
+ icalcomponent *master = root ? masterOf(root.get()) : nullptr;
+ if (!master)
+ return {};
+ // Collect first: removing a component mid-walk invalidates the iterator.
+ QList<icalcomponent *> doomed;
+ for (icalcomponent *c = icalcomponent_get_first_component(root.get(), ICAL_VEVENT_COMPONENT);
+ c; c = icalcomponent_get_next_component(root.get(), ICAL_VEVENT_COMPONENT)) {
+ icalproperty *rid = icalcomponent_get_first_property(c, ICAL_RECURRENCEID_PROPERTY);
+ if (c != master && rid
+ && toDateTime(icalcomponent_get_recurrenceid(c), tzidOf(rid), nullptr) == recurrenceId)
+ doomed.append(c);
+ }
+ for (icalcomponent *c : doomed) {
+ icalcomponent_remove_component(root.get(), c);
+ icalcomponent_free(c);
+ }
+ // setTime replaces every property of its kind, which for EXDATE would
+ // drop the existing ones: add this one alongside instead.
+ const TimeForm form = formOf(root.get(), master);
+ icalproperty *ex = icalproperty_new_exdate(wallTime(recurrenceId, form));
+ if (form.kind == TimeForm::Zoned)
+ icalproperty_add_parameter(ex, icalparameter_new_tzid(form.tzid.constData()));
+ icalcomponent_add_property(master, ex);
+ stamp(master, true);
+ return serialise(root.get());
+}
bool sameMeaning(const QByteArray &a, const QByteArray &b)
{
diff --git a/tests/test_calendarstore.cpp b/tests/test_calendarstore.cpp
index ba2d5c7..921a194 100644
--- a/tests/test_calendarstore.cpp
+++ b/tests/test_calendarstore.cpp
@@ -23,6 +23,8 @@
#include <QTemporaryDir>
#include <QTimeZone>
+#include <algorithm>
+
#include "calendarstore.h"
namespace {
@@ -98,6 +100,16 @@ CalEvent reparse(const QByteArray &text)
return CalendarStore::parseEvent(text, QStringLiteral("/x/e.ics"), QStringLiteral("x"), &unknown);
}
+const QString kDailySeries = QStringLiteral(
+ "UID:d@example.org\r\nDTSTAMP:20260901T000000Z\r\n"
+ "DTSTART;TZID=Europe/Rome:20260921T100000\r\nDTEND;TZID=Europe/Rome:20260921T110000\r\n"
+ "RRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Daily\r\n");
+
+QDateTime rome(int d, int h)
+{
+ return QDateTime(QDate(2026, 9, d), QTime(h, 0), QTimeZone("Europe/Rome"));
+}
+
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"
@@ -141,6 +153,10 @@ private slots:
void sameMeaningIgnoresFormatting();
void sameMeaningMatchesOverridesRegardlessOfOrder();
void sameMeaningRejectsDifferentUids();
+
+ void editingOneOccurrenceWritesAnOverride();
+ void editingTheSameOccurrenceAgainReplacesItsOverride();
+ void deletingOneOccurrenceAddsAnExdateAndDropsItsOverride();
};
void TestCalendarStore::parsesAUtcEvent()
@@ -587,5 +603,63 @@ void TestCalendarStore::sameMeaningRejectsDifferentUids()
QVERIFY(CalendarStore::sameMeaning(a, a));
}
+void TestCalendarStore::editingOneOccurrenceWritesAnOverride()
+{
+ const CalEvent series = parse(vevent(kDailySeries));
+ EventEdit edit = editOf(series);
+ edit.summary = QStringLiteral("Just this one");
+ edit.start = rome(22, 15);
+ edit.end = rome(22, 16);
+ const QByteArray after = CalendarStore::applyEdit(
+ series.rawText, edit, CalendarStore::Scope::ThisOccurrence, rome(22, 10));
+
+ const CalEvent e = reparse(after);
+ QCOMPARE(e.summary, QStringLiteral("Daily")); // master untouched
+ QCOMPARE(e.repeat.count, 5);
+ QCOMPARE(e.overrides.size(), 1);
+ QCOMPARE(e.overrides[0].recurrenceId, rome(22, 10));
+ QCOMPARE(e.overrides[0].summary, QStringLiteral("Just this one"));
+ QCOMPARE(after.count("RRULE"), 1); // the override carries none
+
+ const QList<Occurrence> occ = CalendarStore::occurrences(
+ { e }, rome(21, 0), rome(26, 0));
+ QCOMPARE(occ.size(), 5); // still five, one moved
+ QVERIFY(std::any_of(occ.cbegin(), occ.cend(),
+ [](const Occurrence &o) { return o.isOverride && o.start == rome(22, 15); }));
+}
+
+void TestCalendarStore::editingTheSameOccurrenceAgainReplacesItsOverride()
+{
+ const CalEvent series = parse(vevent(kDailySeries));
+ EventEdit edit = editOf(series);
+ edit.start = rome(22, 15);
+ edit.end = rome(22, 16);
+ QByteArray text = CalendarStore::applyEdit(
+ series.rawText, edit, CalendarStore::Scope::ThisOccurrence, rome(22, 10));
+ edit.start = rome(22, 17);
+ edit.end = rome(22, 18);
+ text = CalendarStore::applyEdit(text, edit, CalendarStore::Scope::ThisOccurrence, rome(22, 10));
+ const CalEvent e = reparse(text);
+ QCOMPARE(e.overrides.size(), 1);
+ QCOMPARE(e.overrides[0].start, rome(22, 17));
+}
+
+void TestCalendarStore::deletingOneOccurrenceAddsAnExdateAndDropsItsOverride()
+{
+ const CalEvent series = parse(vevent(kDailySeries));
+ EventEdit edit = editOf(series);
+ edit.start = rome(23, 15);
+ edit.end = rome(23, 16);
+ const QByteArray withOverride = CalendarStore::applyEdit(
+ series.rawText, edit, CalendarStore::Scope::ThisOccurrence, rome(23, 10));
+
+ const QByteArray after = CalendarStore::deleteOccurrence(withOverride, rome(23, 10));
+ const CalEvent e = reparse(after);
+ QCOMPARE(e.overrides.size(), 0);
+ QCOMPARE(e.exdates, QList<QDateTime>{ rome(23, 10) });
+ QVERIFY2(after.contains("EXDATE;TZID=Europe/Rome:20260923T100000"), after.constData());
+ QCOMPARE(CalendarStore::occurrences({ e }, rome(21, 0), rome(26, 0)).size(), 4);
+}
+
QTEST_MAIN(TestCalendarStore)
#include "test_calendarstore.moc"