@luckydye/calendar 1.3.0 → 1.3.2

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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Removes transport/tracking links commonly injected by email/calendar clients.
3
+ * First pass intentionally targets angle-bracket URI blobs like
4
+ * <https://...>, <mailto:...>, <tel:...>, <im:...>.
5
+ */
6
+ export function sanitizeEventDescription(input: string): string {
7
+ if (!input) return input;
8
+
9
+ return input.replace(/<(?:https?|mailto|tel|im):[^>\s]+>/gi, "");
10
+ }
@@ -429,7 +429,33 @@ export class GoogleCalendarSource implements CalendarSource {
429
429
  /**
430
430
  * Update an existing event in Google Calendar.
431
431
  */
432
- async updateEvent(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
432
+ async updateEvent(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
433
+ const id = event.id;
434
+ const isRsvpOnly = Object.keys(updates).length === 1 && updates.attendees !== undefined;
435
+
436
+ if (isRsvpOnly) {
437
+ // PATCH with only attendees — allowed for non-organizers updating their own response status
438
+ const response = await this.apiRequest<GoogleCalendarEvent>(
439
+ `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`,
440
+ {
441
+ method: 'PATCH',
442
+ body: JSON.stringify({
443
+ attendees: updates.attendees!.map(att => ({
444
+ email: att.email,
445
+ displayName: att.name,
446
+ responseStatus: this.mapToGoogleResponseStatus(att.status),
447
+ optional: att.role === 'OPT-PARTICIPANT',
448
+ })),
449
+ }),
450
+ }
451
+ );
452
+ const updated = this.mapGoogleEvent(response);
453
+ if (!updated) {
454
+ throw new Error('Failed to update event: invalid response from Google Calendar');
455
+ }
456
+ return updated;
457
+ }
458
+
433
459
  const existing = await this.apiRequest<GoogleCalendarEvent>(
434
460
  `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`
435
461
  );
package/src/ICal.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CalendarCredentials } from './CalendarIntegration.js';
2
2
  import type { CalendarEvent, Attendee, Organizer, NotificationConfig } from './CalendarInternal.js';
3
+ import { sanitizeEventDescription } from './DescriptionSanitizer.js';
3
4
 
4
5
  export interface ParseResult {
5
6
  events: CalendarEvent[];
@@ -50,7 +51,9 @@ export function parseICalEventsWithNotifications(icalText: string, color: string
50
51
  end: (currentEvent.end instanceof Date ? currentEvent.end : currentEvent.start) as Date,
51
52
  color,
52
53
  calendar,
53
- description: currentEvent.description ? String(currentEvent.description) : undefined,
54
+ description: currentEvent.description
55
+ ? sanitizeEventDescription(String(currentEvent.description))
56
+ : undefined,
54
57
  location: currentEvent.location ? String(currentEvent.location) : undefined,
55
58
  url: currentEvent.url ? String(currentEvent.url) : undefined,
56
59
  organizer: currentEvent.organizer as Organizer | undefined,
@@ -203,7 +206,9 @@ export function parseSingleICalEvent(icalText: string): Partial<CalendarEvent> {
203
206
  if (keyPart.includes("VALUE=DATE")) raw.isAllDay = true;
204
207
  break;
205
208
  case "DTEND": raw.end = parseICalDate(value, keyPart); break;
206
- case "DESCRIPTION": raw.description = value.replace(/\\n/g, "\n"); break;
209
+ case "DESCRIPTION":
210
+ raw.description = sanitizeEventDescription(value.replace(/\\n/g, "\n"));
211
+ break;
207
212
  case "LOCATION": raw.location = value; break;
208
213
  case "URL": raw.url = value; break;
209
214
  case "RRULE": raw.rrule = value.trim(); break;
@@ -44,17 +44,17 @@ export class InMemorySource implements CalendarSource {
44
44
  return newEvent;
45
45
  }
46
46
 
47
- async updateEvent(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
48
- const event = this.events.get(id);
49
- if (!event) {
50
- throw new Error(`Event ${id} not found`);
47
+ async updateEvent(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
48
+ const existing = this.events.get(event.id);
49
+ if (!existing) {
50
+ throw new Error(`Event ${event.id} not found`);
51
51
  }
52
52
 
53
53
  const updated = {
54
- ...event,
54
+ ...existing,
55
55
  ...updates,
56
56
  };
57
- this.events.set(id, updated);
57
+ this.events.set(event.id, updated);
58
58
  return updated;
59
59
  }
60
60
 
@@ -21,6 +21,8 @@ interface SerializedEvent {
21
21
  status?: 'TENTATIVE' | 'CONFIRMED' | 'CANCELLED';
22
22
  isAllDay?: boolean;
23
23
  reminders?: NotificationConfig[];
24
+ visualStyle?: 'heatmap';
25
+ resourceUrl?: string;
24
26
  }
25
27
 
26
28
  interface SerializedMetadata {
@@ -326,6 +328,8 @@ export class IndexedDBStorage implements CalendarStorage {
326
328
  status: event.status,
327
329
  isAllDay: event.isAllDay,
328
330
  reminders: event.reminders,
331
+ visualStyle: event.visualStyle,
332
+ resourceUrl: event.resourceUrl,
329
333
  };
330
334
  }
331
335
 
@@ -375,6 +379,8 @@ export class IndexedDBStorage implements CalendarStorage {
375
379
  status: serialized.status,
376
380
  isAllDay: serialized.isAllDay,
377
381
  reminders: serialized.reminders,
382
+ visualStyle: serialized.visualStyle,
383
+ resourceUrl: serialized.resourceUrl,
378
384
  };
379
385
  }
380
386
 
@@ -432,11 +432,12 @@ export class InhouseBookingSource implements CalendarSource {
432
432
  /**
433
433
  * Update an existing booking in the Inhouse Booking System.
434
434
  */
435
- async updateEvent(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
435
+ async updateEvent(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
436
436
  if (!this.enabled) {
437
437
  throw new Error('Inhouse Booking System source is disabled');
438
438
  }
439
439
 
440
+ const id = event.id;
440
441
  const parsedId = this.parseEventId(id);
441
442
  if (!parsedId) {
442
443
  throw new Error(`Invalid event ID format: ${id}`);
package/src/Keybinds.ts CHANGED
@@ -1,16 +1,3 @@
1
- const INPUT_SELECTOR = 'input, textarea, [contenteditable="true"]';
2
-
3
- function isInputFocused(shadowHost: Element): boolean {
4
- const active = document.activeElement;
5
- if (active?.matches(INPUT_SELECTOR)) return true;
6
- // Shadow DOM: the host element appears as activeElement when focus is inside it
7
- if (active === shadowHost) {
8
- const shadowActive = shadowHost.shadowRoot?.activeElement;
9
- if (shadowActive?.matches(INPUT_SELECTOR)) return true;
10
- }
11
- return false;
12
- }
13
-
14
1
  export interface Keybind {
15
2
  key: string;
16
3
  /** Matches Cmd (Mac) or Ctrl (Win/Linux) */
@@ -19,12 +6,10 @@ export interface Keybind {
19
6
  action: () => void;
20
7
  }
21
8
 
22
- export function registerKeybinds(
23
- bindings: Keybind[],
24
- shadowHost: Element,
25
- ): () => void {
9
+ export function registerKeybinds(bindings: Keybind[]): () => void {
26
10
  const handler = (e: KeyboardEvent): void => {
27
- if (isInputFocused(shadowHost)) return;
11
+ const focused = e.composedPath()[0] as HTMLElement;
12
+ if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
28
13
 
29
14
  for (const binding of bindings) {
30
15
  const keyMatch = e.key === binding.key;
package/src/StatusBar.ts CHANGED
@@ -10,6 +10,7 @@ export interface StatusBarData {
10
10
  formattedDate: string;
11
11
  formattedTime: string;
12
12
  formattedCursorDate: string;
13
+ altKeyActive: boolean;
13
14
  }
14
15
 
15
16
  export class StatusBarElement extends LitElement {
@@ -62,6 +63,14 @@ export class StatusBarElement extends LitElement {
62
63
  .status-value {
63
64
  color: var(--text-secondary, rgba(255, 255, 255, 0.7));
64
65
  }
66
+
67
+ .meta-key {
68
+ padding: 1px 5px;
69
+ border-radius: 3px;
70
+ border: 1px solid var(--text-muted, rgba(255, 255, 255, 0.4));
71
+ font-size: 9px;
72
+ color: var(--text-secondary, rgba(255, 255, 255, 0.7));
73
+ }
65
74
  `;
66
75
 
67
76
  render() {
@@ -82,6 +91,8 @@ export class StatusBarElement extends LitElement {
82
91
  </div>
83
92
 
84
93
  <status-message></status-message>
94
+
95
+ ${this.data.altKeyActive ? html`<span class="meta-key">Meta</span>` : html``}
85
96
  </div>
86
97
 
87
98
  <div class="status-bar-right">
package/src/Theme.ts CHANGED
@@ -23,7 +23,7 @@ export const themes: Record<ConcreteThemeName, ThemeDefinition> = {
23
23
  "--bg-button-active": "rgba(255, 255, 255, 0.1)",
24
24
  "--bg-today": "rgba(255, 255, 255, 0.05)",
25
25
  "--bg-selection": "rgba(255, 255, 255, 0.1)",
26
- "--bg-weekend": "rgba(255, 255, 255, 0.03)",
26
+ "--bg-weekend": "transparent",
27
27
  "--bg-item": "rgba(255, 255, 255, 0.05)",
28
28
  "--bg-item-hover": "rgba(255, 255, 255, 0.08)",
29
29
  "--grid-color": "rgba(255, 255, 255, 0.1)",
@@ -59,7 +59,7 @@ export const themes: Record<ConcreteThemeName, ThemeDefinition> = {
59
59
  "--bg-button-active": "rgba(0, 0, 0, 0.08)",
60
60
  "--bg-today": "rgba(0, 100, 255, 0.08)",
61
61
  "--bg-selection": "rgba(0, 100, 255, 0.15)",
62
- "--bg-weekend": "rgba(0, 0, 0, 0.02)",
62
+ "--bg-weekend": "transparent",
63
63
  "--bg-item": "rgba(0, 0, 0, 0.03)",
64
64
  "--bg-item-hover": "rgba(0, 0, 0, 0.06)",
65
65
  "--grid-color": "rgba(0, 0, 0, 0.08)",
@@ -95,7 +95,7 @@ export const themes: Record<ConcreteThemeName, ThemeDefinition> = {
95
95
  "--bg-button-active": "rgba(255, 255, 255, 0.1)",
96
96
  "--bg-today": "rgba(181, 137, 0, 0.15)",
97
97
  "--bg-selection": "rgba(181, 137, 0, 0.25)",
98
- "--bg-weekend": "rgba(255, 255, 255, 0.03)",
98
+ "--bg-weekend": "transparent",
99
99
  "--bg-item": "rgba(255, 255, 255, 0.05)",
100
100
  "--bg-item-hover": "rgba(255, 255, 255, 0.08)",
101
101
  "--grid-color": "rgba(131, 148, 150, 0.2)",
@@ -131,7 +131,7 @@ export const themes: Record<ConcreteThemeName, ThemeDefinition> = {
131
131
  "--bg-button-active": "rgb(60, 60, 60)",
132
132
  "--bg-today": "rgb(30, 30, 30)",
133
133
  "--bg-selection": "rgb(255, 255, 0)",
134
- "--bg-weekend": "rgba(255, 255, 255, 0.05)",
134
+ "--bg-weekend": "transparent",
135
135
  "--bg-item": "rgb(20, 20, 20)",
136
136
  "--bg-item-hover": "rgb(40, 40, 40)",
137
137
  "--grid-color": "rgb(80, 80, 80)",
@@ -0,0 +1,114 @@
1
+ import type { CalendarCredentials } from "./CalendarIntegration.js";
2
+ import type { CalendarEvent } from "./CalendarInternal.js";
3
+
4
+ export interface TimeseriesJsonCredentials extends CalendarCredentials {
5
+ url: string;
6
+ timestampField?: string;
7
+ titleField?: string;
8
+ }
9
+
10
+ function parseTimestamp(value: unknown): Date | null {
11
+ if (!value) return null;
12
+ if (value instanceof Date) {
13
+ return Number.isNaN(value.getTime()) ? null : value;
14
+ }
15
+ if (typeof value === "number") {
16
+ const normalized = value < 1_000_000_000_000 ? value * 1000 : value;
17
+ const date = new Date(normalized);
18
+ return Number.isNaN(date.getTime()) ? null : date;
19
+ }
20
+ if (typeof value === "string") {
21
+ const date = new Date(value);
22
+ return Number.isNaN(date.getTime()) ? null : date;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function readStringField(
28
+ record: Record<string, unknown>,
29
+ field: string,
30
+ ): string | undefined {
31
+ const value = record[field];
32
+ if (typeof value !== "string") return undefined;
33
+ const trimmed = value.trim();
34
+ return trimmed ? trimmed : undefined;
35
+ }
36
+
37
+ export async function fetchTimeseriesJsonEvents(
38
+ credentials: TimeseriesJsonCredentials,
39
+ color: string,
40
+ calendar: string,
41
+ sourceId?: string,
42
+ ): Promise<CalendarEvent[]> {
43
+ if (!credentials.url) {
44
+ throw new Error("Timeseries JSON URL is required");
45
+ }
46
+
47
+ const response = await fetch(credentials.url, { cache: "no-store" });
48
+ if (!response.ok) {
49
+ throw new Error(
50
+ `Failed to fetch Timeseries JSON: ${response.status} ${response.statusText}`,
51
+ );
52
+ }
53
+
54
+ const data = await response.json();
55
+ if (!Array.isArray(data)) {
56
+ throw new Error("Timeseries JSON must be an array of objects");
57
+ }
58
+
59
+ const timestampField = credentials.timestampField || "timestamp";
60
+ const titleField = credentials.titleField || "title";
61
+ const defaultDurationMinutes = 1;
62
+
63
+ const events: CalendarEvent[] = [];
64
+
65
+ data.forEach((raw, index) => {
66
+ if (!raw || typeof raw !== "object") return;
67
+ const record = raw as Record<string, unknown>;
68
+
69
+ const start = parseTimestamp(record[timestampField]);
70
+ if (!start) return;
71
+
72
+ const isAllDay = Boolean(record.isAllDay ?? record.allDay);
73
+ const durationMinutes =
74
+ typeof record.durationMinutes === "number" &&
75
+ Number.isFinite(record.durationMinutes) &&
76
+ record.durationMinutes > 0
77
+ ? record.durationMinutes
78
+ : defaultDurationMinutes;
79
+
80
+ let end =
81
+ parseTimestamp(record.end) ||
82
+ parseTimestamp(record.endTimestamp) ||
83
+ parseTimestamp(record.endTime);
84
+
85
+ if (!end || end <= start) {
86
+ const fallbackDuration = isAllDay ? 24 * 60 : durationMinutes;
87
+ end = new Date(start.getTime() + fallbackDuration * 60 * 1000);
88
+ }
89
+
90
+ const title = readStringField(record, titleField) || calendar;
91
+ const id =
92
+ readStringField(record, "id") ||
93
+ `${sourceId || calendar}-${start.getTime()}-${index}`;
94
+
95
+ events.push({
96
+ id,
97
+ title,
98
+ start,
99
+ end,
100
+ color,
101
+ calendar,
102
+ calendarId: sourceId,
103
+ sourceId,
104
+ description: readStringField(record, "description"),
105
+ location: readStringField(record, "location"),
106
+ url: readStringField(record, "url"),
107
+ readOnly: true,
108
+ isAllDay: isAllDay || undefined,
109
+ visualStyle: "heatmap",
110
+ });
111
+ });
112
+
113
+ return events;
114
+ }