@pellux/goodvibes-agent 1.4.3 → 1.5.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 +24 -0
- package/README.md +7 -7
- package/dist/package/main.js +7450 -12285
- package/docs/README.md +2 -2
- package/docs/channels-remote-and-api.md +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/release-and-publishing.md +1 -1
- package/docs/tools-and-commands.md +5 -5
- package/package.json +4 -2
- package/release/performance-snapshot.json +2 -2
- package/release/release-notes.md +11 -7
- package/release/release-readiness.json +6 -6
- package/src/agent/behavior-discovery-summary.ts +4 -18
- package/src/agent/calendar-registry.ts +322 -0
- package/src/agent/competitive-feature-inventory.ts +268 -130
- package/src/agent/document-registry.ts +5 -1
- package/src/agent/email/email-service.ts +350 -0
- package/src/agent/email/imap-client.ts +596 -0
- package/src/agent/email/smtp-client.ts +453 -0
- package/src/agent/ics-calendar.ts +662 -0
- package/src/agent/ics-timezone.ts +172 -0
- package/src/agent/markdown-frontmatter.ts +31 -0
- package/src/agent/memory-safety.ts +1 -1
- package/src/agent/note-registry.ts +3 -9
- package/src/agent/persona-discovery.ts +3 -15
- package/src/agent/persona-registry.ts +1 -10
- package/src/agent/research-source-registry.ts +5 -1
- package/src/agent/routine-discovery.ts +3 -15
- package/src/agent/runtime-profile.ts +3 -0
- package/src/agent/skill-discovery.ts +3 -15
- package/src/agent/skill-draft-proposer.ts +203 -0
- package/src/agent/skill-draft-runner.ts +201 -0
- package/src/agent/skill-registry.ts +36 -1
- package/src/agent/skill-standard.ts +99 -0
- package/src/agent/vibe-file.ts +7 -22
- package/src/cli/completion.ts +1 -1
- package/src/cli/config-overrides.ts +10 -1
- package/src/cli/redaction.ts +17 -5
- package/src/config/provider-model.ts +2 -1
- package/src/config/secret-config.ts +13 -6
- package/src/core/activity-feed.ts +97 -0
- package/src/core/away-digest.ts +161 -0
- package/src/core/conversation-rendering.ts +7 -3
- package/src/core/conversation.ts +22 -15
- package/src/core/hardware-profile.ts +362 -0
- package/src/core/last-seen-store.ts +78 -0
- package/src/core/plain-language.ts +52 -0
- package/src/core/setup-incomplete-hint.ts +90 -0
- package/src/core/system-message-router.ts +38 -87
- package/src/input/agent-workspace-basic-command-editor-submission.ts +60 -220
- package/src/input/agent-workspace-basic-command-editors.ts +39 -141
- package/src/input/agent-workspace-categories.ts +63 -158
- package/src/input/agent-workspace-command-editor.ts +4 -0
- package/src/input/agent-workspace-host-category.ts +0 -5
- package/src/input/agent-workspace-local-editor-submission.ts +3 -1
- package/src/input/agent-workspace-navigation.ts +10 -3
- package/src/input/agent-workspace-onboarding-actions.ts +132 -0
- package/src/input/agent-workspace-onboarding-categories.ts +29 -26
- package/src/input/agent-workspace-onboarding-finish.ts +11 -4
- package/src/input/agent-workspace-onboarding-state.ts +111 -0
- package/src/input/agent-workspace-profile-editor-submission.ts +260 -0
- package/src/input/agent-workspace-profile-editors.ts +155 -0
- package/src/input/agent-workspace-subscription-editor.ts +7 -0
- package/src/input/agent-workspace-types.ts +5 -1
- package/src/input/agent-workspace.ts +65 -39
- package/src/input/command-registry.ts +18 -5
- package/src/input/commands/agent-runtime-profile-runtime.ts +2 -1
- package/src/input/commands/agent-skills-runtime.ts +60 -3
- package/src/input/commands/calendar-runtime.ts +209 -0
- package/src/input/commands/channels-runtime.ts +1 -0
- package/src/input/commands/command-error.ts +9 -0
- package/src/input/commands/compat-runtime.ts +1 -0
- package/src/input/commands/config.ts +1 -0
- package/src/input/commands/conversation-runtime.ts +1 -1
- package/src/input/commands/delegation-runtime.ts +5 -6
- package/src/input/commands/email-runtime.ts +387 -0
- package/src/input/commands/experience-runtime.ts +17 -4
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +6 -1
- package/src/input/commands/knowledge-format.ts +73 -0
- package/src/input/commands/knowledge.ts +9 -74
- package/src/input/commands/local-provider-runtime.ts +2 -1
- package/src/input/commands/local-runtime.ts +10 -4
- package/src/input/commands/mcp-runtime.ts +7 -0
- package/src/input/commands/notify-runtime.ts +17 -1
- package/src/input/commands/onboarding-runtime.ts +1 -0
- package/src/input/commands/operator-actions-runtime.ts +1 -0
- package/src/input/commands/operator-runtime.ts +3 -0
- package/src/input/commands/personas-runtime.ts +1 -0
- package/src/input/commands/platform-access-runtime.ts +40 -17
- package/src/input/commands/product-runtime.ts +6 -1
- package/src/input/commands/provider-accounts-runtime.ts +3 -2
- package/src/input/commands/qrcode-runtime.ts +1 -0
- package/src/input/commands/routines-runtime.ts +1 -0
- package/src/input/commands/runtime-services.ts +0 -13
- package/src/input/commands/security-runtime.ts +1 -0
- package/src/input/commands/session-content.ts +1 -0
- package/src/input/commands/shell-core.ts +5 -2
- package/src/input/commands/subscription-runtime.ts +33 -9
- package/src/input/commands/support-bundle-runtime.ts +8 -1
- package/src/input/commands/tasks-runtime.ts +1 -0
- package/src/input/commands/tts-runtime.ts +1 -0
- package/src/input/commands/vibe-runtime.ts +1 -0
- package/src/input/commands.ts +4 -0
- package/src/input/feed-context-factory.ts +3 -12
- package/src/input/handler-command-route.ts +7 -60
- package/src/input/handler-feed-routes.ts +0 -194
- package/src/input/handler-feed.ts +2 -54
- package/src/input/handler-interactions.ts +0 -2
- package/src/input/handler-modal-stack.ts +0 -9
- package/src/input/handler-picker-routes.ts +24 -1
- package/src/input/handler-shortcuts.ts +11 -40
- package/src/input/handler-ui-state.ts +13 -0
- package/src/input/handler.ts +8 -35
- package/src/input/keybindings.ts +40 -17
- package/src/input/model-picker-local-fit.ts +130 -0
- package/src/input/submission-router.ts +0 -5
- package/src/main.ts +111 -89
- package/src/renderer/activity-sidebar.ts +186 -0
- package/src/renderer/agent-workspace-context-lines.ts +5 -48
- package/src/renderer/agent-workspace-style.ts +1 -1
- package/src/renderer/agent-workspace.ts +14 -2
- package/src/renderer/compositor.ts +37 -125
- package/src/renderer/conversation-overlays.ts +3 -3
- package/src/renderer/help-overlay.ts +7 -5
- package/src/renderer/model-workspace.ts +101 -86
- package/src/{panels → renderer}/polish.ts +3 -3
- package/src/renderer/shell-surface.ts +4 -5
- package/src/renderer/status-token.ts +31 -11
- package/src/renderer/tab-strip.ts +1 -1
- package/src/renderer/tool-call.ts +7 -8
- package/src/renderer/tool-labels.ts +92 -0
- package/src/renderer/ui-factory.ts +48 -96
- package/src/runtime/bootstrap-command-context.ts +0 -5
- package/src/runtime/bootstrap-command-parts.ts +6 -15
- package/src/runtime/bootstrap-core.ts +34 -13
- package/src/runtime/bootstrap-hook-bridge.ts +3 -1
- package/src/runtime/bootstrap-shell.ts +3 -50
- package/src/runtime/bootstrap.ts +3 -4
- package/src/runtime/context.ts +1 -1
- package/src/runtime/diagnostics/panels/index.ts +0 -1
- package/src/runtime/diagnostics/panels/policy.ts +1 -1
- package/src/runtime/execution-ledger.ts +16 -1
- package/src/runtime/index.ts +6 -1
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/onboarding-state.ts +200 -0
- package/src/{panels → runtime}/provider-account-snapshot.ts +5 -2
- package/src/runtime/services.ts +16 -4
- package/src/runtime/terminal-output-guard.ts +7 -0
- package/src/runtime/tool-permission-safety.ts +1 -1
- package/src/runtime/ui/model-picker/data-provider.ts +1 -1
- package/src/runtime/ui/model-picker/health-enrichment.ts +2 -2
- package/src/runtime/ui/model-picker/types.ts +2 -2
- package/src/runtime/ui/provider-health/data-provider.ts +3 -3
- package/src/runtime/ui/provider-health/types.ts +2 -2
- package/src/runtime/ui-services.ts +0 -2
- package/src/shell/agent-workspace-fullscreen.ts +0 -1
- package/src/shell/autonomy-surfacing.ts +272 -0
- package/src/shell/session-continuity-hints.ts +0 -6
- package/src/shell/startup-wiring.ts +221 -0
- package/src/shell/ui-openers.ts +18 -29
- package/src/tools/agent-context-policy.ts +1 -1
- package/src/tools/agent-harness-background-processes.ts +4 -4
- package/src/tools/agent-harness-browser-cockpit-route.ts +3 -3
- package/src/tools/agent-harness-command-runner.ts +6 -1
- package/src/tools/agent-harness-document-ops.ts +26 -53
- package/src/tools/agent-harness-execution-history.ts +1 -13
- package/src/tools/agent-harness-execution-posture.ts +0 -15
- package/src/tools/agent-harness-keybinding-metadata.ts +28 -29
- package/src/tools/agent-harness-local-model-cookbook.ts +24 -1
- package/src/tools/agent-harness-metadata.ts +16 -0
- package/src/tools/agent-harness-mode-catalog.ts +2 -9
- package/src/tools/agent-harness-provider-account-metadata.ts +2 -2
- package/src/tools/agent-harness-setup-posture-utils.ts +1 -1
- package/src/tools/agent-harness-tool-schema.ts +13 -14
- package/src/tools/agent-harness-tool.ts +27 -23
- package/src/tools/agent-harness-ui-surface-metadata.ts +10 -20
- package/src/tools/agent-harness-workspace-action-runner.ts +3 -4
- package/src/tools/agent-workspace-tool.ts +2 -33
- package/src/utils/terminal-width.ts +9 -3
- package/src/version.ts +1 -1
- package/src/input/agent-workspace-panel-route.ts +0 -44
- package/src/input/panel-integration-actions.ts +0 -26
- package/src/panels/approval-panel.ts +0 -149
- package/src/panels/automation-control-panel.ts +0 -212
- package/src/panels/base-panel.ts +0 -254
- package/src/panels/builtin/agent.ts +0 -58
- package/src/panels/builtin/knowledge.ts +0 -26
- package/src/panels/builtin/operations.ts +0 -121
- package/src/panels/builtin/session.ts +0 -138
- package/src/panels/builtin/shared.ts +0 -275
- package/src/panels/builtin/usage.ts +0 -21
- package/src/panels/builtin-panels.ts +0 -23
- package/src/panels/confirm-state.ts +0 -61
- package/src/panels/context-visualizer-panel.ts +0 -204
- package/src/panels/cost-tracker-panel.ts +0 -444
- package/src/panels/docs-panel.ts +0 -285
- package/src/panels/index.ts +0 -25
- package/src/panels/knowledge-panel.ts +0 -417
- package/src/panels/memory-panel.ts +0 -226
- package/src/panels/panel-list-panel.ts +0 -464
- package/src/panels/panel-manager.ts +0 -570
- package/src/panels/provider-accounts-panel.ts +0 -233
- package/src/panels/provider-health-domains.ts +0 -208
- package/src/panels/provider-health-panel.ts +0 -720
- package/src/panels/provider-health-tracker.ts +0 -115
- package/src/panels/provider-stats-panel.ts +0 -366
- package/src/panels/qr-panel.ts +0 -207
- package/src/panels/schedule-panel.ts +0 -321
- package/src/panels/scrollable-list-panel.ts +0 -491
- package/src/panels/search-focus.ts +0 -32
- package/src/panels/security-panel.ts +0 -295
- package/src/panels/session-browser-panel.ts +0 -395
- package/src/panels/session-maintenance.ts +0 -125
- package/src/panels/subscription-panel.ts +0 -263
- package/src/panels/system-messages-panel.ts +0 -230
- package/src/panels/tasks-panel.ts +0 -344
- package/src/panels/thinking-panel.ts +0 -304
- package/src/panels/token-budget-panel.ts +0 -475
- package/src/panels/tool-inspector-panel.ts +0 -436
- package/src/panels/types.ts +0 -54
- package/src/renderer/panel-composite.ts +0 -158
- package/src/renderer/panel-tab-bar.ts +0 -69
- package/src/renderer/panel-workspace-bar.ts +0 -42
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/tools/agent-harness-panel-metadata.ts +0 -211
- /package/src/runtime/perf/{panel-health-monitor.ts → component-health.ts} +0 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ics-calendar.ts — Dependency-free iCalendar (RFC 5545) parse and render.
|
|
3
|
+
*
|
|
4
|
+
* RRULE boundary: only FREQ=DAILY, FREQ=WEEKLY, and FREQ=MONTHLY are expanded.
|
|
5
|
+
* COUNT and UNTIL (DATE and DATE-TIME forms) are honoured. INTERVAL, BYDAY,
|
|
6
|
+
* BYMONTHDAY, EXDATE, and all other recurrence keywords are NOT processed —
|
|
7
|
+
* occurrences that would require them are silently omitted. Callers should
|
|
8
|
+
* treat the `rrule` field as informational for any rule that includes those
|
|
9
|
+
* keywords. Expansion is capped at 90 days from the reference date passed to
|
|
10
|
+
* `expandRecurringEvent`.
|
|
11
|
+
*
|
|
12
|
+
* Timezone handling:
|
|
13
|
+
* - UTC form (DTSTART:...Z): stored and emitted in UTC. No conversion needed.
|
|
14
|
+
* - Floating form (no TZID): treated as UTC for comparison purposes (existing
|
|
15
|
+
* behaviour, documented). Emitted without Z suffix.
|
|
16
|
+
* - TZID-qualified form: converted to a correct UTC instant via Intl-based
|
|
17
|
+
* offset derivation (see ics-timezone.ts). The TZID and original wall-clock
|
|
18
|
+
* are stored and re-emitted on render so round-trips preserve zone identity.
|
|
19
|
+
* Note: we emit DTSTART;TZID=Zone:YYYYMMDDTHHMMSS without a VTIMEZONE block.
|
|
20
|
+
* Tolerant readers (Google Calendar, Apple Calendar, Outlook) accept TZID
|
|
21
|
+
* without VTIMEZONE when the name is a valid IANA zone identifier; conformant
|
|
22
|
+
* strict readers may reject it. This is a documented limitation.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { wallClockToUtcMs, utcMsToWallClock } from './ics-timezone.ts';
|
|
26
|
+
|
|
27
|
+
// ──────────────────────────────────────────────────────────────────
|
|
28
|
+
// Types
|
|
29
|
+
// ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface IcsEvent {
|
|
32
|
+
/** UID property value */
|
|
33
|
+
readonly uid: string;
|
|
34
|
+
/** SUMMARY text (unescaped) */
|
|
35
|
+
readonly summary: string;
|
|
36
|
+
/** DESCRIPTION text (unescaped), if present */
|
|
37
|
+
readonly description?: string;
|
|
38
|
+
/** LOCATION text (unescaped), if present */
|
|
39
|
+
readonly location?: string;
|
|
40
|
+
/**
|
|
41
|
+
* DTSTART as an ISO-8601 string.
|
|
42
|
+
* All-day events use YYYY-MM-DD form.
|
|
43
|
+
* UTC events use YYYY-MM-DDTHH:MM:SSZ form.
|
|
44
|
+
* TZID-qualified events use YYYY-MM-DDTHH:MM:SS (wall-clock, no Z suffix).
|
|
45
|
+
* Floating events (no TZID, no Z) use YYYY-MM-DDTHH:MM:SS (treated as UTC).
|
|
46
|
+
*/
|
|
47
|
+
readonly dtstart: string;
|
|
48
|
+
/**
|
|
49
|
+
* DTEND as an ISO-8601 string, if present.
|
|
50
|
+
* Follows same convention as dtstart.
|
|
51
|
+
*/
|
|
52
|
+
readonly dtend?: string;
|
|
53
|
+
/** True when DTSTART is a DATE (all-day) value */
|
|
54
|
+
readonly allDay: boolean;
|
|
55
|
+
/** TZID parameter from DTSTART, if any */
|
|
56
|
+
readonly tzid?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Corrected UTC instant (ms since epoch) for the DTSTART.
|
|
59
|
+
* Populated for TZID-qualified events via Intl-based offset derivation.
|
|
60
|
+
* Undefined for all-day events, UTC events (use Date.parse(dtstart) instead),
|
|
61
|
+
* and floating events.
|
|
62
|
+
*/
|
|
63
|
+
readonly dtstartUtcMs?: number;
|
|
64
|
+
/** Raw RRULE string, if any */
|
|
65
|
+
readonly rrule?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ──────────────────────────────────────────────────────────────────
|
|
69
|
+
// Internal helpers
|
|
70
|
+
// ──────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unfold continuation lines: CRLF (or bare LF) followed by a single space or
|
|
74
|
+
* tab is a logical-line continuation per RFC 5545 §3.1.
|
|
75
|
+
*/
|
|
76
|
+
function unfoldLines(content: string): string {
|
|
77
|
+
return content.replace(/\r?\n[ \t]/g, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Split content into logical lines after unfolding. */
|
|
81
|
+
function logicalLines(content: string): string[] {
|
|
82
|
+
return unfoldLines(content).split(/\r?\n/);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Unescape RFC 5545 text-value escapes: \\n → newline, \\\\ → \\, \\; → ;, \\, → , */
|
|
86
|
+
function unescapeText(value: string): string {
|
|
87
|
+
return value
|
|
88
|
+
.replace(/\\n/gi, '\n')
|
|
89
|
+
.replace(/\\;/g, ';')
|
|
90
|
+
.replace(/\\,/g, ',')
|
|
91
|
+
.replace(/\\\\/g, '\\');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Escape text for iCalendar output. */
|
|
95
|
+
function escapeText(value: string): string {
|
|
96
|
+
return value
|
|
97
|
+
.replace(/\\/g, '\\\\')
|
|
98
|
+
.replace(/;/g, '\\;')
|
|
99
|
+
.replace(/,/g, '\\,')
|
|
100
|
+
.replace(/\n/g, '\\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Fold a single content line at 75 octets (UTF-8 bytes), continuing with
|
|
105
|
+
* CRLF + SPACE per RFC 5545 §3.1.
|
|
106
|
+
*/
|
|
107
|
+
export function foldLine(line: string): string {
|
|
108
|
+
const encoder = new TextEncoder();
|
|
109
|
+
const bytes = encoder.encode(line);
|
|
110
|
+
if (bytes.length <= 75) return line;
|
|
111
|
+
|
|
112
|
+
const decoder = new TextDecoder();
|
|
113
|
+
const chunks: string[] = [];
|
|
114
|
+
let offset = 0;
|
|
115
|
+
let firstChunk = true;
|
|
116
|
+
while (offset < bytes.length) {
|
|
117
|
+
const limit = firstChunk ? 75 : 74; // first line has no leading space
|
|
118
|
+
// Slice at limit, then back off if we'd split a multi-byte sequence.
|
|
119
|
+
let end = Math.min(offset + limit, bytes.length);
|
|
120
|
+
while (end > offset + 1 && (bytes[end]! & 0xc0) === 0x80) end--;
|
|
121
|
+
chunks.push(decoder.decode(bytes.slice(offset, end)));
|
|
122
|
+
offset = end;
|
|
123
|
+
firstChunk = false;
|
|
124
|
+
}
|
|
125
|
+
return chunks.join('\r\n ');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse a DTSTART / DTEND value string (with optional TZID parameter).
|
|
130
|
+
* Returns { iso, allDay, tzid, utcMs }.
|
|
131
|
+
*
|
|
132
|
+
* Supported value forms:
|
|
133
|
+
* DATE: YYYYMMDD
|
|
134
|
+
* DATE-TIME: YYYYMMDDTHHMMSS (local / TZID)
|
|
135
|
+
* DATE-TIME UTC: YYYYMMDDTHHMMSSZ
|
|
136
|
+
*
|
|
137
|
+
* When a TZID is present, utcMs is set to the correctly converted UTC instant
|
|
138
|
+
* using Intl-based offset derivation. For unknown TZIDs, utcMs falls back to
|
|
139
|
+
* treating the wall-clock as UTC (documented limitation).
|
|
140
|
+
*/
|
|
141
|
+
function parseDateValue(
|
|
142
|
+
value: string,
|
|
143
|
+
tzid?: string,
|
|
144
|
+
): { iso: string; allDay: boolean; tzid?: string; utcMs?: number } {
|
|
145
|
+
const trimmed = value.trim();
|
|
146
|
+
// All-day: pure 8-digit date
|
|
147
|
+
if (/^\d{8}$/.test(trimmed)) {
|
|
148
|
+
const iso = `${trimmed.slice(0, 4)}-${trimmed.slice(4, 6)}-${trimmed.slice(6, 8)}`;
|
|
149
|
+
return { iso, allDay: true };
|
|
150
|
+
}
|
|
151
|
+
// Date-time forms: YYYYMMDDTHHMMSS[Z]
|
|
152
|
+
const m = trimmed.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
|
|
153
|
+
if (m) {
|
|
154
|
+
const isUtc = m[7] === 'Z';
|
|
155
|
+
const utcSuffix = isUtc ? 'Z' : '';
|
|
156
|
+
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${utcSuffix}`;
|
|
157
|
+
if (isUtc) {
|
|
158
|
+
// UTC form: no TZID, utcMs derivable directly from iso
|
|
159
|
+
return { iso, allDay: false };
|
|
160
|
+
}
|
|
161
|
+
if (tzid) {
|
|
162
|
+
// TZID-qualified: convert wall-clock to correct UTC instant
|
|
163
|
+
const utcMs = wallClockToUtcMs(iso, tzid);
|
|
164
|
+
return { iso, allDay: false, tzid, utcMs };
|
|
165
|
+
}
|
|
166
|
+
// Floating: no TZID, no Z suffix — treated as UTC for comparison
|
|
167
|
+
return { iso, allDay: false };
|
|
168
|
+
}
|
|
169
|
+
// Fallback: return as-is
|
|
170
|
+
return { iso: trimmed, allDay: false, tzid };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse a property line into name, params map, and value.
|
|
175
|
+
* Handles "DTSTART;TZID=America/New_York:20240101T090000" etc.
|
|
176
|
+
*/
|
|
177
|
+
function parsePropLine(line: string): { name: string; params: Map<string, string>; value: string } | null {
|
|
178
|
+
const colonIdx = line.indexOf(':');
|
|
179
|
+
if (colonIdx < 0) return null;
|
|
180
|
+
const head = line.slice(0, colonIdx);
|
|
181
|
+
const value = line.slice(colonIdx + 1);
|
|
182
|
+
const parts = head.split(';');
|
|
183
|
+
const name = (parts[0] ?? '').toUpperCase();
|
|
184
|
+
const params = new Map<string, string>();
|
|
185
|
+
for (let i = 1; i < parts.length; i++) {
|
|
186
|
+
const eq = parts[i]!.indexOf('=');
|
|
187
|
+
if (eq >= 0) {
|
|
188
|
+
params.set(parts[i]!.slice(0, eq).toUpperCase(), parts[i]!.slice(eq + 1));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { name, params, value };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ──────────────────────────────────────────────────────────────────
|
|
195
|
+
// Parser
|
|
196
|
+
// ──────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Parse iCalendar text into an array of IcsEvent objects.
|
|
200
|
+
*
|
|
201
|
+
* Tolerant of: unknown component types, unknown properties, missing DTEND,
|
|
202
|
+
* malformed property lines, and non-standard line endings.
|
|
203
|
+
*/
|
|
204
|
+
export function parseIcs(content: string): IcsEvent[] {
|
|
205
|
+
const events: IcsEvent[] = [];
|
|
206
|
+
const lines = logicalLines(content);
|
|
207
|
+
|
|
208
|
+
let inVevent = false;
|
|
209
|
+
let uid = '';
|
|
210
|
+
let summary = '';
|
|
211
|
+
let description: string | undefined;
|
|
212
|
+
let location: string | undefined;
|
|
213
|
+
let dtstart = '';
|
|
214
|
+
let dtend: string | undefined;
|
|
215
|
+
let allDay = false;
|
|
216
|
+
let tzid: string | undefined;
|
|
217
|
+
let dtstartUtcMs: number | undefined;
|
|
218
|
+
let rrule: string | undefined;
|
|
219
|
+
let depth = 0; // tracks nested BEGIN/END for unknown components
|
|
220
|
+
|
|
221
|
+
for (const raw of lines) {
|
|
222
|
+
const line = raw.trim();
|
|
223
|
+
if (!line) continue;
|
|
224
|
+
|
|
225
|
+
if (/^BEGIN:/i.test(line)) {
|
|
226
|
+
const component = line.slice(6).toUpperCase();
|
|
227
|
+
if (component === 'VEVENT') {
|
|
228
|
+
inVevent = true;
|
|
229
|
+
depth = 0;
|
|
230
|
+
uid = '';
|
|
231
|
+
summary = '';
|
|
232
|
+
description = undefined;
|
|
233
|
+
location = undefined;
|
|
234
|
+
dtstart = '';
|
|
235
|
+
dtend = undefined;
|
|
236
|
+
allDay = false;
|
|
237
|
+
tzid = undefined;
|
|
238
|
+
dtstartUtcMs = undefined;
|
|
239
|
+
rrule = undefined;
|
|
240
|
+
} else if (inVevent) {
|
|
241
|
+
depth++; // nested component inside VEVENT (e.g. VALARM)
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (/^END:/i.test(line)) {
|
|
247
|
+
const component = line.slice(4).toUpperCase();
|
|
248
|
+
if (component === 'VEVENT') {
|
|
249
|
+
if (dtstart && summary) {
|
|
250
|
+
events.push({
|
|
251
|
+
uid: uid || dtstart,
|
|
252
|
+
summary,
|
|
253
|
+
description,
|
|
254
|
+
location,
|
|
255
|
+
dtstart,
|
|
256
|
+
dtend,
|
|
257
|
+
allDay,
|
|
258
|
+
tzid: tzid || undefined,
|
|
259
|
+
dtstartUtcMs,
|
|
260
|
+
rrule,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
inVevent = false;
|
|
264
|
+
} else if (inVevent && depth > 0) {
|
|
265
|
+
depth--;
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!inVevent || depth > 0) continue;
|
|
271
|
+
|
|
272
|
+
const prop = parsePropLine(line);
|
|
273
|
+
if (!prop) continue;
|
|
274
|
+
|
|
275
|
+
switch (prop.name) {
|
|
276
|
+
case 'UID': {
|
|
277
|
+
uid = prop.value.trim();
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case 'SUMMARY': {
|
|
281
|
+
summary = unescapeText(prop.value);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case 'DESCRIPTION': {
|
|
285
|
+
description = unescapeText(prop.value);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
case 'LOCATION': {
|
|
289
|
+
location = unescapeText(prop.value);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case 'DTSTART': {
|
|
293
|
+
const paramTzid = prop.params.get('TZID');
|
|
294
|
+
const parsed = parseDateValue(prop.value, paramTzid);
|
|
295
|
+
dtstart = parsed.iso;
|
|
296
|
+
allDay = parsed.allDay;
|
|
297
|
+
tzid = parsed.tzid;
|
|
298
|
+
dtstartUtcMs = parsed.utcMs;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case 'DTEND': {
|
|
302
|
+
const paramTzid = prop.params.get('TZID');
|
|
303
|
+
const parsed = parseDateValue(prop.value, paramTzid);
|
|
304
|
+
dtend = parsed.iso;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case 'RRULE': {
|
|
308
|
+
rrule = prop.value.trim();
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
// All other properties are silently ignored.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return events;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ──────────────────────────────────────────────────────────────────
|
|
319
|
+
// RRULE expansion (simple subset only)
|
|
320
|
+
// ──────────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Parse RRULE string into parts map.
|
|
324
|
+
* Example: "FREQ=WEEKLY;COUNT=5;BYDAY=MO" → { FREQ: 'WEEKLY', COUNT: '5', BYDAY: 'MO' }
|
|
325
|
+
*/
|
|
326
|
+
function parseRrule(rrule: string): Map<string, string> {
|
|
327
|
+
const map = new Map<string, string>();
|
|
328
|
+
for (const part of rrule.split(';')) {
|
|
329
|
+
const eq = part.indexOf('=');
|
|
330
|
+
if (eq >= 0) map.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
|
|
331
|
+
}
|
|
332
|
+
return map;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Add `n` months to UTC-based year/month/day components (returns new ms). */
|
|
336
|
+
function addMonthsToMs(utcMs: number, n: number): number {
|
|
337
|
+
const d = new Date(utcMs);
|
|
338
|
+
const target = d.getUTCMonth() + n;
|
|
339
|
+
d.setUTCMonth(target);
|
|
340
|
+
return d.getTime();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Parse UNTIL value (DATE or DATE-TIME, local or UTC). Returns a Date or null. */
|
|
344
|
+
function parseUntil(until: string): Date | null {
|
|
345
|
+
const v = until.trim();
|
|
346
|
+
if (/^\d{8}$/.test(v)) {
|
|
347
|
+
return new Date(`${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}T00:00:00Z`);
|
|
348
|
+
}
|
|
349
|
+
const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
|
|
350
|
+
if (m) {
|
|
351
|
+
const suffix = 'Z'; // floating UNTIL coerced to UTC for expansion (RFC 5545 §3.3.10 compliance note)
|
|
352
|
+
return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${suffix}`);
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Convert an IcsEvent dtstart to a UTC timestamp (ms since epoch).
|
|
359
|
+
*
|
|
360
|
+
* Priority:
|
|
361
|
+
* 1. dtstartUtcMs (pre-computed during parse, TZID-qualified events)
|
|
362
|
+
* 2. All-day events: UTC noon on the date (stable anchor)
|
|
363
|
+
* 3. UTC events (dtstart ends with Z): Date.parse
|
|
364
|
+
* 4. Floating events (no Z, no TZID): treated as UTC
|
|
365
|
+
*/
|
|
366
|
+
function eventStartMs(event: IcsEvent): number | null {
|
|
367
|
+
if (event.dtstartUtcMs !== undefined) return event.dtstartUtcMs;
|
|
368
|
+
if (event.allDay) {
|
|
369
|
+
const d = new Date(`${event.dtstart}T12:00:00Z`);
|
|
370
|
+
return isNaN(d.getTime()) ? null : d.getTime();
|
|
371
|
+
}
|
|
372
|
+
// TZID-qualified wall-clock without a pre-computed utcMs (e.g. a synthetic
|
|
373
|
+
// endEvent constructed from event.dtend): convert wall-clock → UTC via Intl.
|
|
374
|
+
if (event.tzid && !event.dtstart.endsWith('Z')) {
|
|
375
|
+
return wallClockToUtcMs(event.dtstart, event.tzid);
|
|
376
|
+
}
|
|
377
|
+
const normalized = event.dtstart.endsWith('Z') || event.dtstart.includes('+')
|
|
378
|
+
? event.dtstart
|
|
379
|
+
: `${event.dtstart}Z`;
|
|
380
|
+
const d = new Date(normalized);
|
|
381
|
+
return isNaN(d.getTime()) ? null : d.getTime();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Format a UTC ms timestamp to ISO string matching allDay convention. */
|
|
385
|
+
function utcMsToIso(utcMs: number, allDay: boolean): string {
|
|
386
|
+
const d = new Date(utcMs);
|
|
387
|
+
if (allDay) {
|
|
388
|
+
const y = d.getUTCFullYear();
|
|
389
|
+
const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
390
|
+
const da = String(d.getUTCDate()).padStart(2, '0');
|
|
391
|
+
return `${y}-${mo}-${da}`;
|
|
392
|
+
}
|
|
393
|
+
return d.toISOString().replace('.000', '');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Format a UTC ms timestamp as a wall-clock ISO string in the given IANA timezone.
|
|
398
|
+
* Returns "YYYY-MM-DDTHH:MM:SS" (no Z suffix) for use as a TZID-qualified DTSTART.
|
|
399
|
+
* Falls back to UTC ISO string if the timezone is unavailable.
|
|
400
|
+
*/
|
|
401
|
+
function utcMsToTzWallClock(utcMs: number, tzid: string): string {
|
|
402
|
+
const wc = utcMsToWallClock(utcMs, tzid);
|
|
403
|
+
return wc ?? new Date(utcMs).toISOString().replace('.000Z', 'Z');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Expand a recurring event into individual occurrences for the next
|
|
408
|
+
* `horizonDays` days from `referenceDate`.
|
|
409
|
+
*
|
|
410
|
+
* Only FREQ=DAILY, FREQ=WEEKLY, and FREQ=MONTHLY with optional COUNT / UNTIL
|
|
411
|
+
* are supported. INTERVAL defaults to 1. Any other RRULE keywords
|
|
412
|
+
* (BYDAY, BYMONTHDAY, EXDATE, etc.) are present on the rule but NOT
|
|
413
|
+
* processed — their presence does not cause an error, but the expansion will
|
|
414
|
+
* not reflect their constraints.
|
|
415
|
+
*
|
|
416
|
+
* Returns an empty array for any rule with FREQ other than those three.
|
|
417
|
+
*
|
|
418
|
+
* Timezone-correct stepping (TZID-qualified events):
|
|
419
|
+
* Each occurrence is stepped by computing the NEXT wall-clock time in the
|
|
420
|
+
* event's timezone (e.g. DAILY 09:00 NY stays 09:00 NY across DST). This
|
|
421
|
+
* means the UTC instant shifts by one hour across a DST boundary — which
|
|
422
|
+
* is the correct behaviour.
|
|
423
|
+
*/
|
|
424
|
+
export function expandRecurringEvent(event: IcsEvent, referenceDate: Date, horizonDays = 90): IcsEvent[] {
|
|
425
|
+
if (!event.rrule) return [];
|
|
426
|
+
const parts = parseRrule(event.rrule);
|
|
427
|
+
const freq = parts.get('FREQ')?.toUpperCase();
|
|
428
|
+
if (freq !== 'DAILY' && freq !== 'WEEKLY' && freq !== 'MONTHLY') return [];
|
|
429
|
+
|
|
430
|
+
// If the rule contains keywords we cannot faithfully expand, return only the
|
|
431
|
+
// single base occurrence so callers get accurate data rather than wrong dates.
|
|
432
|
+
const UNSUPPORTED_PARTS = ['BYDAY', 'BYMONTHDAY', 'BYMONTH', 'BYWEEKNO', 'BYYEARDAY', 'BYSETPOS', 'BYHOUR', 'BYMINUTE', 'WKST'] as const;
|
|
433
|
+
if (UNSUPPORTED_PARTS.some((key) => parts.has(key))) {
|
|
434
|
+
return [{ ...event }];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const interval = Math.max(1, parseInt(parts.get('INTERVAL') ?? '1', 10) || 1);
|
|
438
|
+
const maxCount = parts.has('COUNT') ? Math.max(1, parseInt(parts.get('COUNT')!, 10) || 1) : Infinity;
|
|
439
|
+
const until = parts.has('UNTIL') ? parseUntil(parts.get('UNTIL')!) : null;
|
|
440
|
+
|
|
441
|
+
const horizonEnd = new Date(referenceDate.getTime() + horizonDays * 86_400_000);
|
|
442
|
+
|
|
443
|
+
const startMs = eventStartMs(event);
|
|
444
|
+
if (startMs === null) return [];
|
|
445
|
+
|
|
446
|
+
// Duration offset between dtstart and dtend (ms)
|
|
447
|
+
let durationMs = 0;
|
|
448
|
+
if (event.dtend) {
|
|
449
|
+
const endEvent = { ...event, dtstart: event.dtend, dtstartUtcMs: undefined, tzid: event.tzid };
|
|
450
|
+
const endMs = eventStartMs(endEvent);
|
|
451
|
+
if (endMs !== null) durationMs = endMs - startMs;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const results: IcsEvent[] = [];
|
|
455
|
+
let cursorMs = startMs;
|
|
456
|
+
let count = 0;
|
|
457
|
+
let totalIterations = 0;
|
|
458
|
+
const MAX_TOTAL_ITERATIONS = 100_000;
|
|
459
|
+
|
|
460
|
+
while (count < maxCount) {
|
|
461
|
+
// MIN-4: hard backstop on total loop iterations (independent of cursor monotonicity)
|
|
462
|
+
if (totalIterations++ >= MAX_TOTAL_ITERATIONS) break;
|
|
463
|
+
// Enforce UNTIL boundary
|
|
464
|
+
if (until && cursorMs > until.getTime()) break;
|
|
465
|
+
// Enforce horizon
|
|
466
|
+
if (cursorMs > horizonEnd.getTime()) break;
|
|
467
|
+
// Only include occurrences from referenceDate onward
|
|
468
|
+
if (cursorMs >= referenceDate.getTime()) {
|
|
469
|
+
let occStart: string;
|
|
470
|
+
if (event.tzid && !event.allDay) {
|
|
471
|
+
// Emit as wall-clock in the event's timezone (no Z)
|
|
472
|
+
occStart = utcMsToTzWallClock(cursorMs, event.tzid);
|
|
473
|
+
} else {
|
|
474
|
+
occStart = utcMsToIso(cursorMs, event.allDay);
|
|
475
|
+
}
|
|
476
|
+
const occEnd = durationMs > 0
|
|
477
|
+
? (event.tzid && !event.allDay
|
|
478
|
+
? utcMsToTzWallClock(cursorMs + durationMs, event.tzid)
|
|
479
|
+
: utcMsToIso(cursorMs + durationMs, event.allDay))
|
|
480
|
+
: event.dtend;
|
|
481
|
+
results.push({ ...event, dtstart: occStart, dtend: occEnd, dtstartUtcMs: cursorMs });
|
|
482
|
+
count++;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Advance cursor in the event's timezone (DST-correct stepping)
|
|
486
|
+
if (event.tzid && !event.allDay) {
|
|
487
|
+
cursorMs = advanceTzMs(cursorMs, event.tzid, freq, interval);
|
|
488
|
+
} else if (freq === 'DAILY') {
|
|
489
|
+
cursorMs += interval * 86_400_000;
|
|
490
|
+
} else if (freq === 'WEEKLY') {
|
|
491
|
+
cursorMs += interval * 7 * 86_400_000;
|
|
492
|
+
} else {
|
|
493
|
+
cursorMs = addMonthsToMs(cursorMs, interval);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Safety: bail after 10000 iterations regardless
|
|
497
|
+
if (count > 10000) break;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return results;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Advance a UTC ms instant by the given frequency/interval stepping IN the
|
|
505
|
+
* specified IANA timezone. This keeps wall-clock time stable across DST:
|
|
506
|
+
* - DAILY: get next-day wall-clock at same H:M:S in tzid, convert back to UTC.
|
|
507
|
+
* - WEEKLY: same but 7 days later.
|
|
508
|
+
* - MONTHLY: advance the month component of the wall-clock, keep H:M:S.
|
|
509
|
+
*
|
|
510
|
+
* Falls back to fixed-ms arithmetic when the timezone is unavailable.
|
|
511
|
+
*/
|
|
512
|
+
function advanceTzMs(utcMs: number, tzid: string, freq: string, interval: number): number {
|
|
513
|
+
const wc = utcMsToWallClock(utcMs, tzid);
|
|
514
|
+
if (!wc) {
|
|
515
|
+
// Unknown TZID fallback
|
|
516
|
+
if (freq === 'DAILY') return utcMs + interval * 86_400_000;
|
|
517
|
+
if (freq === 'WEEKLY') return utcMs + interval * 7 * 86_400_000;
|
|
518
|
+
return addMonthsToMs(utcMs, interval);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Parse wall-clock components
|
|
522
|
+
const m = wc.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/);
|
|
523
|
+
if (!m) return utcMs + 86_400_000; // unexpected format
|
|
524
|
+
|
|
525
|
+
let year = parseInt(m[1]!, 10);
|
|
526
|
+
let month = parseInt(m[2]!, 10); // 1-12
|
|
527
|
+
let day = parseInt(m[3]!, 10);
|
|
528
|
+
const hour = parseInt(m[4]!, 10);
|
|
529
|
+
const minute = parseInt(m[5]!, 10);
|
|
530
|
+
const second = parseInt(m[6]!, 10);
|
|
531
|
+
|
|
532
|
+
if (freq === 'DAILY') {
|
|
533
|
+
// Increment day; handle month/year overflow via Date
|
|
534
|
+
const next = new Date(Date.UTC(year, month - 1, day + interval * 1, hour, minute, second));
|
|
535
|
+
const nextWc = `${next.getUTCFullYear()}-${String(next.getUTCMonth() + 1).padStart(2, '0')}-${String(next.getUTCDate()).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
|
|
536
|
+
return wallClockToUtcMs(nextWc, tzid);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (freq === 'WEEKLY') {
|
|
540
|
+
const next = new Date(Date.UTC(year, month - 1, day + interval * 7, hour, minute, second));
|
|
541
|
+
const nextWc = `${next.getUTCFullYear()}-${String(next.getUTCMonth() + 1).padStart(2, '0')}-${String(next.getUTCDate()).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
|
|
542
|
+
return wallClockToUtcMs(nextWc, tzid);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// MONTHLY: advance month, keep day/time (clamp to month end)
|
|
546
|
+
month += interval;
|
|
547
|
+
year += Math.floor((month - 1) / 12);
|
|
548
|
+
month = ((month - 1) % 12) + 1;
|
|
549
|
+
// Clamp day to end of target month
|
|
550
|
+
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
551
|
+
day = Math.min(day, daysInMonth);
|
|
552
|
+
const nextWc = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
|
|
553
|
+
return wallClockToUtcMs(nextWc, tzid);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ──────────────────────────────────────────────────────────────────
|
|
557
|
+
// Renderer
|
|
558
|
+
// ──────────────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
/** Format a Date as iCalendar DATE value: YYYYMMDD */
|
|
561
|
+
function formatIcsDate(date: Date): string {
|
|
562
|
+
const y = date.getUTCFullYear();
|
|
563
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
564
|
+
const d = String(date.getUTCDate()).padStart(2, '0');
|
|
565
|
+
return `${y}${m}${d}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Format an ISO string as iCalendar DATE-TIME UTC value: YYYYMMDDTHHMMSSZ */
|
|
569
|
+
function formatIcsDateTimeUtc(iso: string): string {
|
|
570
|
+
const normalized = iso.endsWith('Z') ? iso : `${iso}Z`;
|
|
571
|
+
const d = new Date(normalized);
|
|
572
|
+
if (isNaN(d.getTime())) return iso.replace(/[-:]/g, '').replace(' ', 'T');
|
|
573
|
+
const y = d.getUTCFullYear();
|
|
574
|
+
const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
575
|
+
const da = String(d.getUTCDate()).padStart(2, '0');
|
|
576
|
+
const h = String(d.getUTCHours()).padStart(2, '0');
|
|
577
|
+
const mi = String(d.getUTCMinutes()).padStart(2, '0');
|
|
578
|
+
const s = String(d.getUTCSeconds()).padStart(2, '0');
|
|
579
|
+
return `${y}${mo}${da}T${h}${mi}${s}Z`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Format a wall-clock ISO string ("YYYY-MM-DDTHH:MM:SS") as compact iCalendar
|
|
584
|
+
* local form: YYYYMMDDTHHMMSS (no Z suffix, for use with TZID parameter).
|
|
585
|
+
*/
|
|
586
|
+
function formatIcsDateTimeLocal(iso: string): string {
|
|
587
|
+
// iso is already in "YYYY-MM-DDTHH:MM:SS" form (no Z)
|
|
588
|
+
return iso.replace(/[-:]/g, '').replace(' ', 'T').replace('Z', '');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Produce a folded iCalendar content line from a name=value pair. */
|
|
592
|
+
function icsLine(name: string, value: string, params?: string): string {
|
|
593
|
+
const head = params ? `${name};${params}` : name;
|
|
594
|
+
return foldLine(`${head}:${value}`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Render an array of IcsEvent objects into a valid VCALENDAR string.
|
|
599
|
+
*
|
|
600
|
+
* Text properties are escaped and lines are folded at 75 octets per RFC 5545.
|
|
601
|
+
*
|
|
602
|
+
* DTSTART rendering rules:
|
|
603
|
+
* - All-day: VALUE=DATE form (YYYYMMDD), no TZID.
|
|
604
|
+
* - UTC (dtstart ends with Z): YYYYMMDDTHHMMSSZ.
|
|
605
|
+
* - TZID-qualified: DTSTART;TZID=Zone:YYYYMMDDTHHMMSS (wall-clock, no Z).
|
|
606
|
+
* Note: emitted without VTIMEZONE block. Tolerant readers accept this;
|
|
607
|
+
* strict RFC 5545 readers may reject it (documented limitation).
|
|
608
|
+
* - Floating: YYYYMMDDTHHMMSS (no Z, no TZID).
|
|
609
|
+
*/
|
|
610
|
+
export function renderIcs(events: readonly IcsEvent[]): string {
|
|
611
|
+
const lines: string[] = [
|
|
612
|
+
'BEGIN:VCALENDAR',
|
|
613
|
+
'PRODID:-//GoodVibes Agent//Calendar//EN',
|
|
614
|
+
'VERSION:2.0',
|
|
615
|
+
'CALSCALE:GREGORIAN',
|
|
616
|
+
'METHOD:PUBLISH',
|
|
617
|
+
];
|
|
618
|
+
|
|
619
|
+
for (const event of events) {
|
|
620
|
+
lines.push('BEGIN:VEVENT');
|
|
621
|
+
lines.push(icsLine('UID', escapeText(event.uid)));
|
|
622
|
+
lines.push(icsLine('SUMMARY', escapeText(event.summary)));
|
|
623
|
+
|
|
624
|
+
if (event.allDay) {
|
|
625
|
+
// All-day: strip dashes and emit as DATE
|
|
626
|
+
const raw = event.dtstart.replace(/-/g, '');
|
|
627
|
+
lines.push(icsLine('DTSTART', raw, 'VALUE=DATE'));
|
|
628
|
+
if (event.dtend) {
|
|
629
|
+
lines.push(icsLine('DTEND', event.dtend.replace(/-/g, ''), 'VALUE=DATE'));
|
|
630
|
+
}
|
|
631
|
+
} else if (event.tzid) {
|
|
632
|
+
// TZID-qualified: emit wall-clock local time with TZID parameter.
|
|
633
|
+
// The dtstart field already holds the wall-clock (no Z suffix) from parse.
|
|
634
|
+
const localCompact = formatIcsDateTimeLocal(event.dtstart);
|
|
635
|
+
lines.push(icsLine('DTSTART', localCompact, `TZID=${event.tzid}`));
|
|
636
|
+
if (event.dtend) {
|
|
637
|
+
const endCompact = formatIcsDateTimeLocal(event.dtend);
|
|
638
|
+
lines.push(icsLine('DTEND', endCompact, `TZID=${event.tzid}`));
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
// UTC or floating: emit with Z suffix (or as-is for floating)
|
|
642
|
+
lines.push(icsLine('DTSTART', formatIcsDateTimeUtc(event.dtstart)));
|
|
643
|
+
if (event.dtend) {
|
|
644
|
+
lines.push(icsLine('DTEND', formatIcsDateTimeUtc(event.dtend)));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (event.description) {
|
|
649
|
+
lines.push(icsLine('DESCRIPTION', escapeText(event.description)));
|
|
650
|
+
}
|
|
651
|
+
if (event.location) {
|
|
652
|
+
lines.push(icsLine('LOCATION', escapeText(event.location)));
|
|
653
|
+
}
|
|
654
|
+
if (event.rrule) {
|
|
655
|
+
lines.push(icsLine('RRULE', event.rrule));
|
|
656
|
+
}
|
|
657
|
+
lines.push('END:VEVENT');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
lines.push('END:VCALENDAR');
|
|
661
|
+
return lines.map((l) => `${l}\r\n`).join('');
|
|
662
|
+
}
|