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:
parent
7f643aa00e
commit
892a58f4fd
3 changed files with 591 additions and 0 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue