diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 4 | ||||
| -rw-r--r-- | src/calendarstore.cpp | 171 | ||||
| -rw-r--r-- | src/calendarstore.h | 73 | ||||
| -rw-r--r-- | src/caltypes.h | 126 | ||||
| -rw-r--r-- | src/icalraii.h | 48 | ||||
| -rw-r--r-- | src/repeatrule.cpp | 24 | ||||
| -rw-r--r-- | src/repeatrule.h | 73 |
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; +}; |
