@pellux/goodvibes-tui 0.24.0 → 0.25.0
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/CHANGELOG.md +15 -0
- package/README.md +4 -5
- package/docs/foundation-artifacts/operator-contract.json +304 -230
- package/package.json +2 -2
- package/src/daemon/calendar/caldav-client.ts +657 -0
- package/src/daemon/calendar/ics.ts +556 -0
- package/src/daemon/calendar/index.ts +52 -0
- package/src/daemon/calendar/register.ts +527 -0
- package/src/daemon/channels/drafts/draft-store.ts +363 -0
- package/src/daemon/channels/drafts/index.ts +22 -0
- package/src/daemon/channels/drafts/register.ts +449 -0
- package/src/daemon/channels/inbox/cursor-store.ts +298 -0
- package/src/daemon/channels/inbox/index.ts +58 -0
- package/src/daemon/channels/inbox/mapping.ts +190 -0
- package/src/daemon/channels/inbox/poller.ts +155 -0
- package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
- package/src/daemon/channels/inbox/providers/discord.ts +253 -0
- package/src/daemon/channels/inbox/providers/email.ts +151 -0
- package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
- package/src/daemon/channels/inbox/providers/slack.ts +264 -0
- package/src/daemon/channels/inbox/register.ts +247 -0
- package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
- package/src/daemon/channels/routing/index.ts +39 -0
- package/src/daemon/channels/routing/register.ts +296 -0
- package/src/daemon/channels/routing/route-store.ts +278 -0
- package/src/daemon/channels/routing/routing-resolver.ts +75 -0
- package/src/daemon/email/imap-connector.ts +441 -0
- package/src/daemon/email/imap-parsing.ts +499 -0
- package/src/daemon/email/index.ts +68 -0
- package/src/daemon/email/register.ts +715 -0
- package/src/daemon/email/smtp-connector.ts +557 -0
- package/src/daemon/operator/credential-store.ts +129 -0
- package/src/daemon/operator/index.ts +43 -0
- package/src/daemon/operator/register-helper.ts +150 -0
- package/src/daemon/operator/sqlite-store.ts +124 -0
- package/src/daemon/operator/surfaces.ts +137 -0
- package/src/daemon/operator/types.ts +207 -0
- package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
- package/src/daemon/remote/backends/docker.ts +80 -0
- package/src/daemon/remote/backends/index.ts +34 -0
- package/src/daemon/remote/backends/local-process.ts +113 -0
- package/src/daemon/remote/backends/process-runner.ts +151 -0
- package/src/daemon/remote/backends/ssh.ts +120 -0
- package/src/daemon/remote/backends/types.ts +71 -0
- package/src/daemon/remote/dispatcher.ts +160 -0
- package/src/daemon/remote/index.ts +74 -0
- package/src/daemon/remote/peer-registry.ts +321 -0
- package/src/daemon/remote/register.ts +411 -0
- package/src/daemon/triage/index.ts +59 -0
- package/src/daemon/triage/integration.ts +179 -0
- package/src/daemon/triage/pipeline.ts +285 -0
- package/src/daemon/triage/register.ts +231 -0
- package/src/daemon/triage/scorer.ts +287 -0
- package/src/daemon/triage/tagger.ts +777 -0
- package/src/runtime/services.ts +35 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// RFC 5545 (iCalendar) parser + generator for the CalDAV calendar surface.
|
|
2
|
+
//
|
|
3
|
+
// This module is pure (no network, no secrets). It is used by the CalDAV client
|
|
4
|
+
// to serialise events for PUT and to deserialise REPORT/GET responses, and by
|
|
5
|
+
// the operator methods for `calendar.ics.import` / `calendar.ics.export`.
|
|
6
|
+
//
|
|
7
|
+
// The implementation targets the VEVENT subset that calendar connectors need:
|
|
8
|
+
// SUMMARY, DTSTART, DTEND, DESCRIPTION, LOCATION, UID, ATTENDEE, ORGANIZER,
|
|
9
|
+
// STATUS, RRULE, plus all-day (VALUE=DATE) detection. Unknown properties are
|
|
10
|
+
// preserved on parse via the `raw` map so round-tripping does not lose data.
|
|
11
|
+
|
|
12
|
+
export interface ICalAttendee {
|
|
13
|
+
/** Display name only (CN parameter, or local-part of the address). */
|
|
14
|
+
displayName: string;
|
|
15
|
+
/** Raw value retained for internal generation only — never surfaced to callers. */
|
|
16
|
+
rawValue: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ParsedICalEvent {
|
|
20
|
+
uid: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
location?: string;
|
|
24
|
+
/** ISO-8601 start. */
|
|
25
|
+
start: string;
|
|
26
|
+
/** ISO-8601 end. */
|
|
27
|
+
end: string;
|
|
28
|
+
allDay: boolean;
|
|
29
|
+
status?: string;
|
|
30
|
+
/** Raw RRULE value (e.g. "FREQ=WEEKLY;COUNT=10") when present. */
|
|
31
|
+
recurrence?: string;
|
|
32
|
+
attendees: ICalAttendee[];
|
|
33
|
+
/** Organizer display name only (CN or local-part). */
|
|
34
|
+
organizer?: string;
|
|
35
|
+
/** Raw organizer value for internal use only. */
|
|
36
|
+
organizerRaw?: string;
|
|
37
|
+
/** Any properties not explicitly modelled, keyed by upper-case name. */
|
|
38
|
+
raw: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GenerateICalInput {
|
|
42
|
+
uid: string;
|
|
43
|
+
summary: string;
|
|
44
|
+
start: string; // ISO-8601
|
|
45
|
+
end: string; // ISO-8601
|
|
46
|
+
description?: string;
|
|
47
|
+
location?: string;
|
|
48
|
+
attendees?: string[]; // raw addresses or display names
|
|
49
|
+
organizer?: string;
|
|
50
|
+
status?: string;
|
|
51
|
+
recurrence?: string;
|
|
52
|
+
allDay?: boolean;
|
|
53
|
+
/** Stamp time (ISO-8601); defaults to now. */
|
|
54
|
+
dtStamp?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const PRODID = '-//GoodVibes//CalDAV Connector//EN';
|
|
58
|
+
const CRLF = '\r\n';
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Line folding / unfolding (RFC 5545 section 3.1)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const UTF8 = new TextEncoder();
|
|
65
|
+
|
|
66
|
+
/** Number of octets a string occupies when encoded as UTF-8. */
|
|
67
|
+
function octetLength(value: string): number {
|
|
68
|
+
return UTF8.encode(value).length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fold a content line to <=75 octets per line (RFC 5545 §3.1), with continuation
|
|
73
|
+
* lines prefixed by a single space.
|
|
74
|
+
*
|
|
75
|
+
* Folding is measured in UTF-8 OCTETS, not JS string length (UTF-16 code units),
|
|
76
|
+
* and a fold boundary is never placed in the middle of a multi-byte codepoint.
|
|
77
|
+
* Iterating by Unicode codepoint (via the string iterator, which yields whole
|
|
78
|
+
* codepoints rather than surrogate halves) guarantees the wire bytes stay valid
|
|
79
|
+
* UTF-8 even for non-ASCII SUMMARY/DESCRIPTION/LOCATION content.
|
|
80
|
+
*/
|
|
81
|
+
export function foldLine(line: string): string {
|
|
82
|
+
if (octetLength(line) <= 75) return line;
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
let chunk = '';
|
|
85
|
+
let chunkOctets = 0;
|
|
86
|
+
// First line budget is 75 octets; continuation lines reserve 1 octet for the
|
|
87
|
+
// leading space, leaving 74 octets of payload.
|
|
88
|
+
let budget = 75;
|
|
89
|
+
for (const cp of line) {
|
|
90
|
+
const cpOctets = octetLength(cp);
|
|
91
|
+
if (chunkOctets + cpOctets > budget) {
|
|
92
|
+
parts.push(chunk);
|
|
93
|
+
chunk = cp;
|
|
94
|
+
chunkOctets = cpOctets;
|
|
95
|
+
budget = 74;
|
|
96
|
+
} else {
|
|
97
|
+
chunk += cp;
|
|
98
|
+
chunkOctets += cpOctets;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (chunk.length > 0 || parts.length === 0) parts.push(chunk);
|
|
102
|
+
return parts.map((part, index) => (index === 0 ? part : ` ${part}`)).join(CRLF);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Unfold folded content lines: join continuation lines (those starting with space/tab). */
|
|
106
|
+
export function unfoldLines(content: string): string[] {
|
|
107
|
+
const rawLines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
for (const rawLine of rawLines) {
|
|
110
|
+
if ((rawLine.startsWith(' ') || rawLine.startsWith('\t')) && lines.length > 0) {
|
|
111
|
+
lines[lines.length - 1] += rawLine.slice(1);
|
|
112
|
+
} else {
|
|
113
|
+
lines.push(rawLine);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return lines.filter((line) => line.length > 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Text escaping (RFC 5545 section 3.3.11)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
export function escapeText(value: string): string {
|
|
124
|
+
return value
|
|
125
|
+
.replace(/\\/g, '\\\\')
|
|
126
|
+
.replace(/;/g, '\\;')
|
|
127
|
+
.replace(/,/g, '\\,')
|
|
128
|
+
.replace(/\r\n|\n|\r/g, '\\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function unescapeText(value: string): string {
|
|
132
|
+
let result = '';
|
|
133
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
134
|
+
const ch = value[i];
|
|
135
|
+
if (ch === '\\' && i + 1 < value.length) {
|
|
136
|
+
const next = value[i + 1];
|
|
137
|
+
if (next === 'n' || next === 'N') {
|
|
138
|
+
result += '\n';
|
|
139
|
+
} else if (next === '\\' || next === ';' || next === ',') {
|
|
140
|
+
result += next;
|
|
141
|
+
} else {
|
|
142
|
+
// Unrecognised escape sequence (not one of \\ \; \, \n \N): RFC 5545
|
|
143
|
+
// defines no such sequence, so the backslash is a literal character.
|
|
144
|
+
// Preserve BOTH the backslash and the following char so values that
|
|
145
|
+
// happen to contain a literal backslash round-trip losslessly.
|
|
146
|
+
result += ch;
|
|
147
|
+
result += next;
|
|
148
|
+
}
|
|
149
|
+
i += 1;
|
|
150
|
+
} else {
|
|
151
|
+
result += ch;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Date/time formatting (RFC 5545 section 3.3.5)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
function pad2(value: number): string {
|
|
162
|
+
return value < 10 ? `0${value}` : String(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Format an ISO-8601 datetime as a UTC iCalendar timestamp: YYYYMMDDTHHMMSSZ. */
|
|
166
|
+
export function formatICalDateTime(iso: string): string {
|
|
167
|
+
const date = new Date(iso);
|
|
168
|
+
if (Number.isNaN(date.getTime())) {
|
|
169
|
+
throw new Error(`Invalid date value: ${iso}`);
|
|
170
|
+
}
|
|
171
|
+
return (
|
|
172
|
+
`${date.getUTCFullYear()}`
|
|
173
|
+
+ `${pad2(date.getUTCMonth() + 1)}`
|
|
174
|
+
+ `${pad2(date.getUTCDate())}`
|
|
175
|
+
+ 'T'
|
|
176
|
+
+ `${pad2(date.getUTCHours())}`
|
|
177
|
+
+ `${pad2(date.getUTCMinutes())}`
|
|
178
|
+
+ `${pad2(date.getUTCSeconds())}`
|
|
179
|
+
+ 'Z'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Format an ISO-8601 datetime as an all-day iCalendar DATE: YYYYMMDD.
|
|
185
|
+
*
|
|
186
|
+
* An all-day DATE is a floating calendar day with no timezone (RFC 5545 §3.3.4),
|
|
187
|
+
* so it must reflect the calendar day as written in the INPUT's own offset, not
|
|
188
|
+
* the UTC instant. Deriving YYYYMMDD from UTC components would roll an input such
|
|
189
|
+
* as `2026-12-25T00:00:00+09:00` back to the 24th, corrupting the DATE. We read
|
|
190
|
+
* the wall-clock date components directly from the ISO string's leading
|
|
191
|
+
* `YYYY-MM-DD` (which, for any offset, IS that offset's calendar day) and fall
|
|
192
|
+
* back to UTC components only for non-extended forms that omit a date prefix.
|
|
193
|
+
*/
|
|
194
|
+
export function formatICalDate(iso: string): string {
|
|
195
|
+
const date = new Date(iso);
|
|
196
|
+
if (Number.isNaN(date.getTime())) {
|
|
197
|
+
throw new Error(`Invalid date value: ${iso}`);
|
|
198
|
+
}
|
|
199
|
+
const wallDate = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso.trim());
|
|
200
|
+
if (wallDate) {
|
|
201
|
+
const [, y, m, d] = wallDate;
|
|
202
|
+
return `${y}${m}${d}`;
|
|
203
|
+
}
|
|
204
|
+
return (
|
|
205
|
+
`${date.getUTCFullYear()}`
|
|
206
|
+
+ `${pad2(date.getUTCMonth() + 1)}`
|
|
207
|
+
+ `${pad2(date.getUTCDate())}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse an iCalendar date/date-time value into an ISO-8601 string.
|
|
213
|
+
* Supports: YYYYMMDD (DATE), YYYYMMDDTHHMMSSZ (UTC), YYYYMMDDTHHMMSS (floating/local).
|
|
214
|
+
*/
|
|
215
|
+
export function parseICalDate(value: string): { iso: string; allDay: boolean } {
|
|
216
|
+
const trimmed = value.trim();
|
|
217
|
+
const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(trimmed);
|
|
218
|
+
if (dateOnly) {
|
|
219
|
+
const [, y, m, d] = dateOnly;
|
|
220
|
+
const iso = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d), 0, 0, 0)).toISOString();
|
|
221
|
+
return { iso, allDay: true };
|
|
222
|
+
}
|
|
223
|
+
const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(trimmed);
|
|
224
|
+
if (dateTime) {
|
|
225
|
+
const [, y, m, d, hh, mm, ss, zulu] = dateTime;
|
|
226
|
+
// A trailing 'Z' marks an absolute UTC instant. Without it the value is a
|
|
227
|
+
// floating/local time (RFC 5545 §3.3.5): it has no offset and denotes the
|
|
228
|
+
// wall-clock time in the observer's own zone. Interpreting it via Date.UTC
|
|
229
|
+
// would relabel that wall-clock as UTC and shift the resulting ISO instant
|
|
230
|
+
// by the local offset on round-trip; we instead build it through the local
|
|
231
|
+
// Date constructor so the ISO reflects the same wall-clock in local time.
|
|
232
|
+
const date = zulu
|
|
233
|
+
? new Date(Date.UTC(Number(y), Number(m) - 1, Number(d), Number(hh), Number(mm), Number(ss)))
|
|
234
|
+
: new Date(Number(y), Number(m) - 1, Number(d), Number(hh), Number(mm), Number(ss));
|
|
235
|
+
return { iso: date.toISOString(), allDay: false };
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`Unrecognised iCalendar date value: ${value}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Property line parsing
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
interface ContentLine {
|
|
245
|
+
name: string;
|
|
246
|
+
params: Record<string, string>;
|
|
247
|
+
value: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseContentLine(line: string): ContentLine {
|
|
251
|
+
// Split name(+params) from value at the first unquoted colon.
|
|
252
|
+
let colonIndex = -1;
|
|
253
|
+
let inQuotes = false;
|
|
254
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
255
|
+
const ch = line[i];
|
|
256
|
+
if (ch === '"') inQuotes = !inQuotes;
|
|
257
|
+
else if (ch === ':' && !inQuotes) {
|
|
258
|
+
colonIndex = i;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (colonIndex === -1) {
|
|
263
|
+
return { name: line.toUpperCase(), params: {}, value: '' };
|
|
264
|
+
}
|
|
265
|
+
const namePart = line.slice(0, colonIndex);
|
|
266
|
+
const value = line.slice(colonIndex + 1);
|
|
267
|
+
const segments = splitParams(namePart);
|
|
268
|
+
const name = (segments.shift() ?? '').toUpperCase();
|
|
269
|
+
const params: Record<string, string> = {};
|
|
270
|
+
for (const segment of segments) {
|
|
271
|
+
const eq = segment.indexOf('=');
|
|
272
|
+
if (eq === -1) continue;
|
|
273
|
+
const key = segment.slice(0, eq).toUpperCase();
|
|
274
|
+
let paramValue = segment.slice(eq + 1);
|
|
275
|
+
if (paramValue.startsWith('"') && paramValue.endsWith('"')) {
|
|
276
|
+
paramValue = paramValue.slice(1, -1);
|
|
277
|
+
}
|
|
278
|
+
params[key] = paramValue;
|
|
279
|
+
}
|
|
280
|
+
return { name, params, value };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Split a "NAME;PARAM=foo;OTHER=\"a;b\"" prefix on unquoted semicolons. */
|
|
284
|
+
function splitParams(input: string): string[] {
|
|
285
|
+
const result: string[] = [];
|
|
286
|
+
let current = '';
|
|
287
|
+
let inQuotes = false;
|
|
288
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
289
|
+
const ch = input[i];
|
|
290
|
+
if (ch === '"') {
|
|
291
|
+
inQuotes = !inQuotes;
|
|
292
|
+
current += ch;
|
|
293
|
+
} else if (ch === ';' && !inQuotes) {
|
|
294
|
+
result.push(current);
|
|
295
|
+
current = '';
|
|
296
|
+
} else {
|
|
297
|
+
current += ch;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (current.length > 0) result.push(current);
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Extract a display name from an ATTENDEE/ORGANIZER value + params. */
|
|
305
|
+
export function attendeeDisplayName(params: Record<string, string>, value: string): string {
|
|
306
|
+
const cn = params.CN;
|
|
307
|
+
if (cn && cn.trim().length > 0) return cn.trim();
|
|
308
|
+
const normalized = value.trim();
|
|
309
|
+
const withoutScheme = normalized.replace(/^mailto:/i, '');
|
|
310
|
+
const atIndex = withoutScheme.indexOf('@');
|
|
311
|
+
if (atIndex > 0) return withoutScheme.slice(0, atIndex);
|
|
312
|
+
return withoutScheme;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Parser
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Parse a full .ics document and return all VEVENT components. Multiple VEVENTs
|
|
321
|
+
* (e.g. a VCALENDAR with recurring instances) are all returned.
|
|
322
|
+
*/
|
|
323
|
+
/**
|
|
324
|
+
* Internal accumulator used while parsing a single VEVENT. Carries the VALUE
|
|
325
|
+
* type observed on DTSTART / DTEND so finaliseEvent can enforce agreement
|
|
326
|
+
* (RFC 5545 §3.8.2.2: DTEND must have the same VALUE type as DTSTART).
|
|
327
|
+
*/
|
|
328
|
+
type ParsingEvent = Partial<ParsedICalEvent> & {
|
|
329
|
+
raw: Record<string, string>;
|
|
330
|
+
attendees?: ICalAttendee[];
|
|
331
|
+
startIsDate?: boolean;
|
|
332
|
+
endIsDate?: boolean;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export function parseICS(content: string): ParsedICalEvent[] {
|
|
336
|
+
const lines = unfoldLines(content);
|
|
337
|
+
const events: ParsedICalEvent[] = [];
|
|
338
|
+
let current: ParsingEvent | null = null;
|
|
339
|
+
|
|
340
|
+
for (const line of lines) {
|
|
341
|
+
const upper = line.toUpperCase();
|
|
342
|
+
if (upper === 'BEGIN:VEVENT') {
|
|
343
|
+
current = { attendees: [], raw: {} };
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (upper === 'END:VEVENT') {
|
|
347
|
+
if (current) {
|
|
348
|
+
events.push(finaliseEvent(current));
|
|
349
|
+
current = null;
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (!current) continue;
|
|
354
|
+
|
|
355
|
+
const parsed = parseContentLine(line);
|
|
356
|
+
applyProperty(current, parsed);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return events;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function applyProperty(
|
|
363
|
+
target: ParsingEvent,
|
|
364
|
+
line: ContentLine,
|
|
365
|
+
): void {
|
|
366
|
+
switch (line.name) {
|
|
367
|
+
case 'UID':
|
|
368
|
+
target.uid = line.value.trim();
|
|
369
|
+
break;
|
|
370
|
+
case 'SUMMARY':
|
|
371
|
+
target.summary = unescapeText(line.value);
|
|
372
|
+
break;
|
|
373
|
+
case 'DESCRIPTION':
|
|
374
|
+
target.description = unescapeText(line.value);
|
|
375
|
+
break;
|
|
376
|
+
case 'LOCATION':
|
|
377
|
+
target.location = unescapeText(line.value);
|
|
378
|
+
break;
|
|
379
|
+
case 'STATUS':
|
|
380
|
+
target.status = line.value.trim().toLowerCase();
|
|
381
|
+
break;
|
|
382
|
+
case 'RRULE':
|
|
383
|
+
target.recurrence = line.value.trim();
|
|
384
|
+
break;
|
|
385
|
+
case 'DTSTART': {
|
|
386
|
+
const { iso, allDay } = parseICalDate(line.value);
|
|
387
|
+
// A value is DATE-typed when explicitly tagged VALUE=DATE or when the
|
|
388
|
+
// serialised form carries no time component (parseICalDate -> allDay).
|
|
389
|
+
const isDate = line.params.VALUE === 'DATE' || allDay;
|
|
390
|
+
target.start = iso;
|
|
391
|
+
target.startIsDate = isDate;
|
|
392
|
+
target.allDay = isDate;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
case 'DTEND': {
|
|
396
|
+
const { iso, allDay } = parseICalDate(line.value);
|
|
397
|
+
const isDate = line.params.VALUE === 'DATE' || allDay;
|
|
398
|
+
target.end = iso;
|
|
399
|
+
target.endIsDate = isDate;
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
case 'ATTENDEE': {
|
|
403
|
+
(target.attendees ??= []).push({
|
|
404
|
+
displayName: attendeeDisplayName(line.params, line.value),
|
|
405
|
+
rawValue: line.value.trim(),
|
|
406
|
+
});
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case 'ORGANIZER':
|
|
410
|
+
target.organizer = attendeeDisplayName(line.params, line.value);
|
|
411
|
+
target.organizerRaw = line.value.trim();
|
|
412
|
+
break;
|
|
413
|
+
default:
|
|
414
|
+
target.raw[line.name] = line.value;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function finaliseEvent(
|
|
420
|
+
partial: ParsingEvent,
|
|
421
|
+
): ParsedICalEvent {
|
|
422
|
+
// RFC 5545 §3.8.2.2: when DTEND is present it MUST share the VALUE type of
|
|
423
|
+
// DTSTART. A DATE-valued DTSTART paired with a DATE-TIME DTEND (or vice
|
|
424
|
+
// versa) is malformed; accepting it silently would corrupt all-day handling.
|
|
425
|
+
if (
|
|
426
|
+
partial.startIsDate !== undefined
|
|
427
|
+
&& partial.endIsDate !== undefined
|
|
428
|
+
&& partial.startIsDate !== partial.endIsDate
|
|
429
|
+
) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
'VEVENT DTSTART and DTEND have mismatched VALUE types (one DATE, one DATE-TIME).',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const start = partial.start ?? new Date(0).toISOString();
|
|
435
|
+
const end = partial.end ?? start;
|
|
436
|
+
return {
|
|
437
|
+
uid: partial.uid ?? '',
|
|
438
|
+
summary: partial.summary ?? '',
|
|
439
|
+
description: partial.description,
|
|
440
|
+
location: partial.location,
|
|
441
|
+
start,
|
|
442
|
+
end,
|
|
443
|
+
allDay: partial.allDay ?? false,
|
|
444
|
+
status: partial.status,
|
|
445
|
+
recurrence: partial.recurrence,
|
|
446
|
+
attendees: partial.attendees ?? [],
|
|
447
|
+
organizer: partial.organizer,
|
|
448
|
+
organizerRaw: partial.organizerRaw,
|
|
449
|
+
raw: partial.raw,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Generator
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
function normaliseAttendeeForOutput(entry: string): { value: string; cn?: string } {
|
|
458
|
+
const trimmed = entry.trim();
|
|
459
|
+
if (/@/.test(trimmed) && !/^mailto:/i.test(trimmed)) {
|
|
460
|
+
return { value: `mailto:${trimmed}` };
|
|
461
|
+
}
|
|
462
|
+
if (/^mailto:/i.test(trimmed)) {
|
|
463
|
+
return { value: trimmed };
|
|
464
|
+
}
|
|
465
|
+
// A bare display name (no address): encode as CN with an empty mailto target.
|
|
466
|
+
return { value: 'mailto:invalid@invalid', cn: trimmed };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Render a CN parameter for an ATTENDEE/ORGANIZER line. RFC 5545 §3.2 requires a
|
|
471
|
+
* param value containing COLON, SEMICOLON, or COMMA to be DQUOTE-quoted: without
|
|
472
|
+
* quoting, the parser splits params on the unquoted ';' and ends the value at the
|
|
473
|
+
* first unquoted ':', mis-parsing a display name like 'Doe, Jane' or 'Team: Eng'.
|
|
474
|
+
* DQUOTE itself may not appear inside a quoted param value (§3.2), so any embedded
|
|
475
|
+
* DQUOTE is dropped to keep the emitted line well-formed.
|
|
476
|
+
*/
|
|
477
|
+
function formatCNParam(cn: string): string {
|
|
478
|
+
const sanitised = cn.replace(/"/g, '');
|
|
479
|
+
const needsQuoting = /[:;,]/.test(sanitised);
|
|
480
|
+
return `;CN=${needsQuoting ? `"${sanitised}"` : sanitised}`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Generate a single VEVENT line block (without BEGIN/END VCALENDAR). */
|
|
484
|
+
export function eventToVEvent(input: GenerateICalInput): string[] {
|
|
485
|
+
const lines: string[] = [];
|
|
486
|
+
lines.push('BEGIN:VEVENT');
|
|
487
|
+
lines.push(`UID:${input.uid}`);
|
|
488
|
+
lines.push(`DTSTAMP:${formatICalDateTime(input.dtStamp ?? new Date().toISOString())}`);
|
|
489
|
+
if (input.allDay) {
|
|
490
|
+
lines.push(`DTSTART;VALUE=DATE:${formatICalDate(input.start)}`);
|
|
491
|
+
lines.push(`DTEND;VALUE=DATE:${formatICalDate(input.end)}`);
|
|
492
|
+
} else {
|
|
493
|
+
lines.push(`DTSTART:${formatICalDateTime(input.start)}`);
|
|
494
|
+
lines.push(`DTEND:${formatICalDateTime(input.end)}`);
|
|
495
|
+
}
|
|
496
|
+
lines.push(`SUMMARY:${escapeText(input.summary)}`);
|
|
497
|
+
if (input.description !== undefined && input.description.length > 0) {
|
|
498
|
+
lines.push(`DESCRIPTION:${escapeText(input.description)}`);
|
|
499
|
+
}
|
|
500
|
+
if (input.location !== undefined && input.location.length > 0) {
|
|
501
|
+
lines.push(`LOCATION:${escapeText(input.location)}`);
|
|
502
|
+
}
|
|
503
|
+
if (input.status !== undefined && input.status.length > 0) {
|
|
504
|
+
lines.push(`STATUS:${input.status.toUpperCase()}`);
|
|
505
|
+
}
|
|
506
|
+
if (input.recurrence !== undefined && input.recurrence.length > 0) {
|
|
507
|
+
lines.push(`RRULE:${input.recurrence}`);
|
|
508
|
+
}
|
|
509
|
+
if (input.organizer !== undefined && input.organizer.length > 0) {
|
|
510
|
+
const org = normaliseAttendeeForOutput(input.organizer);
|
|
511
|
+
lines.push(`ORGANIZER${org.cn ? formatCNParam(org.cn) : ''}:${org.value}`);
|
|
512
|
+
}
|
|
513
|
+
for (const attendee of input.attendees ?? []) {
|
|
514
|
+
if (attendee.trim().length === 0) continue;
|
|
515
|
+
const norm = normaliseAttendeeForOutput(attendee);
|
|
516
|
+
lines.push(`ATTENDEE${norm.cn ? formatCNParam(norm.cn) : ''}:${norm.value}`);
|
|
517
|
+
}
|
|
518
|
+
lines.push('END:VEVENT');
|
|
519
|
+
return lines;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Generate a full VCALENDAR wrapping one event. Output uses CRLF line endings. */
|
|
523
|
+
export function generateICS(input: GenerateICalInput): string {
|
|
524
|
+
const lines: string[] = [];
|
|
525
|
+
lines.push('BEGIN:VCALENDAR');
|
|
526
|
+
lines.push('VERSION:2.0');
|
|
527
|
+
lines.push(`PRODID:${PRODID}`);
|
|
528
|
+
lines.push('CALSCALE:GREGORIAN');
|
|
529
|
+
lines.push(...eventToVEvent(input));
|
|
530
|
+
lines.push('END:VCALENDAR');
|
|
531
|
+
return `${lines.map(foldLine).join(CRLF)}${CRLF}`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Generate a full VCALENDAR wrapping many events (used by export). */
|
|
535
|
+
export function generateCalendar(events: GenerateICalInput[]): string {
|
|
536
|
+
const lines: string[] = [];
|
|
537
|
+
lines.push('BEGIN:VCALENDAR');
|
|
538
|
+
lines.push('VERSION:2.0');
|
|
539
|
+
lines.push(`PRODID:${PRODID}`);
|
|
540
|
+
lines.push('CALSCALE:GREGORIAN');
|
|
541
|
+
for (const event of events) {
|
|
542
|
+
lines.push(...eventToVEvent(event));
|
|
543
|
+
}
|
|
544
|
+
lines.push('END:VCALENDAR');
|
|
545
|
+
return `${lines.map(foldLine).join(CRLF)}${CRLF}`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Extract the first UID from a raw .ics document, if present. */
|
|
549
|
+
export function extractUid(content: string): string | undefined {
|
|
550
|
+
for (const line of unfoldLines(content)) {
|
|
551
|
+
if (line.toUpperCase().startsWith('UID:')) {
|
|
552
|
+
return line.slice(line.indexOf(':') + 1).trim();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Public surface barrel for the CalDAV calendar connector.
|
|
2
|
+
//
|
|
3
|
+
// Integration registers the surface by calling registerCalendarMethods(ctx).
|
|
4
|
+
// All other exports are types / helpers for consumers and tests.
|
|
5
|
+
|
|
6
|
+
export { registerCalendarMethods, CALENDAR_METHOD_IDS } from './register.ts';
|
|
7
|
+
export type {
|
|
8
|
+
RegisterCalendarOptions,
|
|
9
|
+
CalDavClientFactory,
|
|
10
|
+
CalendarEventDetail,
|
|
11
|
+
CalendarEventSummaryResult,
|
|
12
|
+
} from './register.ts';
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
createCalDavClient,
|
|
16
|
+
resolveCalDavConfig,
|
|
17
|
+
toRelativeHref,
|
|
18
|
+
parseMultiStatus,
|
|
19
|
+
} from './caldav-client.ts';
|
|
20
|
+
export type {
|
|
21
|
+
CalDavClient,
|
|
22
|
+
CalDavConfig,
|
|
23
|
+
CalDavEvent,
|
|
24
|
+
CreateCalDavClientOptions,
|
|
25
|
+
CreateEventInput,
|
|
26
|
+
CreatedEvent,
|
|
27
|
+
ImportResult,
|
|
28
|
+
ExportResult,
|
|
29
|
+
ListEventsOptions,
|
|
30
|
+
FetchLike,
|
|
31
|
+
} from './caldav-client.ts';
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
parseICS,
|
|
35
|
+
generateICS,
|
|
36
|
+
generateCalendar,
|
|
37
|
+
foldLine,
|
|
38
|
+
unfoldLines,
|
|
39
|
+
escapeText,
|
|
40
|
+
unescapeText,
|
|
41
|
+
formatICalDate,
|
|
42
|
+
formatICalDateTime,
|
|
43
|
+
parseICalDate,
|
|
44
|
+
attendeeDisplayName,
|
|
45
|
+
eventToVEvent,
|
|
46
|
+
extractUid,
|
|
47
|
+
} from './ics.ts';
|
|
48
|
+
export type {
|
|
49
|
+
ParsedICalEvent,
|
|
50
|
+
ICalAttendee,
|
|
51
|
+
GenerateICalInput,
|
|
52
|
+
} from './ics.ts';
|