summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt4
-rw-r--r--src/calendarstore.cpp171
-rw-r--r--src/calendarstore.h73
-rw-r--r--src/caltypes.h126
-rw-r--r--src/icalraii.h48
-rw-r--r--src/repeatrule.cpp24
-rw-r--r--src/repeatrule.h73
7 files changed, 518 insertions, 1 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 26314dc..8af8374 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -44,6 +44,8 @@ add_library(qtmaildir_lib STATIC
searchterm.cpp
contactstore.cpp
signatures.cpp
+ repeatrule.cpp
+ calendarstore.cpp
)
target_include_directories(qtmaildir_lib
@@ -58,7 +60,7 @@ endif()
target_link_libraries(qtmaildir_lib
PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME
- ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM
+ PkgConfig::LIBICAL ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM
${CMARK_GFM_EXTENSIONS_LIBRARY})
# resources.qrc belongs to the executable, not to the static library. A qrc
diff --git a/src/calendarstore.cpp b/src/calendarstore.cpp
new file mode 100644
index 0000000..6cc2bcd
--- /dev/null
+++ b/src/calendarstore.cpp
@@ -0,0 +1,171 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "calendarstore.h"
+#include "icalraii.h"
+
+#include <QTimeZone>
+
+namespace {
+
+QString str(const char *s) { return s ? QString::fromUtf8(s) : QString(); }
+
+QByteArray tzidOf(icalproperty *prop)
+{
+ if (!prop)
+ return {};
+ icalparameter *param = icalproperty_get_first_parameter(prop, ICAL_TZID_PARAMETER);
+ return param ? QByteArray(icalparameter_get_tzid(param)) : QByteArray();
+}
+
+/// One icaltimetype to a QDateTime, in this order (spec, "Time and
+/// recurrence"): a date is local midnight; UTC is UTC; a zone libical resolved
+/// (an embedded VTIMEZONE) converts through libical; a TZID libical could not
+/// resolve is tried as an IANA name through QTimeZone; anything else is local
+/// wall-clock time, and an unresolvable TZID sets *unknownZone.
+QDateTime toDateTime(icaltimetype t, const QByteArray &tzid, bool *unknownZone)
+{
+ if (icaltime_is_null_time(t))
+ return {};
+ const QDate date(t.year, t.month, t.day);
+ if (t.is_date)
+ return QDateTime(date, QTime(0, 0));
+ const QTime time(t.hour, t.minute, t.second);
+ if (icaltime_is_utc(t))
+ return QDateTime(date, time, QTimeZone::utc());
+ if (t.zone) {
+ const icaltimetype utc =
+ icaltime_convert_to_zone(t, icaltimezone_get_utc_timezone());
+ return QDateTime(QDate(utc.year, utc.month, utc.day),
+ QTime(utc.hour, utc.minute, utc.second), QTimeZone::utc());
+ }
+ if (!tzid.isEmpty()) {
+ const QTimeZone zone(tzid);
+ if (zone.isValid())
+ return QDateTime(date, time, zone);
+ if (unknownZone)
+ *unknownZone = true;
+ }
+ return QDateTime(date, time);
+}
+
+CalPerson personOf(icalproperty *prop)
+{
+ CalPerson person;
+ QString value = str(icalproperty_get_value_as_string(prop));
+ if (value.startsWith(QLatin1String("mailto:"), Qt::CaseInsensitive))
+ value = value.mid(7);
+ person.address = value;
+ person.name = str(icalproperty_get_parameter_as_string(prop, "CN"));
+ person.partstat = str(icalproperty_get_parameter_as_string(prop, "PARTSTAT"));
+ return person;
+}
+
+/// The VEVENT without a RECURRENCE-ID, or the first VEVENT if every one has.
+icalcomponent *masterOf(icalcomponent *root)
+{
+ icalcomponent *first = nullptr;
+ for (icalcomponent *c = icalcomponent_get_first_component(root, ICAL_VEVENT_COMPONENT);
+ c; c = icalcomponent_get_next_component(root, ICAL_VEVENT_COMPONENT)) {
+ if (!first)
+ first = c;
+ if (!icalcomponent_get_first_property(c, ICAL_RECURRENCEID_PROPERTY))
+ return c;
+ }
+ return first;
+}
+
+IcalComponent parseRoot(const QByteArray &text)
+{
+ IcalComponent root(icalparser_parse_string(text.constData()));
+ if (root && icalcomponent_isa(root.get()) == ICAL_VEVENT_COMPONENT) {
+ // A bare VEVENT with no VCALENDAR around it: wrap it, so every
+ // caller walks the same shape.
+ IcalComponent cal(icalcomponent_new(ICAL_VCALENDAR_COMPONENT));
+ icalcomponent_add_component(cal.get(), root.release());
+ return cal;
+ }
+ return root;
+}
+
+/// The end of a component: DTEND, else DTSTART + DURATION (libical computes
+/// both through get_dtend), else one day for a date and the start otherwise.
+QDateTime endOf(icalcomponent *c, const QDateTime &start, bool allDay, bool *unknownZone)
+{
+ icalproperty *endProp = icalcomponent_get_first_property(c, ICAL_DTEND_PROPERTY);
+ const icaltimetype end = icalcomponent_get_dtend(c);
+ if (!icaltime_is_null_time(end))
+ return toDateTime(end, tzidOf(endProp ? endProp
+ : icalcomponent_get_first_property(c, ICAL_DTSTART_PROPERTY)),
+ unknownZone);
+ return allDay ? start.addDays(1) : start;
+}
+
+} // namespace
+
+namespace CalendarStore {
+
+CalEvent parseEvent(const QByteArray &text, const QString &filePath,
+ const QString &collectionDir, bool *unknownZone)
+{
+ CalEvent event;
+ event.filePath = filePath;
+ event.collectionDir = collectionDir;
+ event.rawText = text;
+ if (unknownZone)
+ *unknownZone = false;
+
+ IcalComponent root = parseRoot(text);
+ if (!root)
+ return event;
+ icalcomponent *master = masterOf(root.get());
+ if (!master)
+ return event;
+
+ event.uid = str(icalcomponent_get_uid(master));
+ event.summary = str(icalcomponent_get_summary(master));
+ event.location = str(icalcomponent_get_location(master));
+ event.description = str(icalcomponent_get_description(master));
+ event.sequence = icalcomponent_get_sequence(master);
+
+ icalproperty *startProp = icalcomponent_get_first_property(master, ICAL_DTSTART_PROPERTY);
+ const icaltimetype start = icalcomponent_get_dtstart(master);
+ event.allDay = start.is_date;
+ event.start = toDateTime(start, tzidOf(startProp), unknownZone);
+ event.end = endOf(master, event.start, event.allDay, unknownZone);
+
+ event.hasAlarm = icalcomponent_get_first_component(master, ICAL_VALARM_COMPONENT);
+ if (icalproperty *org = icalcomponent_get_first_property(master, ICAL_ORGANIZER_PROPERTY))
+ event.organizer = personOf(org);
+ for (icalproperty *a = icalcomponent_get_first_property(master, ICAL_ATTENDEE_PROPERTY);
+ a; a = icalcomponent_get_next_property(master, ICAL_ATTENDEE_PROPERTY))
+ event.attendees.append(personOf(a));
+
+ // Recurrence, exceptions and overrides: Task 4.
+ return event;
+}
+
+LoadResult load(const QString &) { return {}; }
+QList<Occurrence> occurrences(const QList<CalEvent> &, const QDateTime &, const QDateTime &) { return {}; }
+bool isEditable(const CalEvent &, const CalCollection &, const QStringList &) { return false; }
+QByteArray applyEdit(const QByteArray &, const EventEdit &, Scope, const QDateTime &) { return {}; }
+QByteArray newEvent(const EventEdit &, const QByteArray &) { return {}; }
+QByteArray deleteOccurrence(const QByteArray &, const QDateTime &) { return {}; }
+bool sameMeaning(const QByteArray &, const QByteArray &) { return false; }
+
+} // namespace CalendarStore
diff --git a/src/calendarstore.h b/src/calendarstore.h
new file mode 100644
index 0000000..7740d02
--- /dev/null
+++ b/src/calendarstore.h
@@ -0,0 +1,73 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include "caltypes.h"
+
+#include <QByteArray>
+#include <QDateTime>
+#include <QList>
+#include <QString>
+#include <QStringList>
+
+/// The calendar's iCalendar work, and the only code that includes libical.
+///
+/// A namespace of free functions over values, like ContactStore and
+/// MimeParser, so every rule with a right answer is testable without a
+/// widget. See the spec, docs/superpowers/specs/2026-09-24-calendar-design.md.
+namespace CalendarStore {
+
+enum class Scope { All, ThisOccurrence };
+
+/// Parses one file's text. An empty uid in the result means the text held no
+/// usable VEVENT. `unknownZone` is set when a TZID resolved to nothing and the
+/// times were read as local.
+CalEvent parseEvent(const QByteArray &text, const QString &filePath,
+ const QString &collectionDir, bool *unknownZone);
+
+/// Reads every collection under `dir` and every *.ics in each.
+LoadResult load(const QString &dir);
+
+/// Every occurrence of every event overlapping [from, to), sorted by start.
+QList<Occurrence> occurrences(const QList<CalEvent> &events,
+ const QDateTime &from, const QDateTime &to);
+
+/// Whether the user may edit or delete `event`: its collection is writable,
+/// and it has no ORGANIZER or the organiser is one of `ownAddresses`.
+bool isEditable(const CalEvent &event, const CalCollection &collection,
+ const QStringList &ownAddresses);
+
+/// Applies `edit` to the file text and returns the new text; empty when the
+/// text does not parse. Scope::ThisOccurrence writes or replaces the override
+/// for `recurrenceId`. Properties the form does not own survive.
+QByteArray applyEdit(const QByteArray &text, const EventEdit &edit,
+ Scope scope, const QDateTime &recurrenceId);
+
+/// A complete new file for `edit`, timed events in the IANA zone `zoneId`.
+QByteArray newEvent(const EventEdit &edit, const QByteArray &zoneId);
+
+/// Removes one occurrence: adds an EXDATE to the master and drops any override
+/// for that slot. Empty on a parse failure.
+QByteArray deleteOccurrence(const QByteArray &text, const QDateTime &recurrenceId);
+
+/// Whether two texts describe the same event: summary, times, repeat rule,
+/// exceptions. Formatting is ignored, since the server may normalise it.
+bool sameMeaning(const QByteArray &a, const QByteArray &b);
+
+} // namespace CalendarStore
diff --git a/src/caltypes.h b/src/caltypes.h
new file mode 100644
index 0000000..10a65f1
--- /dev/null
+++ b/src/caltypes.h
@@ -0,0 +1,126 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include "repeatrule.h"
+
+#include <QByteArray>
+#include <QColor>
+#include <QDateTime>
+#include <QList>
+#include <QString>
+
+/// One vdir collection: a subdirectory of calendars_dir.
+struct CalCollection
+{
+ QString dir; ///< The directory's own name, e.g. "52".
+ QString path; ///< Absolute path.
+ /// From the vdir's `displayname` file, which vdirsyncer's metasync fills
+ /// from the server; the directory name when absent.
+ QString displayName;
+ /// From the vdir's `color` file (`#RRGGBB`); a colour hashed from the
+ /// directory name when absent, so no collection is ever invisible.
+ QColor color;
+ bool readOnly = false; ///< The directory is not writable.
+};
+
+struct CalPerson
+{
+ QString name; ///< CN, possibly empty.
+ QString address; ///< The address with "mailto:" removed.
+ QString partstat; ///< PARTSTAT as written, e.g. "ACCEPTED"; empty if absent.
+};
+
+/// A RECURRENCE-ID override: one occurrence of a series moved or changed.
+struct CalOverride
+{
+ QDateTime recurrenceId; ///< The original slot it replaces.
+ QDateTime start;
+ QDateTime end;
+ bool allDay = false;
+ QString summary;
+ QString location;
+ QString description;
+ bool cancelled = false; ///< STATUS:CANCELLED: the slot is removed.
+};
+
+/// One .ics file's event, as the rest of the application sees it.
+struct CalEvent
+{
+ QString filePath;
+ QString collectionDir;
+ QString uid;
+ QString summary;
+ QString location;
+ QString description;
+ QDateTime start;
+ QDateTime end; ///< Exclusive. For an all-day event, midnight after the last day.
+ bool allDay = false;
+ RepeatRule repeat; ///< Freq::None when the event does not repeat.
+ QList<QDateTime> exdates;
+ QList<CalOverride> overrides;
+ bool hasAlarm = false;
+ CalPerson organizer; ///< Empty address: no ORGANIZER.
+ QList<CalPerson> attendees;
+ int sequence = 0;
+ QByteArray rawText; ///< The file exactly as read: stale checks and undo.
+};
+
+/// One expanded occurrence. What a view is built from.
+struct Occurrence
+{
+ int eventIndex = -1; ///< Into LoadResult::events.
+ QDateTime start;
+ QDateTime end;
+ bool allDay = false;
+ /// The slot this occurrence fills in its series, which is what a
+ /// RECURRENCE-ID or an EXDATE names. Invalid for a non-repeating event.
+ QDateTime recurrenceId;
+ bool isOverride = false;
+ int overrideIndex = -1; ///< Into CalEvent::overrides when isOverride.
+};
+
+/// An occurrence plus what a view draws. Views see nothing else.
+struct CalendarItem
+{
+ Occurrence occurrence;
+ QString title;
+ QColor color;
+};
+
+/// What the edit form produces.
+struct EventEdit
+{
+ QString summary;
+ QString location;
+ QString description;
+ QDateTime start;
+ QDateTime end;
+ bool allDay = false;
+ RepeatRule repeat;
+ QString collectionDir;
+};
+
+struct LoadResult
+{
+ QList<CalCollection> collections;
+ QList<CalEvent> events;
+ int unparsable = 0; ///< Files skipped because they did not parse.
+ int unknownZones = 0; ///< Events whose TZID resolved to nothing, shown in local time.
+};
diff --git a/src/icalraii.h b/src/icalraii.h
new file mode 100644
index 0000000..b296db3
--- /dev/null
+++ b/src/icalraii.h
@@ -0,0 +1,48 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <libical/ical.h>
+
+#include <cstdlib>
+#include <memory>
+
+/// Owners for libical handles, the calendar's counterpart to nmraii.h.
+///
+/// The same rule as notmuch's: no libical pointer leaves calendarstore.cpp,
+/// the one file that includes this header. unique_ptr rather than NmHandle's
+/// template because every libical handle here has exactly one destroy
+/// function and no aliasing, which is what unique_ptr already expresses.
+struct IcalComponentFree {
+ void operator()(icalcomponent *c) const { icalcomponent_free(c); }
+};
+using IcalComponent = std::unique_ptr<icalcomponent, IcalComponentFree>;
+
+struct IcalRecurFree {
+ void operator()(icalrecur_iterator *i) const { icalrecur_iterator_free(i); }
+};
+using IcalRecurIterator = std::unique_ptr<icalrecur_iterator, IcalRecurFree>;
+
+/// For strings returned by the `*_r` functions, which the caller must free().
+/// The plain variants return a ring buffer libical reuses, so a pointer kept
+/// from one is overwritten by a later call.
+struct IcalStringFree {
+ void operator()(char *s) const { std::free(s); }
+};
+using IcalString = std::unique_ptr<char, IcalStringFree>;
diff --git a/src/repeatrule.cpp b/src/repeatrule.cpp
new file mode 100644
index 0000000..df16c5e
--- /dev/null
+++ b/src/repeatrule.cpp
@@ -0,0 +1,24 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "repeatrule.h"
+
+RepeatRule RepeatRule::fromRRule(const QString &, const QDate &) { return {}; }
+QString RepeatRule::toRRule(const QString &) const { return {}; }
+QString RepeatRule::describe() const { return {}; }
+bool RepeatRule::operator==(const RepeatRule &) const { return false; }
diff --git a/src/repeatrule.h b/src/repeatrule.h
new file mode 100644
index 0000000..59dfceb
--- /dev/null
+++ b/src/repeatrule.h
@@ -0,0 +1,73 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QDate>
+#include <QList>
+#include <QString>
+
+/// The repeat control's model, and its RRULE value text in both directions.
+///
+/// No libical: an RRULE value is `KEY=VALUE;KEY=VALUE`, which is simpler to
+/// read and write as text than through icalrecurrencetype, and keeping it pure
+/// is what lets every row of the spec's repeat table be tested as a string.
+struct RepeatRule
+{
+ enum class Freq { None, Daily, Weekly, Monthly, Yearly };
+ /// Monthly and Yearly only: on a day of the month, or on the Nth weekday.
+ enum class By { MonthDay, Weekday };
+ enum class End { Never, Until, Count };
+
+ Freq freq = Freq::None;
+ int interval = 1;
+ /// Weekly: Qt::DayOfWeek values, 1 (Monday) to 7 (Sunday), sorted.
+ QList<int> weekdays;
+ By by = By::MonthDay;
+ int monthDay = 0; ///< Monthly, By::MonthDay: 1 to 31.
+ int ordinal = 0; ///< By::Weekday: 1 to 4, or -1 for "last".
+ int weekday = 0; ///< By::Weekday: Qt::DayOfWeek.
+ int month = 0; ///< Yearly, By::Weekday: 1 to 12.
+ End end = End::Never;
+ QDate until; ///< End::Until: the last day an occurrence may fall on.
+ int count = 0; ///< End::Count.
+ /// Carried through a rewrite untouched, per the spec: several existing
+ /// rules carry it and it changes nothing for the modes above.
+ QString wkst;
+
+ /// A rule outside the control's table. `customText` is the value exactly
+ /// as read, and an edit never rewrites it.
+ bool custom = false;
+ QString customText;
+
+ /// Parses an RRULE value (no "RRULE:" prefix). `start` is the event's
+ /// first day, which fills in what an RRULE leaves implicit (a weekly rule
+ /// with no BYDAY repeats on the start's weekday).
+ static RepeatRule fromRRule(const QString &value, const QDate &start);
+
+ /// The RRULE value, or an empty string for Freq::None. `untilValue` is
+ /// the whole UNTIL value, `20261231` for an all-day event or
+ /// `20261231T225959Z` for a timed one, computed by the caller because
+ /// only it knows the event's zone. Ignored unless end is End::Until.
+ QString toRRule(const QString &untilValue = QString()) const;
+
+ /// "Monthly, on the last Friday", translated. Custom rules say so.
+ QString describe() const;
+
+ bool operator==(const RepeatRule &other) const;
+};