Skip to content

Commit 4526973

Browse files
author
Koen Zwikstra
committed
Initial commit
0 parents  commit 4526973

38 files changed

+14056
-0
lines changed

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# https://dart.dev/guides/libraries/private-files
2+
# Created by `dart pub`
3+
.dart_tool/
4+
.vscode/
5+
6+
# Avoid committing pubspec.lock for library packages; see
7+
# https://dart.dev/guides/libraries/private-files#pubspeclock.
8+
pubspec.lock
9+
10+
*.exe

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
## [1.0.0] - 2025-01-15
6+
7+
### Added
8+
- RFC 5545 compliant iCalendar parser
9+
- Support for VEVENT, VTODO, VJOURNAL, VTIMEZONE components
10+
- Full RRULE recurrence expansion
11+
- Streaming parser for large files
12+
- Custom property parser registration
13+
- Timezone-aware date/time handling
14+
- Two-layer architecture (document + semantic)
15+
16+
[1.0.0]: https://github.com/firstfloorsoftware/firstfloor_calendar/releases/tag/v1.0.0

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 First Floor Software
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

README.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# firstfloor_calendar
2+
3+
[![Pub Package](https://img.shields.io/pub/v/firstfloor_calendar.svg)](https://pub.dev/packages/firstfloor_calendar)
4+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5+
6+
A Dart library for parsing iCalendar (.ics) files with RFC 5545 support.
7+
8+
## Features
9+
10+
- Parse iCalendar files into strongly typed models
11+
- Support for events, todos, journals, and timezones
12+
- Full RRULE recurrence expansion
13+
- Stream large files without loading into memory
14+
- Extensible with custom property parsers
15+
16+
## Installation
17+
18+
```yaml
19+
dependencies:
20+
firstfloor_calendar: ^1.0.0
21+
```
22+
23+
## Usage
24+
25+
### Basic Parsing
26+
27+
Parse iCalendar text into a strongly typed `Calendar` object. The parser handles all RFC 5545 components including events, todos, journals, and timezones.
28+
29+
```dart
30+
import 'package:firstfloor_calendar/firstfloor_calendar.dart';
31+
32+
final icsContent = '''
33+
BEGIN:VCALENDAR
34+
VERSION:2.0
35+
PRODID:-//Example//EN
36+
BEGIN:VEVENT
37+
UID:event-123@example.com
38+
DTSTART:20240315T100000Z
39+
DTEND:20240315T110000Z
40+
SUMMARY:Team Meeting
41+
END:VEVENT
42+
END:VCALENDAR''';
43+
44+
final parser = CalendarParser();
45+
final calendar = parser.parseFromString(icsContent);
46+
47+
for (final event in calendar.events) {
48+
print('${event.summary}: ${event.dtstart}');
49+
}
50+
```
51+
52+
### Working with Events
53+
54+
Access event properties with full type safety. Required properties like `uid` and `dtstart` are non-nullable, while optional properties return nullable values.
55+
56+
```dart
57+
final event = calendar.events.first;
58+
59+
// Required properties
60+
print('UID: ${event.uid}');
61+
print('Start: ${event.dtstart}');
62+
63+
// Optional properties
64+
print('Summary: ${event.summary ?? "Untitled"}');
65+
print('Location: ${event.location ?? "No location"}');
66+
print('Description: ${event.description ?? ""}');
67+
68+
// Attendees
69+
for (final attendee in event.attendees) {
70+
print('Attendee: ${attendee.address}');
71+
}
72+
```
73+
74+
### Recurring Events
75+
76+
Generate occurrences from recurrence rules (RRULE). The `occurrences()` method returns a lazy stream that handles both recurring and non-recurring events gracefully.
77+
78+
```dart
79+
final event = calendar.events.first;
80+
81+
// Get first 10 occurrences
82+
for (final occurrence in event.occurrences().take(10)) {
83+
print('Occurrence: $occurrence');
84+
}
85+
```
86+
87+
### Streaming Large Files
88+
89+
Parse large iCalendar files efficiently using the streaming parser. Components are processed one at a time without loading the entire file into memory.
90+
91+
```dart
92+
import 'dart:io';
93+
94+
final file = File('large-calendar.ics');
95+
final streamParser = DocumentStreamParser();
96+
97+
await for (final component in streamParser.streamComponents(file.openRead())) {
98+
if (component.name == 'VEVENT') {
99+
final summary = component.properties
100+
.where((p) => p.name == 'SUMMARY')
101+
.firstOrNull
102+
?.value;
103+
print('Event: ${summary ?? "Untitled"}');
104+
}
105+
}
106+
```
107+
108+
### Custom Property Parsers
109+
110+
Extend the parser with custom property handlers for vendor-specific or experimental properties. Register custom parsers before parsing your calendar data.
111+
112+
```dart
113+
final parser = CalendarParser();
114+
115+
parser.registerPropertyRule(
116+
componentName: 'VEVENT',
117+
propertyName: 'X-CUSTOM-PRIORITY',
118+
rule: PropertyRule(
119+
parser: (property) {
120+
final value = int.tryParse(property.value);
121+
if (value == null || value < 1 || value > 10) {
122+
throw ParseException(
123+
'X-CUSTOM-PRIORITY must be between 1-10',
124+
lineNumber: property.lineNumber,
125+
);
126+
}
127+
return value;
128+
},
129+
),
130+
);
131+
132+
final calendar = parser.parseFromString(icsContent);
133+
final priority = calendar.events.first.value<int>('X-CUSTOM-PRIORITY');
134+
```
135+
136+
## Architecture
137+
138+
The library uses a two-layer architecture:
139+
140+
- **Document Layer** (`DocumentParser`): Parses raw .ics text into an untyped tree structure
141+
- **Semantic Layer** (`CalendarParser`): Converts the document tree into strongly typed models with validation
142+
143+
Use `DocumentParser` for low-level access, and `CalendarParser` for most applications.
144+
145+
## License
146+
147+
MIT License - see [LICENSE](LICENSE) file for details.

analysis_options.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# This file configures the static analysis results for your project (errors,
2+
# warnings, and lints).
3+
#
4+
# This enables the 'recommended' set of lints from `package:lints`.
5+
# This set helps identify many issues that may lead to problems when running
6+
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
7+
# style and format.
8+
#
9+
# If you want a smaller set of lints you can change this to specify
10+
# 'package:lints/core.yaml'. These are just the most critical lints
11+
# (the recommended set includes the core lints).
12+
# The core lints are also what is used by pub.dev for scoring packages.
13+
14+
include: package:lints/recommended.yaml
15+
16+
# Uncomment the following section to specify additional rules.
17+
18+
# linter:
19+
# rules:
20+
# - camel_case_types
21+
22+
# analyzer:
23+
# exclude:
24+
# - path/to/excluded/files/**
25+
26+
# For more information about the core and recommended set of lints, see
27+
# https://dart.dev/go/core-lints
28+
29+
# For additional information about configuring this file, see
30+
# https://dart.dev/guides/language/analysis-options

example/main.dart

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import 'package:firstfloor_calendar/firstfloor_calendar.dart';
2+
3+
void main() {
4+
// Sample iCalendar content
5+
final icsContent = '''
6+
BEGIN:VCALENDAR
7+
VERSION:2.0
8+
PRODID:-//Example//EN
9+
BEGIN:VEVENT
10+
UID:daily-standup@example.com
11+
DTSTAMP:20240315T080000Z
12+
DTSTART:20240315T090000Z
13+
DTEND:20240315T091500Z
14+
SUMMARY:Daily Standup
15+
LOCATION:Conference Room A
16+
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=10
17+
END:VEVENT
18+
BEGIN:VEVENT
19+
UID:team-lunch@example.com
20+
DTSTAMP:20240320T100000Z
21+
DTSTART:20240320T120000Z
22+
DTEND:20240320T130000Z
23+
SUMMARY:Team Lunch
24+
LOCATION:Downtown Bistro
25+
DESCRIPTION:Monthly team lunch to celebrate achievements
26+
END:VEVENT
27+
END:VCALENDAR''';
28+
29+
// Parse the calendar
30+
final parser = CalendarParser();
31+
final calendar = parser.parseFromString(icsContent);
32+
33+
print('Calendar: ${calendar.prodid}');
34+
print('Events: ${calendar.events.length}\n');
35+
36+
// List all events
37+
for (final event in calendar.events) {
38+
print('📅 ${event.summary}');
39+
print(' Start: ${event.dtstart}');
40+
if (event.location != null) {
41+
print(' Location: ${event.location}');
42+
}
43+
if (event.description != null) {
44+
print(' Description: ${event.description}');
45+
}
46+
47+
// Show recurring event occurrences
48+
if (event.rrule != null) {
49+
print(' Recurring: ${event.rrule!.freq}');
50+
print(' First 5 occurrences:');
51+
for (final occurrence in event.occurrences().take(5)) {
52+
print(' • $occurrence');
53+
}
54+
}
55+
print('');
56+
}
57+
}

lib/firstfloor_calendar.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
library;
2+
3+
export 'src/document/document.dart';
4+
export 'src/semantic/semantic.dart';
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import 'package:collection/collection.dart';
2+
3+
/// Represents the raw structural representation of an iCalendar document
4+
/// without any type interpretation of property values.
5+
class CalendarDocument extends CalendarDocumentComponent {
6+
/// Creates a new calendar document with the given properties and components.
7+
const CalendarDocument({
8+
super.properties = const [],
9+
super.components = const [],
10+
super.lineNumber = 0,
11+
}) : super(name: 'VCALENDAR');
12+
}
13+
14+
/// Represents a component in the calendar document structure.
15+
class CalendarDocumentComponent {
16+
/// The name of the calendar component.
17+
final String name;
18+
19+
/// The list of properties associated with this component.
20+
final List<CalendarProperty> properties;
21+
22+
/// The list of sub-components within this component.
23+
final List<CalendarDocumentComponent> components;
24+
25+
/// The line number where this component is defined.
26+
final int lineNumber;
27+
28+
/// Creates a new component with the given name, properties, and components.
29+
const CalendarDocumentComponent({
30+
required this.name,
31+
this.properties = const [],
32+
this.components = const [],
33+
this.lineNumber = 0,
34+
});
35+
36+
/// Returns the properties with the given name.
37+
Iterable<CalendarProperty> propertiesNamed(String name) {
38+
return properties.where((p) => p.name == name);
39+
}
40+
41+
/// Returns the values for the properties with the given name.
42+
Iterable<String> values(String name) {
43+
return properties.where((p) => p.name == name).map((p) => p.value);
44+
}
45+
46+
// Return the first value for the given property name, or null if not found.
47+
String? value(String name) {
48+
return properties.firstWhereOrNull((p) => p.name == name)?.value;
49+
}
50+
51+
/// Returns the components with the given name.
52+
Iterable<CalendarDocumentComponent> componentsNamed(String name) {
53+
return components.where((c) => c.name == name);
54+
}
55+
56+
/// Returns the first component with the given name, or null if not found.
57+
CalendarDocumentComponent? component(String name) {
58+
return components.firstWhereOrNull((c) => c.name == name);
59+
}
60+
61+
@override
62+
String toString() {
63+
return name;
64+
}
65+
}
66+
67+
/// Represents a raw property in the document structure
68+
class CalendarProperty {
69+
/// The name of the property.
70+
final String name;
71+
72+
/// The parameters associated with the property, if any.
73+
final Map<String, List<String>> parameters;
74+
75+
/// The raw value of the property.
76+
final String value;
77+
78+
/// The line number where this property is defined.
79+
final int lineNumber;
80+
81+
/// Creates a new calendar property with the given name, parameters, and value.
82+
const CalendarProperty({
83+
required this.name,
84+
this.parameters = const {},
85+
required this.value,
86+
this.lineNumber = 0,
87+
});
88+
89+
@override
90+
String toString() {
91+
final params = parameters.entries
92+
.map((e) => '${e.key}=${e.value.join(',')}')
93+
.join(';');
94+
return '$name${params.isNotEmpty ? ';$params' : ''}:$value';
95+
}
96+
}

lib/src/document/document.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export 'calendar_document.dart';
2+
export 'document_parser.dart';
3+
export 'parser.dart' show ParseException;

0 commit comments

Comments
 (0)