1c0a7cb850
Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass.
239 lines
6.0 KiB
TypeScript
239 lines
6.0 KiB
TypeScript
/**
|
|
* Calendar service — fetches and parses iCal (.ics) files.
|
|
* Uses lightweight hand-parsing of VEVENT blocks (no heavy dependencies).
|
|
*/
|
|
|
|
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
const DEFAULT_DAYS_AHEAD = 14;
|
|
|
|
interface CacheEntry {
|
|
readonly data: string; // raw ical text
|
|
readonly expiresAt: number;
|
|
}
|
|
|
|
export interface CalendarEvent {
|
|
readonly summary: string;
|
|
readonly start: string;
|
|
readonly end: string;
|
|
readonly location: string | null;
|
|
readonly calendarLabel: string;
|
|
readonly calendarColor: string;
|
|
}
|
|
|
|
export interface CalendarSource {
|
|
readonly url: string;
|
|
readonly color?: string;
|
|
readonly label?: string;
|
|
}
|
|
|
|
const cache = new Map<string, CacheEntry>();
|
|
|
|
function getCached(key: string): string | null {
|
|
const entry = cache.get(key);
|
|
if (!entry) return null;
|
|
if (Date.now() > entry.expiresAt) {
|
|
cache.delete(key);
|
|
return null;
|
|
}
|
|
return entry.data;
|
|
}
|
|
|
|
function setCache(key: string, data: string): void {
|
|
cache.set(key, {
|
|
data,
|
|
expiresAt: Date.now() + CACHE_TTL_MS
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse an iCal date string (YYYYMMDD, YYYYMMDDTHHmmssZ, YYYYMMDDTHHmmss).
|
|
*/
|
|
function parseIcalDate(dateStr: string): Date | null {
|
|
if (!dateStr) return null;
|
|
|
|
// Remove TZID parameter prefix if present
|
|
const clean = dateStr.replace(/^.*:/, '').trim();
|
|
|
|
// All-day event: YYYYMMDD
|
|
if (/^\d{8}$/.test(clean)) {
|
|
const year = parseInt(clean.substring(0, 4), 10);
|
|
const month = parseInt(clean.substring(4, 6), 10) - 1;
|
|
const day = parseInt(clean.substring(6, 8), 10);
|
|
return new Date(year, month, day);
|
|
}
|
|
|
|
// DateTime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ
|
|
const dtMatch = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
|
|
if (dtMatch) {
|
|
const [, year, month, day, hour, minute, second, utc] = dtMatch;
|
|
if (utc === 'Z') {
|
|
return new Date(
|
|
Date.UTC(
|
|
parseInt(year, 10),
|
|
parseInt(month, 10) - 1,
|
|
parseInt(day, 10),
|
|
parseInt(hour, 10),
|
|
parseInt(minute, 10),
|
|
parseInt(second, 10)
|
|
)
|
|
);
|
|
}
|
|
return new Date(
|
|
parseInt(year, 10),
|
|
parseInt(month, 10) - 1,
|
|
parseInt(day, 10),
|
|
parseInt(hour, 10),
|
|
parseInt(minute, 10),
|
|
parseInt(second, 10)
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract a property value from an iCal VEVENT block.
|
|
* Handles folded lines (continuation lines starting with space/tab).
|
|
*/
|
|
function extractProperty(block: string, property: string): string {
|
|
// Match property with optional parameters (e.g., DTSTART;TZID=...:value)
|
|
const regex = new RegExp(`^${property}[;:](.*)$`, 'im');
|
|
const match = block.match(regex);
|
|
if (!match) return '';
|
|
|
|
const value = match[1];
|
|
|
|
// If the property had parameters (;PARAM=value:actualValue), extract just the value
|
|
if (property === 'DTSTART' || property === 'DTEND') {
|
|
// Keep the full string — parseIcalDate handles TZID prefix
|
|
return value.trim();
|
|
}
|
|
|
|
return value.trim();
|
|
}
|
|
|
|
/**
|
|
* Parse VEVENT blocks from iCal text.
|
|
*/
|
|
function parseVEvents(
|
|
icalText: string
|
|
): Array<{ summary: string; start: string; end: string; location: string }> {
|
|
const events: Array<{ summary: string; start: string; end: string; location: string }> = [];
|
|
|
|
// Unfold continuation lines (RFC 5545: lines starting with space/tab are continuations)
|
|
const unfolded = icalText.replace(/\r?\n[ \t]/g, '');
|
|
|
|
const eventRegex = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/gi;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = eventRegex.exec(unfolded)) !== null) {
|
|
const block = match[1];
|
|
const summary = extractProperty(block, 'SUMMARY');
|
|
const dtStart = extractProperty(block, 'DTSTART');
|
|
const dtEnd = extractProperty(block, 'DTEND');
|
|
const location = extractProperty(block, 'LOCATION');
|
|
|
|
const startDate = parseIcalDate(dtStart);
|
|
if (!startDate) continue;
|
|
|
|
const endDate = parseIcalDate(dtEnd);
|
|
|
|
events.push({
|
|
summary: summary || 'Untitled Event',
|
|
start: startDate.toISOString(),
|
|
end: endDate ? endDate.toISOString() : startDate.toISOString(),
|
|
location: location || ''
|
|
});
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Fetch iCal text from a URL.
|
|
*/
|
|
async function fetchIcalText(url: string): Promise<string> {
|
|
const cached = getCached(url);
|
|
if (cached) return cached;
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
'User-Agent': 'WebAppLauncher/1.0',
|
|
Accept: 'text/calendar, application/ics'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Calendar source returned ${response.status}`);
|
|
}
|
|
|
|
const text = await response.text();
|
|
setCache(url, text);
|
|
return text;
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
throw new Error('Calendar request timed out');
|
|
}
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch and parse events from multiple iCal URLs, merged and sorted by start time.
|
|
*/
|
|
export async function fetchCalendarEvents(
|
|
sources: readonly CalendarSource[],
|
|
daysAhead?: number
|
|
): Promise<readonly CalendarEvent[]> {
|
|
const days = daysAhead ?? DEFAULT_DAYS_AHEAD;
|
|
const now = new Date();
|
|
const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
|
|
|
const allEvents: CalendarEvent[] = [];
|
|
|
|
const results = await Promise.allSettled(
|
|
sources.map(async (source) => {
|
|
const icalText = await fetchIcalText(source.url);
|
|
const events = parseVEvents(icalText);
|
|
|
|
return events
|
|
.filter((event) => {
|
|
const start = new Date(event.start);
|
|
return start >= now && start <= cutoff;
|
|
})
|
|
.map((event) => ({
|
|
summary: event.summary,
|
|
start: event.start,
|
|
end: event.end,
|
|
location: event.location || null,
|
|
calendarLabel: source.label ?? 'Calendar',
|
|
calendarColor: source.color ?? '#6366f1'
|
|
}));
|
|
})
|
|
);
|
|
|
|
for (const result of results) {
|
|
if (result.status === 'fulfilled') {
|
|
allEvents.push(...result.value);
|
|
}
|
|
}
|
|
|
|
// Sort by start time ascending
|
|
return [...allEvents].sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
|
}
|
|
|
|
/**
|
|
* Clear the calendar cache.
|
|
*/
|
|
export function clearCache(): void {
|
|
cache.clear();
|
|
}
|