@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.
- package/dist/calendar.js +2198 -1539
- package/package.json +2 -2
- package/src/CalDAVConfig.ts +50 -1
- package/src/CalDAVSource.ts +52 -19
- package/src/CalendarIntegration.ts +2 -2
- package/src/CalendarInternal.ts +10 -4
- package/src/CalendarLayer.ts +28 -0
- package/src/CalendarView.ts +517 -1146
- package/src/DescriptionSanitizer.ts +10 -0
- package/src/GoogleCalendarSource.ts +27 -1
- package/src/ICal.ts +7 -2
- package/src/InMemorySource.ts +6 -6
- package/src/IndexedDBStorage.ts +6 -0
- package/src/InhouseBookingSource.ts +2 -1
- package/src/Keybinds.ts +3 -18
- package/src/StatusBar.ts +11 -0
- package/src/Theme.ts +4 -4
- package/src/TimeseriesJson.ts +114 -0
- package/src/app.ts +199 -48
- package/src/layers/EventsLayer.ts +958 -0
- package/src/layers/GridLayer.ts +296 -0
- package/src/layers/TimeseriesHeatmapLayer.ts +132 -0
- package/src/lib.ts +1 -0
|
@@ -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(
|
|
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
|
|
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":
|
|
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;
|
package/src/InMemorySource.ts
CHANGED
|
@@ -44,17 +44,17 @@ export class InMemorySource implements CalendarSource {
|
|
|
44
44
|
return newEvent;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
async updateEvent(
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
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
|
-
...
|
|
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
|
|
package/src/IndexedDBStorage.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
+
}
|