@pipobscure/ical 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +661 -0
- package/dist/alarm.d.ts +35 -0
- package/dist/alarm.d.ts.map +1 -0
- package/dist/alarm.js +87 -0
- package/dist/alarm.js.map +1 -0
- package/dist/calendar.d.ts +52 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +121 -0
- package/dist/calendar.js.map +1 -0
- package/dist/component.d.ts +48 -0
- package/dist/component.d.ts.map +1 -0
- package/dist/component.js +170 -0
- package/dist/component.js.map +1 -0
- package/dist/event.d.ts +74 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +263 -0
- package/dist/event.js.map +1 -0
- package/dist/freebusy.d.ts +45 -0
- package/dist/freebusy.d.ts.map +1 -0
- package/dist/freebusy.js +111 -0
- package/dist/freebusy.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +48 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +148 -0
- package/dist/journal.js.map +1 -0
- package/dist/parse.d.ts +27 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +140 -0
- package/dist/parse.js.map +1 -0
- package/dist/property-registry.d.ts +31 -0
- package/dist/property-registry.d.ts.map +1 -0
- package/dist/property-registry.js +83 -0
- package/dist/property-registry.js.map +1 -0
- package/dist/property.d.ts +36 -0
- package/dist/property.d.ts.map +1 -0
- package/dist/property.js +135 -0
- package/dist/property.js.map +1 -0
- package/dist/timezone.d.ts +55 -0
- package/dist/timezone.d.ts.map +1 -0
- package/dist/timezone.js +141 -0
- package/dist/timezone.js.map +1 -0
- package/dist/todo.d.ts +67 -0
- package/dist/todo.d.ts.map +1 -0
- package/dist/todo.js +220 -0
- package/dist/todo.js.map +1 -0
- package/dist/tokenize.d.ts +22 -0
- package/dist/tokenize.d.ts.map +1 -0
- package/dist/tokenize.js +95 -0
- package/dist/tokenize.js.map +1 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/value-types.d.ts +81 -0
- package/dist/value-types.d.ts.map +1 -0
- package/dist/value-types.js +445 -0
- package/dist/value-types.js.map +1 -0
- package/package.json +45 -0
- package/src/alarm.ts +105 -0
- package/src/calendar.ts +153 -0
- package/src/component.ts +193 -0
- package/src/event.ts +307 -0
- package/src/freebusy.ts +133 -0
- package/src/index.ts +85 -0
- package/src/journal.ts +174 -0
- package/src/parse.ts +166 -0
- package/src/property-registry.ts +124 -0
- package/src/property.ts +163 -0
- package/src/timezone.ts +169 -0
- package/src/todo.ts +253 -0
- package/src/tokenize.ts +99 -0
- package/src/types.ts +135 -0
- package/src/value-types.ts +498 -0
package/src/parse.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tolerant iCalendar parser.
|
|
3
|
+
*
|
|
4
|
+
* Principles:
|
|
5
|
+
* - Accepts CRLF, LF, or bare CR line endings.
|
|
6
|
+
* - Handles folded lines.
|
|
7
|
+
* - Unknown components are stored as generic Component instances.
|
|
8
|
+
* - Unknown properties are stored with TEXT value.
|
|
9
|
+
* - Missing required wrappers are recovered from where possible.
|
|
10
|
+
* - Malformed property lines are skipped with a warning (no crash).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { tokenize } from './tokenize.js';
|
|
14
|
+
import { parseProperty } from './property.js';
|
|
15
|
+
import { Component } from './component.js';
|
|
16
|
+
import { Calendar } from './calendar.js';
|
|
17
|
+
import { Event } from './event.js';
|
|
18
|
+
import { Todo } from './todo.js';
|
|
19
|
+
import { Journal } from './journal.js';
|
|
20
|
+
import { FreeBusy } from './freebusy.js';
|
|
21
|
+
import { Timezone, buildTimezoneRule } from './timezone.js';
|
|
22
|
+
import { Alarm } from './alarm.js';
|
|
23
|
+
|
|
24
|
+
// ── Raw tree built during tokenization ───────────────────────────────────
|
|
25
|
+
|
|
26
|
+
interface RawNode {
|
|
27
|
+
type: string;
|
|
28
|
+
props: Array<{ name: string; params: Record<string, string>; value: string }>;
|
|
29
|
+
children: RawNode[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build a raw component tree from the flat token stream. */
|
|
33
|
+
function buildTree(src: string): RawNode[] {
|
|
34
|
+
const tokens = tokenize(src);
|
|
35
|
+
const root: RawNode = { type: 'ROOT', props: [], children: [] };
|
|
36
|
+
const stack: RawNode[] = [root];
|
|
37
|
+
|
|
38
|
+
for (const token of tokens) {
|
|
39
|
+
const current = stack[stack.length - 1]!;
|
|
40
|
+
|
|
41
|
+
if (token.name === 'BEGIN') {
|
|
42
|
+
const node: RawNode = {
|
|
43
|
+
type: token.value.toUpperCase().trim(),
|
|
44
|
+
props: [],
|
|
45
|
+
children: [],
|
|
46
|
+
};
|
|
47
|
+
current.children.push(node);
|
|
48
|
+
stack.push(node);
|
|
49
|
+
} else if (token.name === 'END') {
|
|
50
|
+
if (stack.length > 1) stack.pop();
|
|
51
|
+
// Tolerant: if stack is empty we ignore the END
|
|
52
|
+
} else {
|
|
53
|
+
// Normalize params: ensure all values are strings (flatten single-element arrays)
|
|
54
|
+
const params: Record<string, string> = {};
|
|
55
|
+
for (const [k, v] of Object.entries(token.params)) {
|
|
56
|
+
params[k] = Array.isArray(v) ? (v as string[]).join(',') : String(v);
|
|
57
|
+
}
|
|
58
|
+
current.props.push({ name: token.name, params, value: token.value });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return root.children;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Component builders ────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function buildComponent(node: RawNode): Component {
|
|
68
|
+
switch (node.type) {
|
|
69
|
+
case 'VCALENDAR': return buildCalendar(node);
|
|
70
|
+
case 'VEVENT': return buildEvent(node);
|
|
71
|
+
case 'VTODO': return buildTodo(node);
|
|
72
|
+
case 'VJOURNAL': return buildJournal(node);
|
|
73
|
+
case 'VFREEBUSY': return buildFreebusy(node);
|
|
74
|
+
case 'VTIMEZONE': return buildTimezone(node);
|
|
75
|
+
case 'VALARM': return buildAlarm(node);
|
|
76
|
+
case 'STANDARD':
|
|
77
|
+
case 'DAYLIGHT': return buildTimezoneRule(node.type, node.props);
|
|
78
|
+
default: return buildGeneric(node);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildCalendar(node: RawNode): Calendar {
|
|
83
|
+
const children = node.children.map(buildComponent);
|
|
84
|
+
return Calendar.fromRaw(node.props, children);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildEvent(node: RawNode): Event {
|
|
88
|
+
const children = node.children.map(buildComponent);
|
|
89
|
+
return Event.fromRaw(node.props, children);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildTodo(node: RawNode): Todo {
|
|
93
|
+
const children = node.children.map(buildComponent);
|
|
94
|
+
return Todo.fromRaw(node.props, children);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildJournal(node: RawNode): Journal {
|
|
98
|
+
return Journal.fromRaw(node.props);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildFreebusy(node: RawNode): FreeBusy {
|
|
102
|
+
return FreeBusy.fromRaw(node.props);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildTimezone(node: RawNode): Timezone {
|
|
106
|
+
const children = node.children.map(buildComponent);
|
|
107
|
+
return Timezone.fromRaw(node.props, children);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildAlarm(node: RawNode): Alarm {
|
|
111
|
+
return Alarm.fromRaw(node.props);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildGeneric(node: RawNode): Component {
|
|
115
|
+
const comp = new Component(node.type);
|
|
116
|
+
for (const { name, params, value } of node.props) {
|
|
117
|
+
comp.addProperty(parseProperty(name, value, params));
|
|
118
|
+
}
|
|
119
|
+
for (const child of node.children) {
|
|
120
|
+
comp.addComponent(buildComponent(child));
|
|
121
|
+
}
|
|
122
|
+
return comp;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse an iCalendar string and return a Calendar object.
|
|
129
|
+
*
|
|
130
|
+
* Tolerant: if the input has no VCALENDAR wrapper (e.g. just a bare VEVENT)
|
|
131
|
+
* the content is wrapped in a synthetic Calendar.
|
|
132
|
+
*
|
|
133
|
+
* @throws Only on completely unparseable input that yields zero tokens.
|
|
134
|
+
*/
|
|
135
|
+
export function parse(src: string): Calendar {
|
|
136
|
+
const roots = buildTree(src);
|
|
137
|
+
|
|
138
|
+
// Find the first VCALENDAR
|
|
139
|
+
const calNode = roots.find((n) => n.type === 'VCALENDAR');
|
|
140
|
+
if (calNode) {
|
|
141
|
+
return buildCalendar(calNode);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Tolerant: no VCALENDAR wrapper — synthesize one
|
|
145
|
+
const cal = new Calendar();
|
|
146
|
+
for (const node of roots) {
|
|
147
|
+
cal.addComponent(buildComponent(node));
|
|
148
|
+
}
|
|
149
|
+
return cal;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse an iCalendar string and return all top-level Calendar objects.
|
|
154
|
+
* Some feeds embed multiple VCALENDAR blocks in a single file.
|
|
155
|
+
*/
|
|
156
|
+
export function parseAll(src: string): Calendar[] {
|
|
157
|
+
const roots = buildTree(src);
|
|
158
|
+
const calendars = roots.filter((n) => n.type === 'VCALENDAR');
|
|
159
|
+
|
|
160
|
+
if (calendars.length > 0) {
|
|
161
|
+
return calendars.map(buildCalendar);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// No VCALENDAR blocks — wrap everything in one
|
|
165
|
+
return [parse(src)];
|
|
166
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 5545 property registry.
|
|
3
|
+
*
|
|
4
|
+
* Maps property names to their default value type, allowed alternative types,
|
|
5
|
+
* whether they accept a list of values, and which components they belong to.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ValueTypeName =
|
|
9
|
+
| 'TEXT'
|
|
10
|
+
| 'INTEGER'
|
|
11
|
+
| 'FLOAT'
|
|
12
|
+
| 'BOOLEAN'
|
|
13
|
+
| 'URI'
|
|
14
|
+
| 'CAL-ADDRESS'
|
|
15
|
+
| 'BINARY'
|
|
16
|
+
| 'UTC-OFFSET'
|
|
17
|
+
| 'DATE'
|
|
18
|
+
| 'DATE-TIME'
|
|
19
|
+
| 'TIME'
|
|
20
|
+
| 'DURATION'
|
|
21
|
+
| 'PERIOD'
|
|
22
|
+
| 'RECUR'
|
|
23
|
+
| 'GEO';
|
|
24
|
+
|
|
25
|
+
export interface PropertyDef {
|
|
26
|
+
/** Default value type when no VALUE= parameter is present */
|
|
27
|
+
readonly defaultType: ValueTypeName;
|
|
28
|
+
/** Additional allowed value types selectable via VALUE= parameter */
|
|
29
|
+
readonly allowedTypes?: readonly ValueTypeName[];
|
|
30
|
+
/**
|
|
31
|
+
* true → value is a comma-separated list of the same type
|
|
32
|
+
* false → single value
|
|
33
|
+
*/
|
|
34
|
+
readonly multi?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* RFC 5545 §3.8 — Component Properties
|
|
39
|
+
* Includes deprecated but commonly encountered properties (EXRULE).
|
|
40
|
+
*/
|
|
41
|
+
export const PROPERTY_REGISTRY: Readonly<Record<string, PropertyDef>> = {
|
|
42
|
+
// ── Calendar Properties (§3.7) ─────────────────────────────────────────
|
|
43
|
+
CALSCALE: { defaultType: 'TEXT' },
|
|
44
|
+
METHOD: { defaultType: 'TEXT' },
|
|
45
|
+
PRODID: { defaultType: 'TEXT' },
|
|
46
|
+
VERSION: { defaultType: 'TEXT' },
|
|
47
|
+
|
|
48
|
+
// ── Descriptive Component Properties (§3.8.1) ──────────────────────────
|
|
49
|
+
ATTACH: { defaultType: 'URI', allowedTypes: ['BINARY'] },
|
|
50
|
+
CATEGORIES: { defaultType: 'TEXT', multi: true },
|
|
51
|
+
CLASS: { defaultType: 'TEXT' },
|
|
52
|
+
COMMENT: { defaultType: 'TEXT' },
|
|
53
|
+
DESCRIPTION: { defaultType: 'TEXT' },
|
|
54
|
+
GEO: { defaultType: 'GEO' },
|
|
55
|
+
LOCATION: { defaultType: 'TEXT' },
|
|
56
|
+
'PERCENT-COMPLETE': { defaultType: 'INTEGER' },
|
|
57
|
+
PRIORITY: { defaultType: 'INTEGER' },
|
|
58
|
+
RESOURCES: { defaultType: 'TEXT', multi: true },
|
|
59
|
+
STATUS: { defaultType: 'TEXT' },
|
|
60
|
+
SUMMARY: { defaultType: 'TEXT' },
|
|
61
|
+
|
|
62
|
+
// ── Date and Time Component Properties (§3.8.2) ───────────────────────
|
|
63
|
+
COMPLETED: { defaultType: 'DATE-TIME' },
|
|
64
|
+
DTEND: { defaultType: 'DATE-TIME', allowedTypes: ['DATE'] },
|
|
65
|
+
DUE: { defaultType: 'DATE-TIME', allowedTypes: ['DATE'] },
|
|
66
|
+
DTSTART: { defaultType: 'DATE-TIME', allowedTypes: ['DATE'] },
|
|
67
|
+
DURATION: { defaultType: 'DURATION' },
|
|
68
|
+
FREEBUSY: { defaultType: 'PERIOD', multi: true },
|
|
69
|
+
TRANSP: { defaultType: 'TEXT' },
|
|
70
|
+
|
|
71
|
+
// ── Time Zone Component Properties (§3.8.3) ───────────────────────────
|
|
72
|
+
TZID: { defaultType: 'TEXT' },
|
|
73
|
+
TZNAME: { defaultType: 'TEXT' },
|
|
74
|
+
TZOFFSETFROM: { defaultType: 'UTC-OFFSET' },
|
|
75
|
+
TZOFFSETTO: { defaultType: 'UTC-OFFSET' },
|
|
76
|
+
TZURL: { defaultType: 'URI' },
|
|
77
|
+
|
|
78
|
+
// ── Relationship Component Properties (§3.8.4) ────────────────────────
|
|
79
|
+
ATTENDEE: { defaultType: 'CAL-ADDRESS' },
|
|
80
|
+
CONTACT: { defaultType: 'TEXT' },
|
|
81
|
+
ORGANIZER: { defaultType: 'CAL-ADDRESS' },
|
|
82
|
+
'RECURRENCE-ID': { defaultType: 'DATE-TIME', allowedTypes: ['DATE'] },
|
|
83
|
+
'RELATED-TO': { defaultType: 'TEXT' },
|
|
84
|
+
URL: { defaultType: 'URI' },
|
|
85
|
+
UID: { defaultType: 'TEXT' },
|
|
86
|
+
|
|
87
|
+
// ── Recurrence Component Properties (§3.8.5) ──────────────────────────
|
|
88
|
+
EXDATE: { defaultType: 'DATE-TIME', allowedTypes: ['DATE'], multi: true },
|
|
89
|
+
RDATE: { defaultType: 'DATE-TIME', allowedTypes: ['DATE', 'PERIOD'], multi: true },
|
|
90
|
+
RRULE: { defaultType: 'RECUR' },
|
|
91
|
+
EXRULE: { defaultType: 'RECUR' }, // deprecated, still common
|
|
92
|
+
|
|
93
|
+
// ── Alarm Component Properties (§3.8.6) ───────────────────────────────
|
|
94
|
+
ACTION: { defaultType: 'TEXT' },
|
|
95
|
+
REPEAT: { defaultType: 'INTEGER' },
|
|
96
|
+
TRIGGER: { defaultType: 'DURATION', allowedTypes: ['DATE-TIME'] },
|
|
97
|
+
|
|
98
|
+
// ── Change Management Component Properties (§3.8.7) ──────────────────
|
|
99
|
+
CREATED: { defaultType: 'DATE-TIME' },
|
|
100
|
+
DTSTAMP: { defaultType: 'DATE-TIME' },
|
|
101
|
+
'LAST-MODIFIED': { defaultType: 'DATE-TIME' },
|
|
102
|
+
SEQUENCE: { defaultType: 'INTEGER' },
|
|
103
|
+
|
|
104
|
+
// ── Miscellaneous Component Properties (§3.8.8) ───────────────────────
|
|
105
|
+
'REQUEST-STATUS': { defaultType: 'TEXT' },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Look up a property definition; returns undefined for X- and unknown properties. */
|
|
109
|
+
export function getPropertyDef(name: string): PropertyDef | undefined {
|
|
110
|
+
return PROPERTY_REGISTRY[name];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Determine the effective value type for a content line.
|
|
115
|
+
* Respects VALUE= parameter override.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveValueType(
|
|
118
|
+
name: string,
|
|
119
|
+
params: Readonly<Record<string, string | readonly string[]>>,
|
|
120
|
+
): ValueTypeName {
|
|
121
|
+
const override = typeof params['VALUE'] === 'string' ? params['VALUE'].toUpperCase() : undefined;
|
|
122
|
+
if (override) return override as ValueTypeName;
|
|
123
|
+
return PROPERTY_REGISTRY[name]?.defaultType ?? 'TEXT';
|
|
124
|
+
}
|
package/src/property.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property — a named, parametered, typed iCalendar property.
|
|
3
|
+
*
|
|
4
|
+
* Strict on construction (required for serialization).
|
|
5
|
+
* Tolerant when created from raw parsed data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ICalValue, ParsedProperty } from './types.js';
|
|
9
|
+
import { CODECS, GEO } from './value-types.js';
|
|
10
|
+
import { getPropertyDef, resolveValueType } from './property-registry.js';
|
|
11
|
+
import type { ValueTypeName } from './property-registry.js';
|
|
12
|
+
|
|
13
|
+
export class Property implements ParsedProperty {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly params: Readonly<Record<string, string | readonly string[]>>;
|
|
16
|
+
readonly value: ICalValue | readonly ICalValue[];
|
|
17
|
+
readonly rawValue: string;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
name: string,
|
|
21
|
+
value: ICalValue | readonly ICalValue[],
|
|
22
|
+
params: Readonly<Record<string, string | readonly string[]>> = {},
|
|
23
|
+
rawValue = '',
|
|
24
|
+
) {
|
|
25
|
+
this.name = name.toUpperCase();
|
|
26
|
+
this.value = value;
|
|
27
|
+
this.params = params;
|
|
28
|
+
this.rawValue = rawValue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Get the first (or only) value as a plain value (null when the list is empty). */
|
|
32
|
+
get scalar(): ICalValue | null {
|
|
33
|
+
const v = this.value;
|
|
34
|
+
if (Array.isArray(v)) return (v as ICalValue[])[0] ?? null;
|
|
35
|
+
return v as ICalValue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Get the value as an array (always). */
|
|
39
|
+
get list(): ICalValue[] {
|
|
40
|
+
const v = this.value;
|
|
41
|
+
if (Array.isArray(v)) return [...(v as ICalValue[])];
|
|
42
|
+
return [v as ICalValue];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Convenience: get value as string (or null). */
|
|
46
|
+
get text(): string | null {
|
|
47
|
+
const v = this.scalar;
|
|
48
|
+
return typeof v === 'string' ? v : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Convenience: get value as number (or null). */
|
|
52
|
+
get number(): number | null {
|
|
53
|
+
const v = this.scalar;
|
|
54
|
+
return typeof v === 'number' ? v : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Convenience: get value as boolean. */
|
|
58
|
+
get boolean(): boolean | null {
|
|
59
|
+
const v = this.scalar;
|
|
60
|
+
return typeof v === 'boolean' ? v : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serialize this property to a single (unfolded) content line string.
|
|
65
|
+
* Throws if the value type cannot be serialized.
|
|
66
|
+
*/
|
|
67
|
+
toContentLine(): string {
|
|
68
|
+
const paramStr = serializeParams(this.params);
|
|
69
|
+
const valueStr = this.serializeValue();
|
|
70
|
+
return `${this.name}${paramStr}:${valueStr}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private serializeValue(): string {
|
|
74
|
+
const def = getPropertyDef(this.name);
|
|
75
|
+
const typeName = resolveValueType(this.name, this.params);
|
|
76
|
+
|
|
77
|
+
if (typeName === 'GEO') {
|
|
78
|
+
const v = this.scalar as { type: 'geo'; latitude: number; longitude: number };
|
|
79
|
+
return GEO.serialize(v);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const codec = CODECS[typeName];
|
|
83
|
+
|
|
84
|
+
const serialize = (v: ICalValue): string => {
|
|
85
|
+
if (codec) return codec.serialize(v);
|
|
86
|
+
// Unknown / X- property: convert to string
|
|
87
|
+
return String(v ?? '');
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (def?.multi && Array.isArray(this.value)) {
|
|
91
|
+
return this.value.map(serialize).join(',');
|
|
92
|
+
}
|
|
93
|
+
return serialize(Array.isArray(this.value) ? (this.value[0] ?? '') : this.value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function serializeParams(
|
|
98
|
+
params: Readonly<Record<string, string | readonly string[]>>,
|
|
99
|
+
): string {
|
|
100
|
+
let result = '';
|
|
101
|
+
for (const [key, val] of Object.entries(params)) {
|
|
102
|
+
const valStr = Array.isArray(val) ? val.join(',') : val;
|
|
103
|
+
// Quote if contains special characters
|
|
104
|
+
const needsQuote = /[;:,]/.test(valStr as string);
|
|
105
|
+
result += `;${key}=${needsQuote ? `"${valStr}"` : valStr}`;
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Factory ───────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse a raw content-line value string into a typed Property.
|
|
114
|
+
* Tolerant: unknown value types fall back to TEXT.
|
|
115
|
+
*/
|
|
116
|
+
export function parseProperty(
|
|
117
|
+
name: string,
|
|
118
|
+
rawValue: string,
|
|
119
|
+
params: Readonly<Record<string, string | readonly string[]>>,
|
|
120
|
+
): Property {
|
|
121
|
+
const typeName = resolveValueType(name, params) as ValueTypeName;
|
|
122
|
+
const def = getPropertyDef(name);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
if (typeName === 'GEO') {
|
|
126
|
+
return new Property(name, GEO.parse(rawValue), params, rawValue);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const codec = CODECS[typeName] ?? CODECS['TEXT']!;
|
|
130
|
+
|
|
131
|
+
if (def?.multi) {
|
|
132
|
+
// Split on unescaped commas
|
|
133
|
+
const parts = splitMultiValue(rawValue);
|
|
134
|
+
const values = parts.map((p) => codec.parse(p, params) as ICalValue);
|
|
135
|
+
return new Property(name, values, params, rawValue);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const value = codec.parse(rawValue, params) as ICalValue;
|
|
139
|
+
return new Property(name, value, params, rawValue);
|
|
140
|
+
} catch {
|
|
141
|
+
// Tolerant fallback: store as raw string
|
|
142
|
+
return new Property(name, rawValue, params, rawValue);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Split a comma-separated multi-value string, respecting backslash escapes. */
|
|
147
|
+
function splitMultiValue(raw: string): string[] {
|
|
148
|
+
const parts: string[] = [];
|
|
149
|
+
let current = '';
|
|
150
|
+
for (let i = 0; i < raw.length; i++) {
|
|
151
|
+
if (raw[i] === '\\' && i + 1 < raw.length) {
|
|
152
|
+
current += raw[i]! + raw[i + 1]!;
|
|
153
|
+
i++;
|
|
154
|
+
} else if (raw[i] === ',') {
|
|
155
|
+
parts.push(current);
|
|
156
|
+
current = '';
|
|
157
|
+
} else {
|
|
158
|
+
current += raw[i]!;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
parts.push(current);
|
|
162
|
+
return parts;
|
|
163
|
+
}
|
package/src/timezone.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTIMEZONE, DAYLIGHT, and STANDARD components (RFC 5545 §3.6.5)
|
|
3
|
+
*
|
|
4
|
+
* VTIMEZONE contains one or more DAYLIGHT or STANDARD sub-components that
|
|
5
|
+
* define the UTC offset rules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Component } from './component.js';
|
|
9
|
+
import { parseProperty } from './property.js';
|
|
10
|
+
import type { ICalDateTime, ICalUtcOffset, ICalRecur } from './types.js';
|
|
11
|
+
|
|
12
|
+
// ── STANDARD / DAYLIGHT ──────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export class TimezoneRule extends Component {
|
|
15
|
+
constructor(type: 'STANDARD' | 'DAYLIGHT') {
|
|
16
|
+
super(type);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get dtstart(): ICalDateTime | null {
|
|
20
|
+
const v = this.getValue('DTSTART');
|
|
21
|
+
return typeof v === 'object' && v !== null && v.type === 'date-time'
|
|
22
|
+
? (v as ICalDateTime)
|
|
23
|
+
: null;
|
|
24
|
+
}
|
|
25
|
+
set dtstart(v: ICalDateTime) {
|
|
26
|
+
this.setProperty('DTSTART', v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get tzoffsetfrom(): ICalUtcOffset | null {
|
|
30
|
+
const v = this.getValue('TZOFFSETFROM');
|
|
31
|
+
return typeof v === 'object' && v !== null && v.type === 'utc-offset'
|
|
32
|
+
? (v as ICalUtcOffset)
|
|
33
|
+
: null;
|
|
34
|
+
}
|
|
35
|
+
set tzoffsetfrom(v: ICalUtcOffset) {
|
|
36
|
+
this.setProperty('TZOFFSETFROM', v);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get tzoffsetto(): ICalUtcOffset | null {
|
|
40
|
+
const v = this.getValue('TZOFFSETTO');
|
|
41
|
+
return typeof v === 'object' && v !== null && v.type === 'utc-offset'
|
|
42
|
+
? (v as ICalUtcOffset)
|
|
43
|
+
: null;
|
|
44
|
+
}
|
|
45
|
+
set tzoffsetto(v: ICalUtcOffset) {
|
|
46
|
+
this.setProperty('TZOFFSETTO', v);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get tzname(): string | null {
|
|
50
|
+
return this.getProperty('TZNAME')?.text ?? null;
|
|
51
|
+
}
|
|
52
|
+
set tzname(v: string) {
|
|
53
|
+
this.setProperty('TZNAME', v);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get rrule(): ICalRecur | null {
|
|
57
|
+
const v = this.getValue('RRULE');
|
|
58
|
+
return typeof v === 'object' && v !== null && v.type === 'recur' ? (v as ICalRecur) : null;
|
|
59
|
+
}
|
|
60
|
+
set rrule(v: ICalRecur) {
|
|
61
|
+
this.setProperty('RRULE', v);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Strict validation ────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
override toString(): string {
|
|
67
|
+
if (!this.dtstart) throw new Error(`${this.type}: DTSTART is required`);
|
|
68
|
+
if (!this.tzoffsetfrom) throw new Error(`${this.type}: TZOFFSETFROM is required`);
|
|
69
|
+
if (!this.tzoffsetto) throw new Error(`${this.type}: TZOFFSETTO is required`);
|
|
70
|
+
return super.toString();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class Standard extends TimezoneRule {
|
|
75
|
+
constructor() { super('STANDARD'); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class Daylight extends TimezoneRule {
|
|
79
|
+
constructor() { super('DAYLIGHT'); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── VTIMEZONE ─────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export class Timezone extends Component {
|
|
85
|
+
constructor() {
|
|
86
|
+
super('VTIMEZONE');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** TZID is required (RFC 5545 §3.6.5). */
|
|
90
|
+
get tzid(): string | null {
|
|
91
|
+
return this.getProperty('TZID')?.text ?? null;
|
|
92
|
+
}
|
|
93
|
+
set tzid(v: string) {
|
|
94
|
+
this.setProperty('TZID', v);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get tzurl(): string | null {
|
|
98
|
+
return this.getProperty('TZURL')?.text ?? null;
|
|
99
|
+
}
|
|
100
|
+
set tzurl(v: string) {
|
|
101
|
+
this.setProperty('TZURL', v);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get lastModified(): ICalDateTime | null {
|
|
105
|
+
const v = this.getValue('LAST-MODIFIED');
|
|
106
|
+
return typeof v === 'object' && v !== null && v.type === 'date-time'
|
|
107
|
+
? (v as ICalDateTime)
|
|
108
|
+
: null;
|
|
109
|
+
}
|
|
110
|
+
set lastModified(v: ICalDateTime) {
|
|
111
|
+
this.setProperty('LAST-MODIFIED', v);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get standardRules(): TimezoneRule[] {
|
|
115
|
+
return this.getComponents('STANDARD') as TimezoneRule[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get daylightRules(): TimezoneRule[] {
|
|
119
|
+
return this.getComponents('DAYLIGHT') as TimezoneRule[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
addStandard(rule: Standard): this {
|
|
123
|
+
this.addComponent(rule);
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
addDaylight(rule: Daylight): this {
|
|
128
|
+
this.addComponent(rule);
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Strict validation ────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
override toString(): string {
|
|
135
|
+
if (!this.tzid) throw new Error('VTIMEZONE: TZID is required');
|
|
136
|
+
if (this.components.length === 0) {
|
|
137
|
+
throw new Error('VTIMEZONE: at least one STANDARD or DAYLIGHT sub-component is required');
|
|
138
|
+
}
|
|
139
|
+
return super.toString();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Factory ──────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
static fromRaw(
|
|
145
|
+
props: ReadonlyArray<{ name: string; params: Record<string, string>; value: string }>,
|
|
146
|
+
subcomponents: Component[],
|
|
147
|
+
): Timezone {
|
|
148
|
+
const tz = new Timezone();
|
|
149
|
+
for (const { name, params, value } of props) {
|
|
150
|
+
tz.addProperty(parseProperty(name, value, params));
|
|
151
|
+
}
|
|
152
|
+
for (const sub of subcomponents) {
|
|
153
|
+
tz.addComponent(sub);
|
|
154
|
+
}
|
|
155
|
+
return tz;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Build a TimezoneRule (STANDARD or DAYLIGHT) from raw parsed data. */
|
|
160
|
+
export function buildTimezoneRule(
|
|
161
|
+
type: string,
|
|
162
|
+
props: ReadonlyArray<{ name: string; params: Record<string, string>; value: string }>,
|
|
163
|
+
): TimezoneRule {
|
|
164
|
+
const rule = type.toUpperCase() === 'DAYLIGHT' ? new Daylight() : new Standard();
|
|
165
|
+
for (const { name, params, value } of props) {
|
|
166
|
+
rule.addProperty(parseProperty(name, value, params));
|
|
167
|
+
}
|
|
168
|
+
return rule;
|
|
169
|
+
}
|