128 lines
3.9 KiB
TypeScript
128 lines
3.9 KiB
TypeScript
type TimeRange = { start: Date; end: Date };
|
|
|
|
type CacheEntry = { expiresAt: number; ranges: TimeRange[] };
|
|
|
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
const MAX_RANGE_DAYS = 365; // guard against unbounded events
|
|
|
|
const globalCache = (globalThis as any).__icalCache || new Map<string, CacheEntry>();
|
|
(globalThis as any).__icalCache = globalCache;
|
|
|
|
function parseDateValue(raw: string): Date | null {
|
|
if (!raw) return null;
|
|
// Format examples: 20250101, 20250101T120000Z, 20250101T120000
|
|
if (/^\d{8}$/.test(raw)) {
|
|
const year = Number(raw.slice(0, 4));
|
|
const month = Number(raw.slice(4, 6)) - 1;
|
|
const day = Number(raw.slice(6, 8));
|
|
return new Date(Date.UTC(year, month, day));
|
|
}
|
|
const parsed = new Date(raw);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
function normalizeRange(startRaw: string | null, endRaw: string | null): TimeRange | null {
|
|
const start = startRaw ? parseDateValue(startRaw) : null;
|
|
let end = endRaw ? parseDateValue(endRaw) : null;
|
|
|
|
if (!start) return null;
|
|
if (!end) {
|
|
end = new Date(start);
|
|
end.setUTCDate(end.getUTCDate() + 1);
|
|
}
|
|
|
|
// Clamp absurdly long ranges
|
|
const maxEnd = new Date(start);
|
|
maxEnd.setUTCDate(maxEnd.getUTCDate() + MAX_RANGE_DAYS);
|
|
if (end > maxEnd) end = maxEnd;
|
|
|
|
if (end <= start) return null;
|
|
return { start, end };
|
|
}
|
|
|
|
function parseIcs(text: string): TimeRange[] {
|
|
const lines = text.split(/\r?\n/);
|
|
const ranges: TimeRange[] = [];
|
|
let current: { dtstart?: string; dtend?: string; status?: string } | null = null;
|
|
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.trim();
|
|
if (line === 'BEGIN:VEVENT') {
|
|
current = {};
|
|
continue;
|
|
}
|
|
if (line === 'END:VEVENT') {
|
|
if (current && current.status !== 'CANCELLED') {
|
|
const range = normalizeRange(current.dtstart || null, current.dtend || null);
|
|
if (range) ranges.push(range);
|
|
}
|
|
current = null;
|
|
continue;
|
|
}
|
|
if (!current) continue;
|
|
|
|
if (line.startsWith('DTSTART')) {
|
|
const [, value] = line.split(':');
|
|
current.dtstart = value;
|
|
} else if (line.startsWith('DTEND')) {
|
|
const [, value] = line.split(':');
|
|
current.dtend = value;
|
|
} else if (line.startsWith('STATUS')) {
|
|
const [, value] = line.split(':');
|
|
current.status = value;
|
|
}
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
async function fetchCalendarUrl(url: string): Promise<TimeRange[]> {
|
|
const cached = globalCache.get(url) as CacheEntry | undefined;
|
|
const now = Date.now();
|
|
if (cached && cached.expiresAt > now) {
|
|
return cached.ranges;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(url, { headers: { 'User-Agent': 'lomavuokraus-ical/1.0' }, cache: 'no-store' });
|
|
if (!res.ok) throw new Error(`Fetch failed (${res.status})`);
|
|
const text = await res.text();
|
|
const ranges = parseIcs(text);
|
|
globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges });
|
|
return ranges;
|
|
} catch (err) {
|
|
console.error('Failed to fetch calendar', url, err);
|
|
globalCache.set(url, { expiresAt: now + CACHE_TTL_MS, ranges: [] });
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getCalendarRanges(urls: string[]): Promise<TimeRange[]> {
|
|
const unique = Array.from(new Set(urls.filter(Boolean)));
|
|
if (!unique.length) return [];
|
|
const results = await Promise.all(unique.map(fetchCalendarUrl));
|
|
return results.flat();
|
|
}
|
|
|
|
export function isRangeAvailable(ranges: TimeRange[], start: Date, end: Date): boolean {
|
|
for (const r of ranges) {
|
|
if (r.start < end && r.end > start) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function expandBlockedDates(ranges: TimeRange[], from: Date, to: Date): string[] {
|
|
const days: string[] = [];
|
|
const cursor = new Date(from);
|
|
while (cursor <= to) {
|
|
const next = new Date(cursor);
|
|
next.setUTCDate(next.getUTCDate() + 1);
|
|
if (!isRangeAvailable(ranges, cursor, next)) {
|
|
days.push(cursor.toISOString().slice(0, 10));
|
|
}
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
return days;
|
|
}
|