@gmickel/gno 1.27.0 → 1.28.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.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/assets/skill/SKILL.md +26 -1
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +2 -2
  7. package/spec/cli.md +41 -4
  8. package/spec/db/schema.sql +12 -0
  9. package/spec/mcp.md +5 -0
  10. package/spec/output-schemas/ask.schema.json +125 -0
  11. package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
  12. package/spec/output-schemas/get.schema.json +94 -0
  13. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  14. package/spec/output-schemas/multi-get.schema.json +128 -0
  15. package/spec/output-schemas/record-import.schema.json +193 -0
  16. package/spec/output-schemas/search-result.schema.json +94 -0
  17. package/spec/output-schemas/search-results.schema.json +125 -0
  18. package/spec/project-profile.schema.json +66 -0
  19. package/src/app/context-format.ts +1 -1
  20. package/src/cli/commands/get.ts +12 -2
  21. package/src/cli/commands/index-cmd.ts +13 -0
  22. package/src/cli/commands/multi-get.ts +9 -2
  23. package/src/cli/commands/shared.ts +28 -0
  24. package/src/cli/commands/update.ts +5 -0
  25. package/src/cli/program.ts +4 -0
  26. package/src/config/project-profile.ts +2 -0
  27. package/src/config/types.ts +16 -0
  28. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  29. package/src/converters/adapters/browser-export/formats.ts +358 -0
  30. package/src/converters/adapters/email/adapter.ts +429 -0
  31. package/src/converters/adapters/email/html.ts +162 -0
  32. package/src/converters/adapters/email/mime.ts +454 -0
  33. package/src/converters/adapters/email/parameters.ts +77 -0
  34. package/src/converters/adapters/ical/adapter.ts +475 -0
  35. package/src/converters/adapters/ical/recurrence.ts +186 -0
  36. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  37. package/src/converters/adapters/jsonl/config.ts +105 -0
  38. package/src/converters/adapters/shared/html-text.ts +165 -0
  39. package/src/converters/adapters/shared/record-utils.ts +79 -0
  40. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  41. package/src/converters/adapters/transcript/adapter.ts +295 -0
  42. package/src/converters/adapters/transcript/json.ts +184 -0
  43. package/src/converters/adapters/transcript/model.ts +171 -0
  44. package/src/converters/adapters/transcript/text.ts +58 -0
  45. package/src/converters/adapters/transcript/timed.ts +152 -0
  46. package/src/converters/index.ts +11 -1
  47. package/src/converters/mime.ts +8 -0
  48. package/src/converters/pipeline.ts +15 -1
  49. package/src/converters/registry.ts +37 -1
  50. package/src/converters/types.ts +136 -0
  51. package/src/core/context-capsule-schema.ts +87 -0
  52. package/src/core/context-capsule.ts +20 -0
  53. package/src/core/context-evidence.ts +7 -1
  54. package/src/core/document-capabilities.ts +12 -0
  55. package/src/core/project-profile-apply-state.ts +5 -0
  56. package/src/core/project-profile.ts +4 -0
  57. package/src/core/record-metadata.ts +49 -0
  58. package/src/ingestion/record-adapter-canonical.ts +433 -0
  59. package/src/ingestion/record-adapter.ts +437 -0
  60. package/src/ingestion/record-container.ts +688 -0
  61. package/src/ingestion/record-path.ts +20 -0
  62. package/src/ingestion/record-sync.ts +70 -0
  63. package/src/ingestion/sync.ts +228 -36
  64. package/src/ingestion/types.ts +69 -1
  65. package/src/ingestion/walker.ts +31 -11
  66. package/src/mcp/tools/get.ts +10 -2
  67. package/src/mcp/tools/multi-get.ts +9 -2
  68. package/src/mcp/tools/workspace-write.ts +2 -0
  69. package/src/pipeline/filters.ts +1 -1
  70. package/src/pipeline/graph-retrieval.ts +7 -4
  71. package/src/pipeline/hybrid.ts +7 -4
  72. package/src/pipeline/result-context.ts +12 -4
  73. package/src/pipeline/search.ts +11 -4
  74. package/src/pipeline/types.ts +3 -0
  75. package/src/pipeline/vsearch.ts +26 -8
  76. package/src/sdk/documents.ts +13 -5
  77. package/src/serve/browse-tree.ts +4 -2
  78. package/src/serve/routes/api.ts +63 -65
  79. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  80. package/src/store/migrations/index.ts +2 -0
  81. package/src/store/sqlite/adapter.ts +114 -7
  82. package/src/store/types.ts +40 -0
  83. package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +0 -1
@@ -0,0 +1,475 @@
1
+ import type {
2
+ RecordAdapter,
3
+ RecordAdapterEvent,
4
+ RecordAdapterInput,
5
+ RecordAnchor,
6
+ } from "../../types";
7
+
8
+ import { summarizeRecurrence } from "./recurrence";
9
+
10
+ const DATE_PATTERN = /^(\d{4})(\d{2})(\d{2})$/;
11
+ const DATE_TIME_PATTERN = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/;
12
+ const NUMERIC_OFFSET_PATTERN = /[+-]\d{4}$/;
13
+
14
+ interface LogicalLine {
15
+ text: string;
16
+ startLine: number;
17
+ endLine: number;
18
+ }
19
+
20
+ interface IcalProperty {
21
+ name: string;
22
+ params: Map<string, string>;
23
+ value: string;
24
+ }
25
+
26
+ const failure = (
27
+ sourceLocator: string,
28
+ retryable = false
29
+ ): RecordAdapterEvent => ({
30
+ type: "failure",
31
+ failure: {
32
+ code: "MALFORMED_RECORD",
33
+ message: "Malformed iCalendar event.",
34
+ retryable,
35
+ sourceLocator,
36
+ },
37
+ });
38
+
39
+ async function* physicalLines(
40
+ input: RecordAdapterInput
41
+ ): AsyncGenerator<{ text: string; line: number }> {
42
+ const decoder = new TextDecoder("utf-8", { fatal: true });
43
+ let pending = "";
44
+ let line = 0;
45
+ for await (const chunk of input.open()) {
46
+ pending += decoder.decode(chunk, { stream: true });
47
+ while (true) {
48
+ const newline = pending.indexOf("\n");
49
+ if (newline < 0) break;
50
+ line += 1;
51
+ const raw = pending.slice(0, newline);
52
+ pending = pending.slice(newline + 1);
53
+ if (raw.length > input.limits.maxRecordChars) {
54
+ throw new Error("iCalendar physical line exceeded its limit.");
55
+ }
56
+ const withoutCarriageReturn = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
57
+ yield {
58
+ text:
59
+ line === 1 && withoutCarriageReturn.startsWith("\uFEFF")
60
+ ? withoutCarriageReturn.slice(1)
61
+ : withoutCarriageReturn,
62
+ line,
63
+ };
64
+ }
65
+ if (pending.length > input.limits.maxRecordChars) {
66
+ throw new Error("iCalendar physical line exceeded its limit.");
67
+ }
68
+ }
69
+ pending += decoder.decode();
70
+ if (pending.length > 0) {
71
+ if (pending.length > input.limits.maxRecordChars) {
72
+ throw new Error("iCalendar physical line exceeded its limit.");
73
+ }
74
+ line += 1;
75
+ yield {
76
+ text: pending.endsWith("\r") ? pending.slice(0, -1) : pending,
77
+ line,
78
+ };
79
+ }
80
+ }
81
+
82
+ async function* logicalLines(
83
+ input: RecordAdapterInput
84
+ ): AsyncGenerator<LogicalLine> {
85
+ let pending: LogicalLine | undefined;
86
+ for await (const physical of physicalLines(input)) {
87
+ if (/^[ \t]/.test(physical.text) && pending) {
88
+ pending.text += physical.text.slice(1);
89
+ if (pending.text.length > input.limits.maxRecordChars) {
90
+ throw new Error("iCalendar unfolded line exceeded its limit.");
91
+ }
92
+ pending.endLine = physical.line;
93
+ continue;
94
+ }
95
+ if (pending) yield pending;
96
+ pending = {
97
+ text: physical.text,
98
+ startLine: physical.line,
99
+ endLine: physical.line,
100
+ };
101
+ }
102
+ if (pending) yield pending;
103
+ }
104
+
105
+ const parseProperty = (line: string): IcalProperty | undefined => {
106
+ const colon = line.indexOf(":");
107
+ if (colon <= 0) return undefined;
108
+ const [rawName, ...rawParams] = line.slice(0, colon).split(";");
109
+ const name = rawName?.trim().toUpperCase();
110
+ if (!name) return undefined;
111
+ const params = new Map<string, string>();
112
+ for (const rawParam of rawParams) {
113
+ const equals = rawParam.indexOf("=");
114
+ if (equals <= 0) continue;
115
+ const key = rawParam.slice(0, equals).trim().toUpperCase();
116
+ const value = rawParam
117
+ .slice(equals + 1)
118
+ .trim()
119
+ .replace(/^"|"$/g, "");
120
+ params.set(key, value);
121
+ }
122
+ return { name, params, value: line.slice(colon + 1).trim() };
123
+ };
124
+
125
+ const unescapeText = (value: string): string =>
126
+ value
127
+ .replace(/\\n/gi, "\n")
128
+ .replace(/\\,/g, ",")
129
+ .replace(/\\;/g, ";")
130
+ .replace(/\\\\/g, "\\")
131
+ .replaceAll("&", "&amp;")
132
+ .replaceAll("<", "&lt;")
133
+ .replaceAll(">", "&gt;")
134
+ .replace(/([\\`*_[\]#!])/g, "\\$1");
135
+
136
+ const validCalendarDate = (
137
+ year: number,
138
+ month: number,
139
+ day: number,
140
+ hour = 0,
141
+ minute = 0,
142
+ second = 0
143
+ ): boolean => {
144
+ const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
145
+ return (
146
+ date.getUTCFullYear() === year &&
147
+ date.getUTCMonth() === month - 1 &&
148
+ date.getUTCDate() === day &&
149
+ date.getUTCHours() === hour &&
150
+ date.getUTCMinutes() === minute &&
151
+ date.getUTCSeconds() === second
152
+ );
153
+ };
154
+
155
+ const normalizeDate = (property: IcalProperty): string | undefined => {
156
+ if (NUMERIC_OFFSET_PATTERN.test(property.value)) return undefined;
157
+ const dateOnly = DATE_PATTERN.exec(property.value);
158
+ if (dateOnly) {
159
+ const [, year, month, day] = dateOnly;
160
+ if (!validCalendarDate(Number(year), Number(month), Number(day))) {
161
+ return undefined;
162
+ }
163
+ return `${year}-${month}-${day}`;
164
+ }
165
+ const dateTime = DATE_TIME_PATTERN.exec(property.value);
166
+ if (!dateTime) return undefined;
167
+ const [, year, month, day, hour, minute, second, utc] = dateTime;
168
+ if (
169
+ !validCalendarDate(
170
+ Number(year),
171
+ Number(month),
172
+ Number(day),
173
+ Number(hour),
174
+ Number(minute),
175
+ Number(second)
176
+ )
177
+ ) {
178
+ return undefined;
179
+ }
180
+ const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
181
+ if (utc) return `${iso}Z`;
182
+ const timezone = property.params.get("TZID");
183
+ return timezone ? `TZID=${timezone}:${iso}` : iso;
184
+ };
185
+
186
+ const participant = (property: IcalProperty): string => {
187
+ const address = property.value.replace(/^mailto:/i, "");
188
+ const name = property.params.get("CN");
189
+ return unescapeText(name ? `${name} <${address}>` : address);
190
+ };
191
+
192
+ const values = (properties: IcalProperty[], name: string): IcalProperty[] =>
193
+ properties.filter((property) => property.name === name);
194
+
195
+ const first = (
196
+ properties: IcalProperty[],
197
+ name: string
198
+ ): IcalProperty | undefined => values(properties, name)[0];
199
+
200
+ const convertEvent = (
201
+ properties: IcalProperty[],
202
+ startLine: number,
203
+ endLine: number
204
+ ): RecordAdapterEvent | undefined => {
205
+ const uid = first(properties, "UID")?.value.normalize("NFC").trim();
206
+ if (!uid) return undefined;
207
+ const startProperty = first(properties, "DTSTART");
208
+ const endProperty = first(properties, "DTEND");
209
+ const start = startProperty ? normalizeDate(startProperty) : undefined;
210
+ const end = endProperty ? normalizeDate(endProperty) : undefined;
211
+ if ((startProperty && !start) || (endProperty && !end)) return undefined;
212
+ const invalidRecurrenceDate = properties
213
+ .filter((property) =>
214
+ ["RECURRENCE-ID", "RDATE", "EXDATE"].includes(property.name)
215
+ )
216
+ .some((property) =>
217
+ property.value
218
+ .split(",")
219
+ .some((value) => !normalizeDate({ ...property, value }))
220
+ );
221
+ if (invalidRecurrenceDate) return undefined;
222
+ const recurrence = summarizeRecurrence(properties, startProperty?.value);
223
+ const recurrenceIdProperty = first(properties, "RECURRENCE-ID");
224
+ const recurrenceIdentity = recurrenceIdProperty
225
+ ? normalizeDate(recurrenceIdProperty)
226
+ : undefined;
227
+ if (recurrenceIdProperty && !recurrenceIdentity) return undefined;
228
+ const summary = unescapeText(first(properties, "SUMMARY")?.value ?? "Event");
229
+ const description = first(properties, "DESCRIPTION")?.value;
230
+ const location = first(properties, "LOCATION")?.value;
231
+ const organizerProperty = first(properties, "ORGANIZER");
232
+ const createdProperty = first(properties, "CREATED");
233
+ const updatedProperty = first(properties, "LAST-MODIFIED");
234
+ const attendees = values(properties, "ATTENDEE").map(participant);
235
+ const organizer = organizerProperty
236
+ ? participant(organizerProperty)
237
+ : undefined;
238
+ const categories = values(properties, "CATEGORIES")
239
+ .flatMap((property) => property.value.split(","))
240
+ .map(unescapeText)
241
+ .filter(Boolean);
242
+ const lines = [`# ${summary}`];
243
+ if (start) lines.push(`Start: ${start}`);
244
+ if (end) lines.push(`End: ${end}`);
245
+ if (location) lines.push(`Location: ${unescapeText(location)}`);
246
+ if (organizer) lines.push(`Organizer: ${organizer}`);
247
+ if (attendees.length > 0) lines.push(`Attendees: ${attendees.join(", ")}`);
248
+ if (description) lines.push("", unescapeText(description));
249
+ if (recurrence.rrule) {
250
+ lines.push("", `Recurrence: ${unescapeText(recurrence.rrule)}`);
251
+ }
252
+ if (recurrence.truncated) {
253
+ lines.push("Recurrence anchors: truncated to the bounded local horizon");
254
+ }
255
+ const stableId = recurrenceIdentity
256
+ ? `ical:${uid}::recurrence:${recurrenceIdentity}`
257
+ : `ical:${uid}`;
258
+ const anchors: RecordAnchor[] = [
259
+ { kind: "event", value: uid },
260
+ ...recurrence.occurrenceAnchors.map((value) => ({
261
+ kind: "timestamp" as const,
262
+ value,
263
+ })),
264
+ ];
265
+ return {
266
+ type: "record",
267
+ record: {
268
+ stableId,
269
+ sourceLocator: `lines:${startLine}-${endLine}`,
270
+ markdown: lines.join("\n"),
271
+ title: summary,
272
+ metadata: {
273
+ author: organizer,
274
+ participants: [
275
+ ...new Set([organizer, ...attendees].filter(Boolean)),
276
+ ] as string[],
277
+ categories,
278
+ dateFields: Object.fromEntries(
279
+ [
280
+ ["start", start],
281
+ ["end", end],
282
+ ["created", createdProperty && normalizeDate(createdProperty)],
283
+ ["updated", updatedProperty && normalizeDate(updatedProperty)],
284
+ ].filter((entry): entry is [string, string] => Boolean(entry[1]))
285
+ ),
286
+ eventId: uid,
287
+ },
288
+ anchors,
289
+ },
290
+ };
291
+ };
292
+
293
+ async function* parseCalendar(
294
+ input: RecordAdapterInput
295
+ ): AsyncGenerator<RecordAdapterEvent> {
296
+ let sawCalendar = false;
297
+ let calendarOpen = false;
298
+ let endedCalendar = false;
299
+ let eventStart = 0;
300
+ let eventChars = 0;
301
+ let eventProperties: IcalProperty[] | undefined;
302
+ const nestedComponents: string[] = [];
303
+ const calendarComponents: string[] = [];
304
+ let hadFailure = false;
305
+ try {
306
+ for await (const line of logicalLines(input)) {
307
+ if (line.text === "BEGIN:VCALENDAR") {
308
+ if (sawCalendar || calendarOpen || endedCalendar || eventProperties) {
309
+ hadFailure = true;
310
+ yield failure(`line:${line.startLine}`);
311
+ continue;
312
+ }
313
+ sawCalendar = true;
314
+ calendarOpen = true;
315
+ continue;
316
+ }
317
+ if (line.text === "END:VCALENDAR") {
318
+ if (
319
+ !calendarOpen ||
320
+ eventProperties ||
321
+ nestedComponents.length > 0 ||
322
+ calendarComponents.length > 0
323
+ ) {
324
+ hadFailure = true;
325
+ yield failure(`line:${line.startLine}`);
326
+ continue;
327
+ }
328
+ calendarOpen = false;
329
+ endedCalendar = true;
330
+ continue;
331
+ }
332
+ if (line.text === "BEGIN:VEVENT") {
333
+ if (
334
+ !calendarOpen ||
335
+ endedCalendar ||
336
+ eventProperties ||
337
+ calendarComponents.length > 0
338
+ ) {
339
+ hadFailure = true;
340
+ yield failure(`line:${line.startLine}`);
341
+ continue;
342
+ }
343
+ eventStart = line.startLine;
344
+ eventChars = 0;
345
+ nestedComponents.length = 0;
346
+ eventProperties = [];
347
+ continue;
348
+ }
349
+ if (line.text === "END:VEVENT") {
350
+ if (!eventProperties || nestedComponents.length > 0) {
351
+ hadFailure = true;
352
+ yield failure(`line:${line.startLine}`);
353
+ continue;
354
+ }
355
+ const converted = convertEvent(
356
+ eventProperties,
357
+ eventStart,
358
+ line.endLine
359
+ );
360
+ if (converted) yield converted;
361
+ else {
362
+ hadFailure = true;
363
+ yield failure(`lines:${eventStart}-${line.endLine}`);
364
+ }
365
+ eventProperties = undefined;
366
+ continue;
367
+ }
368
+ if (!eventProperties) {
369
+ if (!line.text.trim()) continue;
370
+ if (!calendarOpen || endedCalendar) {
371
+ hadFailure = true;
372
+ yield failure(`line:${line.startLine}`);
373
+ continue;
374
+ }
375
+ if (line.text.startsWith("BEGIN:")) {
376
+ const component = line.text.slice("BEGIN:".length).trim();
377
+ if (!component) {
378
+ hadFailure = true;
379
+ yield failure(`line:${line.startLine}`);
380
+ } else {
381
+ calendarComponents.push(component);
382
+ }
383
+ continue;
384
+ }
385
+ if (line.text.startsWith("END:")) {
386
+ const component = line.text.slice("END:".length).trim();
387
+ if (!component || calendarComponents.at(-1) !== component) {
388
+ hadFailure = true;
389
+ yield failure(`line:${line.startLine}`);
390
+ } else {
391
+ calendarComponents.pop();
392
+ }
393
+ continue;
394
+ }
395
+ if (!parseProperty(line.text)) {
396
+ hadFailure = true;
397
+ yield failure(`line:${line.startLine}`);
398
+ }
399
+ continue;
400
+ }
401
+ if (line.text.startsWith("BEGIN:")) {
402
+ const component = line.text.slice("BEGIN:".length).trim();
403
+ if (!component) {
404
+ hadFailure = true;
405
+ yield failure(`line:${line.startLine}`);
406
+ } else {
407
+ nestedComponents.push(component);
408
+ }
409
+ continue;
410
+ }
411
+ if (line.text.startsWith("END:") && nestedComponents.length > 0) {
412
+ const component = line.text.slice("END:".length).trim();
413
+ if (nestedComponents.at(-1) !== component) {
414
+ hadFailure = true;
415
+ yield failure(`line:${line.startLine}`);
416
+ } else {
417
+ nestedComponents.pop();
418
+ }
419
+ continue;
420
+ }
421
+ if (line.text.startsWith("END:")) {
422
+ hadFailure = true;
423
+ yield failure(`line:${line.startLine}`);
424
+ continue;
425
+ }
426
+ if (nestedComponents.length > 0) continue;
427
+ eventChars += line.text.length;
428
+ if (eventChars > input.limits.maxRecordChars) {
429
+ hadFailure = true;
430
+ yield {
431
+ type: "failure",
432
+ failure: {
433
+ code: "RECORD_TOO_LARGE",
434
+ message: "iCalendar event exceeded its record limit.",
435
+ retryable: false,
436
+ sourceLocator: `line:${eventStart}`,
437
+ },
438
+ };
439
+ eventProperties = undefined;
440
+ continue;
441
+ }
442
+ const property = parseProperty(line.text);
443
+ if (property) eventProperties.push(property);
444
+ else {
445
+ hadFailure = true;
446
+ yield failure(`line:${line.startLine}`);
447
+ }
448
+ }
449
+ } catch {
450
+ hadFailure = true;
451
+ yield failure("calendar", true);
452
+ }
453
+ if (eventProperties) {
454
+ hadFailure = true;
455
+ yield failure(`line:${eventStart}`, true);
456
+ }
457
+ if (calendarOpen || calendarComponents.length > 0) {
458
+ hadFailure = true;
459
+ yield failure("calendar", true);
460
+ }
461
+ const complete =
462
+ sawCalendar &&
463
+ endedCalendar &&
464
+ !calendarOpen &&
465
+ calendarComponents.length === 0 &&
466
+ !hadFailure;
467
+ yield { type: "snapshot", state: complete ? "complete" : "partial" };
468
+ }
469
+
470
+ export const icalAdapter: RecordAdapter = {
471
+ id: "adapter/ical-export",
472
+ version: "1.0.0",
473
+ canHandle: (mime, ext) => mime === "text/calendar" || ext === ".ics",
474
+ records: parseCalendar,
475
+ };
@@ -0,0 +1,186 @@
1
+ const BASIC_DATE_PATTERN = /^(\d{4})(\d{2})(\d{2})$/;
2
+ const BASIC_DATE_TIME_PATTERN =
3
+ /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/;
4
+
5
+ export const MAX_RECURRENCE_ANCHORS = 64;
6
+ const MAX_RECURRENCE_HORIZON_DAYS = 366;
7
+
8
+ export interface RecurrenceSummary {
9
+ recurrenceId?: string;
10
+ rrule?: string;
11
+ exdates: string[];
12
+ rdates: string[];
13
+ occurrenceAnchors: string[];
14
+ truncated: boolean;
15
+ }
16
+
17
+ export interface IcalPropertyLike {
18
+ name: string;
19
+ value: string;
20
+ params?: ReadonlyMap<string, string>;
21
+ }
22
+
23
+ interface BasicDateParts {
24
+ date: Date;
25
+ dateOnly: boolean;
26
+ utc: boolean;
27
+ }
28
+
29
+ const parseBasicDate = (value: string): BasicDateParts | undefined => {
30
+ const dateOnly = BASIC_DATE_PATTERN.exec(value);
31
+ if (dateOnly) {
32
+ const [, year, month, day] = dateOnly;
33
+ const date = new Date(
34
+ Date.UTC(Number(year), Number(month) - 1, Number(day), 0, 0, 0)
35
+ );
36
+ return { date, dateOnly: true, utc: false };
37
+ }
38
+ const dateTime = BASIC_DATE_TIME_PATTERN.exec(value);
39
+ if (!dateTime) return undefined;
40
+ const [, year, month, day, hour, minute, second, utc] = dateTime;
41
+ const date = new Date(
42
+ Date.UTC(
43
+ Number(year),
44
+ Number(month) - 1,
45
+ Number(day),
46
+ Number(hour),
47
+ Number(minute),
48
+ Number(second)
49
+ )
50
+ );
51
+ return { date, dateOnly: false, utc: Boolean(utc) };
52
+ };
53
+
54
+ const pad = (value: number): string => value.toString().padStart(2, "0");
55
+
56
+ const formatBasicDate = (parts: BasicDateParts, date: Date): string => {
57
+ const datePart = `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`;
58
+ if (parts.dateOnly) return datePart;
59
+ const timePart = `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`;
60
+ return `${datePart}T${timePart}${parts.utc ? "Z" : ""}`;
61
+ };
62
+
63
+ const parseRule = (value: string): Map<string, string> | undefined => {
64
+ const rule = new Map<string, string>();
65
+ for (const part of value.split(";")) {
66
+ const equals = part.indexOf("=");
67
+ if (equals <= 0 || equals === part.length - 1) return undefined;
68
+ const key = part.slice(0, equals).toUpperCase();
69
+ const item = part.slice(equals + 1);
70
+ if (!/^[A-Z-]+$/.test(key) || rule.has(key)) return undefined;
71
+ rule.set(key, item);
72
+ }
73
+ return rule.size > 0 ? rule : undefined;
74
+ };
75
+
76
+ const recurrenceAnchor = (value: string, timezone?: string): string =>
77
+ timezone ? `TZID=${timezone}:${value}` : value;
78
+
79
+ const expandSimpleRule = (
80
+ start: string,
81
+ ruleValue: string,
82
+ exclusions: Set<string>,
83
+ limit: number,
84
+ timezone?: string
85
+ ): { anchors: string[]; truncated: boolean } => {
86
+ const startParts = parseBasicDate(start);
87
+ if (!startParts) return { anchors: [], truncated: true };
88
+ const rule = parseRule(ruleValue);
89
+ if (!rule) return { anchors: [], truncated: true };
90
+ const supportedKeys = new Set(["FREQ", "COUNT", "INTERVAL"]);
91
+ if ([...rule.keys()].some((key) => !supportedKeys.has(key))) {
92
+ return { anchors: [], truncated: true };
93
+ }
94
+ const frequency = rule.get("FREQ");
95
+ if (frequency !== "DAILY" && frequency !== "WEEKLY") {
96
+ return { anchors: [], truncated: true };
97
+ }
98
+ const configuredCount = rule.get("COUNT");
99
+ const countValue = Number(configuredCount ?? limit);
100
+ const intervalValue = Number(rule.get("INTERVAL") ?? 1);
101
+ if (
102
+ !Number.isSafeInteger(countValue) ||
103
+ countValue < 1 ||
104
+ !Number.isSafeInteger(intervalValue) ||
105
+ intervalValue < 1
106
+ ) {
107
+ return { anchors: [], truncated: true };
108
+ }
109
+ const stepDays = frequency === "WEEKLY" ? intervalValue * 7 : intervalValue;
110
+ const requested = Math.min(countValue, limit + 1);
111
+ const anchors: string[] = [];
112
+ for (let index = 0; index < requested; index += 1) {
113
+ const elapsedDays = index * stepDays;
114
+ if (elapsedDays > MAX_RECURRENCE_HORIZON_DAYS) {
115
+ return { anchors, truncated: true };
116
+ }
117
+ const occurrence = new Date(startParts.date);
118
+ occurrence.setUTCDate(occurrence.getUTCDate() + elapsedDays);
119
+ const formatted = formatBasicDate(startParts, occurrence);
120
+ if (!exclusions.has(formatted)) {
121
+ anchors.push(recurrenceAnchor(formatted, timezone));
122
+ }
123
+ }
124
+ return {
125
+ anchors: anchors.slice(0, limit),
126
+ truncated:
127
+ configuredCount === undefined ||
128
+ countValue > limit ||
129
+ anchors.length > limit,
130
+ };
131
+ };
132
+
133
+ /**
134
+ * Expand only calendar-safe daily/weekly rules. Unsupported RRULE shapes remain
135
+ * source-visible and explicitly truncated instead of inventing DST semantics.
136
+ */
137
+ export function summarizeRecurrence(
138
+ properties: readonly IcalPropertyLike[],
139
+ start: string | undefined,
140
+ limit = MAX_RECURRENCE_ANCHORS
141
+ ): RecurrenceSummary {
142
+ const property = (name: string): string | undefined =>
143
+ properties.find((item) => item.name === name)?.value;
144
+ const values = (name: string): string[] =>
145
+ properties
146
+ .filter((item) => item.name === name)
147
+ .flatMap((item) =>
148
+ item.value
149
+ .split(",")
150
+ .map((value) => recurrenceAnchor(value, item.params?.get("TZID")))
151
+ )
152
+ .map((item) => item.trim())
153
+ .filter(Boolean);
154
+
155
+ const recurrenceId = property("RECURRENCE-ID");
156
+ const rrule = property("RRULE");
157
+ const exdates = values("EXDATE").sort();
158
+ const rdates = values("RDATE").sort();
159
+ const explicit = rdates.filter((item) => !exdates.includes(item));
160
+ const startProperty = properties.find((item) => item.name === "DTSTART");
161
+ const startTimezone = startProperty?.params?.get("TZID");
162
+ const rawExdates = new Set(
163
+ properties
164
+ .filter((item) => item.name === "EXDATE")
165
+ .flatMap((item) => item.value.split(","))
166
+ );
167
+ const expanded =
168
+ rrule && start
169
+ ? expandSimpleRule(start, rrule, rawExdates, limit, startTimezone)
170
+ : {
171
+ anchors: start ? [recurrenceAnchor(start, startTimezone)] : [],
172
+ truncated: false,
173
+ };
174
+ const occurrenceAnchors = [...new Set([...expanded.anchors, ...explicit])]
175
+ .sort()
176
+ .slice(0, limit);
177
+ return {
178
+ recurrenceId,
179
+ rrule,
180
+ exdates,
181
+ rdates,
182
+ occurrenceAnchors,
183
+ truncated:
184
+ expanded.truncated || expanded.anchors.length + explicit.length > limit,
185
+ };
186
+ }