@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.
- package/README.md +2 -2
- package/assets/skill/SKILL.md +26 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/spec/cli.md +41 -4
- package/spec/db/schema.sql +12 -0
- package/spec/mcp.md +5 -0
- package/spec/output-schemas/ask.schema.json +125 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
- package/spec/output-schemas/get.schema.json +94 -0
- package/spec/output-schemas/mcp-job-status.schema.json +28 -0
- package/spec/output-schemas/multi-get.schema.json +128 -0
- package/spec/output-schemas/record-import.schema.json +193 -0
- package/spec/output-schemas/search-result.schema.json +94 -0
- package/spec/output-schemas/search-results.schema.json +125 -0
- package/spec/project-profile.schema.json +66 -0
- package/src/app/context-format.ts +1 -1
- package/src/cli/commands/get.ts +12 -2
- package/src/cli/commands/index-cmd.ts +13 -0
- package/src/cli/commands/multi-get.ts +9 -2
- package/src/cli/commands/shared.ts +28 -0
- package/src/cli/commands/update.ts +5 -0
- package/src/cli/program.ts +4 -0
- package/src/config/project-profile.ts +2 -0
- package/src/config/types.ts +16 -0
- package/src/converters/adapters/browser-export/adapter.ts +199 -0
- package/src/converters/adapters/browser-export/formats.ts +358 -0
- package/src/converters/adapters/email/adapter.ts +429 -0
- package/src/converters/adapters/email/html.ts +162 -0
- package/src/converters/adapters/email/mime.ts +454 -0
- package/src/converters/adapters/email/parameters.ts +77 -0
- package/src/converters/adapters/ical/adapter.ts +475 -0
- package/src/converters/adapters/ical/recurrence.ts +186 -0
- package/src/converters/adapters/jsonl/adapter.ts +238 -0
- package/src/converters/adapters/jsonl/config.ts +105 -0
- package/src/converters/adapters/shared/html-text.ts +165 -0
- package/src/converters/adapters/shared/record-utils.ts +79 -0
- package/src/converters/adapters/shared/utf8-lines.ts +141 -0
- package/src/converters/adapters/transcript/adapter.ts +295 -0
- package/src/converters/adapters/transcript/json.ts +184 -0
- package/src/converters/adapters/transcript/model.ts +171 -0
- package/src/converters/adapters/transcript/text.ts +58 -0
- package/src/converters/adapters/transcript/timed.ts +152 -0
- package/src/converters/index.ts +11 -1
- package/src/converters/mime.ts +8 -0
- package/src/converters/pipeline.ts +15 -1
- package/src/converters/registry.ts +37 -1
- package/src/converters/types.ts +136 -0
- package/src/core/context-capsule-schema.ts +87 -0
- package/src/core/context-capsule.ts +20 -0
- package/src/core/context-evidence.ts +7 -1
- package/src/core/document-capabilities.ts +12 -0
- package/src/core/project-profile-apply-state.ts +5 -0
- package/src/core/project-profile.ts +4 -0
- package/src/core/record-metadata.ts +49 -0
- package/src/ingestion/record-adapter-canonical.ts +433 -0
- package/src/ingestion/record-adapter.ts +437 -0
- package/src/ingestion/record-container.ts +688 -0
- package/src/ingestion/record-path.ts +20 -0
- package/src/ingestion/record-sync.ts +70 -0
- package/src/ingestion/sync.ts +228 -36
- package/src/ingestion/types.ts +69 -1
- package/src/ingestion/walker.ts +31 -11
- package/src/mcp/tools/get.ts +10 -2
- package/src/mcp/tools/multi-get.ts +9 -2
- package/src/mcp/tools/workspace-write.ts +2 -0
- package/src/pipeline/filters.ts +1 -1
- package/src/pipeline/graph-retrieval.ts +7 -4
- package/src/pipeline/hybrid.ts +7 -4
- package/src/pipeline/result-context.ts +12 -4
- package/src/pipeline/search.ts +11 -4
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +26 -8
- package/src/sdk/documents.ts +13 -5
- package/src/serve/browse-tree.ts +4 -2
- package/src/serve/routes/api.ts +63 -65
- package/src/store/migrations/022-record-export-lineage.ts +42 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +114 -7
- package/src/store/types.ts +40 -0
- 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("&", "&")
|
|
132
|
+
.replaceAll("<", "<")
|
|
133
|
+
.replaceAll(">", ">")
|
|
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
|
+
}
|