feat(todo): a calendar's feed read, and linked to a project in its payload (ADR 0033, D39)

The developer's decisions of 10 Nov 2026 for calendar feeds, recorded as
D39: a project links to a calendar's iCal feed, its events shown beside
tasks, read-only; each device fetches it, the address sealed in the
project's payload, the last copy kept on the device.

This lays the ground: `readIcal` reads a feed's events (title, start,
end or duration, all-day, place, cancelled, the dates a recurrence
leaves out and the occurrences changed on their own), on the device's
clock through `timezone`, and expands their recurrence with pub.dev's
`rrule` (Apache-2.0, the developer's choice); `ProjectContent` gains
`calendarUrl`, its payload staying schema 1. Nothing shows it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 11:49:34 +01:00
commit 892a58f4fd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 591 additions and 0 deletions

View file

@ -0,0 +1,335 @@
import 'package:equatable/equatable.dart';
import 'package:rrule/rrule.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// One occurrence of a calendar's event (ADR 0033, D39), on the device's
/// clock: from [start] to [end], the end not part of it; an [allDay] one
/// from midnight to midnight of its days.
final class const IcalEvent({
required final String uid,
required final String title,
required final DateTime start,
required final DateTime end,
final bool allDay = false,
final String? location,
}) with Equatable {
@override
List<Object?> get props => [uid, title, start, end, allDay, location];
}
/// Whether [text] is a calendar file at all, not a page saying it isn't.
bool isIcal(String text) => text.trimLeft().startsWith('BEGIN:VCALENDAR');
/// The occurrences of [text]'s events that run within [from] to [until],
/// in order: a recurring event's expanded by its rule, the dates it leaves
/// out left out and the occurrences changed on their own in their place;
/// cancelled ones left out. A file that is no calendar holds none.
List<IcalEvent> readIcal(
String text, {
required DateTime from,
required DateTime until,
}) {
if (!isIcal(text)) {
return const [];
}
final components = _events(_lines(text));
// Occurrences changed on their own, by their event and the moment they
// replace.
final changed = <(String, DateTime)>{
for (final event in components)
if (event.recurrenceId case final id?) (event.uid, id),
};
final found = <IcalEvent>[];
for (final event in components) {
if (event.cancelled) {
continue;
}
for (final occurrence in event.occurrences(from, until)) {
final replaced =
event.recurrenceId == null &&
changed.contains((event.uid, occurrence.start));
if (!replaced &&
occurrence.end.isAfter(from) &&
occurrence.start.isBefore(until)) {
found.add(occurrence);
}
}
}
return found..sort((a, b) => a.start.compareTo(b.start));
}
/// A line of the file, its folds joined: `NAME;PARAM=VALUE:value`.
final class const _Line(
final String name,
final Map<String, String> params,
final String value,
);
/// The file's lines, those folded onto the next (starting with a space or
/// a tab) joined back.
List<_Line> _lines(String text) {
final unfolded = text
.replaceAll('\r\n', '\n')
.replaceAll(RegExp('\n[ \t]'), '')
.split('\n');
return [for (final raw in unfolded) ?_line(raw)];
}
_Line? _line(String raw) {
// The value starts after the first colon outside quotes.
var quoted = false;
for (var i = 0; i < raw.length; i++) {
final char = raw[i];
if (char == '"') {
quoted = !quoted;
} else if (char == ':' && !quoted) {
final head = raw.substring(0, i).split(';');
return _Line(head.first.toUpperCase(), {
for (final param in head.skip(1))
if (param.indexOf('=') case final at when at > 0)
param.substring(0, at).toUpperCase(): param
.substring(at + 1)
.replaceAll('"', ''),
}, raw.substring(i + 1));
}
}
return null;
}
/// A text value with its escapes read: `\n`, `\,`, `\;`, `\\`.
String _text(String value) => value.replaceAllMapped(
RegExp(r'\\([nN,;\\])'),
(match) => switch (match[1]) {
'n' || 'N' => '\n',
final char => char!,
},
);
/// How a date or time of the file is read: a day alone, a moment in UTC,
/// a wall clock in a time zone, or a wall clock as written (floating, or a
/// zone not known here).
sealed class const _Clock();
final class const _Day() extends _Clock;
final class const _Utc() extends _Clock;
final class const _Floating() extends _Clock;
final class const _Zone(final tz.Location location) extends _Clock;
/// A date or time as written, its fields held in a UTC [DateTime] (as
/// `rrule` takes them), with how it is to be read.
final class const _Stamp(final DateTime wall, final _Clock clock) {
/// On the device's clock.
DateTime get local => switch (clock) {
_Utc() => wall.toLocal(),
_Zone(:final location) => DateTime.fromMillisecondsSinceEpoch(
tz.TZDateTime(
location,
wall.year,
wall.month,
wall.day,
wall.hour,
wall.minute,
wall.second,
).millisecondsSinceEpoch,
),
_Day() || _Floating() => DateTime(
wall.year,
wall.month,
wall.day,
wall.hour,
wall.minute,
wall.second,
),
};
_Stamp at(DateTime wall) => _Stamp(wall, clock);
}
var _zonesLoaded = false;
_Stamp? _stamp(String value, Map<String, String> params) {
final match = RegExp(
r'^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$',
).firstMatch(value.trim());
if (match == null) {
return null;
}
int at(int group) => int.parse(match[group] ?? '0');
final wall = DateTime.utc(at(1), at(2), at(3), at(4), at(5), at(6));
if (match[4] == null) {
return _Stamp(wall, const _Day());
}
if (match[7] != null) {
return _Stamp(wall, const _Utc());
}
if (params['TZID'] case final zone?) {
if (!_zonesLoaded) {
tz_data.initializeTimeZones();
_zonesLoaded = true;
}
try {
return _Stamp(wall, _Zone(tz.getLocation(zone)));
} on tz.LocationNotFoundException {
// A zone named otherwise (Outlook's Windows names): as written.
}
}
return _Stamp(wall, const _Floating());
}
/// RFC 5545's duration: `P1W`, `P1DT2H30M`, `PT45M`, `-PT15M`.
Duration? _duration(String value) {
final match = RegExp(
r'^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$',
).firstMatch(value.trim());
if (match == null) {
return null;
}
int at(int group) => int.parse(match[group] ?? '0');
final duration = Duration(
days: at(2) * 7 + at(3),
hours: at(4),
minutes: at(5),
seconds: at(6),
);
return match[1] == '-' ? -duration : duration;
}
/// An event of the file, as written.
final class _Event() {
String uid = '';
String title = '';
String? location;
_Stamp? start;
_Stamp? end;
Duration? duration;
RecurrenceRule? rule;
final excluded = <DateTime>{};
bool cancelled = false;
/// The moment on the device's clock of the occurrence it changes, where
/// it changes one of a recurring event's.
DateTime? recurrenceId;
bool get allDay => start?.clock is _Day;
/// How long each occurrence lasts, on the wall clock.
Duration get length {
if (duration case final duration?) {
return duration;
}
if ((start, end) case (final start?, final end?)) {
return end.wall.difference(start.wall);
}
return allDay ? const Duration(days: 1) : Duration.zero;
}
/// Its occurrences that may run within [from] to [until]: each start the
/// rule gives near the window, but those left out.
Iterable<IcalEvent> occurrences(DateTime from, DateTime until) sync* {
final start = this.start;
if (start == null) {
return;
}
final length = this.length;
// A day's margin either side for the zones' offsets.
final after = _wall(from).subtract(length + const Duration(days: 1));
final before = _wall(until).add(const Duration(days: 1));
final starts = switch (rule) {
null => [start.wall],
_ when before.isBefore(start.wall) => const <DateTime>[],
final rule => rule.getInstances(
start: start.wall,
after: after.isBefore(start.wall) ? start.wall : after,
includeAfter: true,
before: before,
),
};
for (final wall in starts) {
final local = start.at(wall).local;
if (excluded.contains(local)) {
continue;
}
yield IcalEvent(
uid: uid,
title: title,
start: local,
end: start.at(wall.add(length)).local,
allDay: allDay,
location: location,
);
}
}
static DateTime _wall(DateTime local) => DateTime.utc(
local.year,
local.month,
local.day,
local.hour,
local.minute,
local.second,
);
}
/// The file's events, but those without a start.
List<_Event> _events(List<_Line> lines) {
final events = <_Event>[];
_Event? event;
// Lines inside a component within the event (an alarm) aren't its own.
var nested = 0;
for (final line in lines) {
switch (line.name) {
case 'BEGIN' when line.value.toUpperCase() == 'VEVENT':
event = _Event();
nested = 0;
case 'BEGIN' when event != null:
nested++;
case 'END' when event != null && nested > 0:
nested--;
case 'END' when event != null:
if (event.start != null) {
events.add(event);
}
event = null;
case _ when event != null && nested == 0:
_read(event, line);
}
}
return events;
}
void _read(_Event event, _Line line) {
switch (line.name) {
case 'UID':
event.uid = line.value;
case 'SUMMARY':
event.title = _text(line.value);
case 'LOCATION':
event.location = _text(line.value);
case 'DTSTART':
event.start = _stamp(line.value, line.params);
case 'DTEND':
event.end = _stamp(line.value, line.params);
case 'DURATION':
event.duration = _duration(line.value);
case 'STATUS':
event.cancelled = line.value.toUpperCase() == 'CANCELLED';
case 'RRULE':
try {
event.rule = RecurrenceRule.fromString('RRULE:${line.value}');
} on FormatException {
// A rule it can't read: its first occurrence alone.
}
case 'EXDATE':
for (final value in line.value.split(',')) {
if (_stamp(value, line.params) case final stamp?) {
event.excluded.add(stamp.local);
}
}
case 'RECURRENCE-ID':
event.recurrenceId = _stamp(line.value, line.params)?.local;
}
}

View file

@ -24,6 +24,7 @@ const _known = {
'parentId',
'favorite',
'defaultView',
'calendarUrl',
};
/// [content] as its row's payload, before sealing: UTF-8 JSON, the colour by
@ -42,6 +43,7 @@ ByteData encodeProject(ProjectContent content) =>
if (content.favorite) 'favorite': true,
if (!content.defaultView.isDefault)
'defaultView': encodeView(content.defaultView),
'calendarUrl': ?content.calendarUrl,
});
/// [view] by its values' names, those left as at first left out.
@ -129,6 +131,7 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
parentId: json['parentId'] as String?,
favorite: json['favorite'] == true,
defaultView: decodeView(json['defaultView']),
calendarUrl: json['calendarUrl'] as String?,
unknown: {
for (final MapEntry(:key, :value) in json.entries)
if (!_known.contains(key)) key: value,

View file

@ -32,6 +32,10 @@ final class const ProjectContent({
/// layout as its editor picks it, the rest as at first.
final ProjectView defaultView = const ProjectView(),
/// The address of a calendar's feed whose events it shows beside its
/// tasks, read-only (D39); none: no calendar.
final String? calendarUrl,
/// What its payload holds that this version doesn't know (as a task's,
/// D18): written back as it was, so that a project changed here keeps
/// what a newer version gave it.
@ -55,6 +59,7 @@ final class const ProjectContent({
String? Function()? parentId,
bool? favorite,
ProjectView? defaultView,
String? Function()? calendarUrl,
}) => ProjectContent(
name: name ?? this.name,
color: color == null ? this.color : color(),
@ -64,6 +69,7 @@ final class const ProjectContent({
parentId: parentId == null ? this.parentId : parentId(),
favorite: favorite ?? this.favorite,
defaultView: defaultView ?? this.defaultView,
calendarUrl: calendarUrl == null ? this.calendarUrl : calendarUrl(),
unknown: unknown,
);
@ -77,6 +83,7 @@ final class const ProjectContent({
parentId,
favorite,
defaultView,
calendarUrl,
unknown,
];
}