Files
web-app-launcher/src/lib/server/services/calendarService.ts
T
alexei.dolgolyov 1c0a7cb850 feat: Phases 4-7 — Full Feature Expansion (26 features)
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.
2026-03-25 14:18:10 +03:00

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();
}