/** * Local file-backed calendar event store (Calendar Events v0 — Phase 1B). * * Persists source calendars and normalized events per vault under data_dir. * Self-hosted Hub only in v0; hosted canister parity follows later. * * @see docs/CALENDAR-EVENTS-V0-SPEC.md */ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { randomUUID } from 'crypto'; import { parseIcsToEvents } from './ics-normalizer.mjs'; import { buildSourceCalendarDefaults } from './source-calendar-defaults.mjs'; const STORE_FILENAME = 'hub_calendar_store.json'; const MAX_ICS_IMPORT_BYTES = 5 * 1024 * 1024; /** @typedef {import('./source-calendar-defaults.mjs').AgentContextTier} AgentContextTier */ /** * @typedef {Object} StoredSourceCalendar * @property {string} source_calendar_id * @property {string} connector_id * @property {string} display_name * @property {string|null} [color] * @property {'personal'|'work'|'school'|'other'|null} [user_group] * @property {boolean} enabled_for_sync * @property {boolean} enabled_for_display * @property {boolean} enabled_for_agents * @property {AgentContextTier} agent_context_tier_max * @property {string} [provider] */ /** * @typedef {Object} StoredCalendarEvent * @property {string} event_id * @property {string} source_calendar_id * @property {string} external_uid * @property {string} start * @property {string} end * @property {string} timezone * @property {string|null} summary * @property {boolean} busy * @property {'confirmed'|'cancelled'|'tentative'} status * @property {string|null} recurrence_rule * @property {string[]|null} linked_note_paths * @property {string|null} deleted_at */ /** * @typedef {Object} VaultCalendarStore * @property {StoredSourceCalendar[]} source_calendars * @property {StoredCalendarEvent[]} events */ /** * @typedef {Object} CalendarStoreFile * @property {Record} vaults */ /** * @param {string} dataDir * @returns {string} */ export function getCalendarStorePath(dataDir) { return path.join(dataDir, STORE_FILENAME); } /** * @param {string} dataDir * @returns {CalendarStoreFile} */ export function loadCalendarStore(dataDir) { const filePath = getCalendarStorePath(dataDir); if (!fs.existsSync(filePath)) { return { vaults: {} }; } try { const raw = fs.readFileSync(filePath, 'utf8'); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || !parsed.vaults || typeof parsed.vaults !== 'object') { return { vaults: {} }; } return /** @type {CalendarStoreFile} */ (parsed); } catch { return { vaults: {} }; } } /** * @param {string} dataDir * @param {CalendarStoreFile} store */ export function saveCalendarStore(dataDir, store) { const filePath = getCalendarStorePath(dataDir); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`; fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8'); fs.renameSync(tmp, filePath); } /** * @param {string} dataDir * @param {string} vaultId * @returns {VaultCalendarStore} */ export function getVaultCalendarStore(dataDir, vaultId) { const store = loadCalendarStore(dataDir); if (!store.vaults[vaultId]) { store.vaults[vaultId] = { source_calendars: [], events: [] }; } return store.vaults[vaultId]; } /** * @param {string} sourceCalendarId * @param {string} externalUid * @returns {string} */ export function buildEventId(sourceCalendarId, externalUid) { const digest = crypto.createHash('sha256') .update(`${sourceCalendarId}:${externalUid}`, 'utf8') .digest('hex') .slice(0, 24); return `evt_${digest}`; } /** * @param {string} dataDir * @param {string} vaultId * @returns {StoredSourceCalendar[]} */ export function listSourceCalendars(dataDir, vaultId) { return getVaultCalendarStore(dataDir, vaultId).source_calendars.slice(); } /** * @param {string} dataDir * @param {string} vaultId * @param {string} sourceCalendarId * @returns {StoredSourceCalendar|undefined} */ export function getSourceCalendar(dataDir, vaultId, sourceCalendarId) { return getVaultCalendarStore(dataDir, vaultId) .source_calendars .find((c) => c.source_calendar_id === sourceCalendarId); } /** * @param {StoredSourceCalendar} calendar * @returns {object} */ export function sourceCalendarForClient(calendar) { return { source_calendar_id: calendar.source_calendar_id, connector_id: calendar.connector_id, display_name: calendar.display_name, color: calendar.color ?? null, user_group: calendar.user_group ?? null, enabled_for_sync: calendar.enabled_for_sync, enabled_for_display: calendar.enabled_for_display, enabled_for_agents: calendar.enabled_for_agents, agent_context_tier_max: calendar.agent_context_tier_max, provider: calendar.provider ?? 'ics_file', }; } /** * @param {string} dataDir * @param {string} vaultId * @param {{ * icsText: string, * displayName?: string, * sourceCalendarId?: string, * connectorId?: string, * defaultTimezone?: string, * }} input * @returns {{ source_calendar_id: string, connector_id: string, imported: number, updated: number }} */ export function importIcsIntoVault(dataDir, vaultId, input) { const icsText = input.icsText; if (typeof icsText !== 'string' || !icsText.trim()) { throw new TypeError('icsText is required'); } if (icsText.length > MAX_ICS_IMPORT_BYTES) { throw new RangeError(`ICS import exceeds ${MAX_ICS_IMPORT_BYTES} bytes`); } const store = loadCalendarStore(dataDir); if (!store.vaults[vaultId]) { store.vaults[vaultId] = { source_calendars: [], events: [] }; } const vaultStore = store.vaults[vaultId]; let sourceCalendarId = typeof input.sourceCalendarId === 'string' ? input.sourceCalendarId.trim() : ''; let connectorId = typeof input.connectorId === 'string' ? input.connectorId.trim() : ''; let sourceCalendar = sourceCalendarId ? vaultStore.source_calendars.find((c) => c.source_calendar_id === sourceCalendarId) : undefined; if (sourceCalendarId && !sourceCalendar) { throw new Error(`Source calendar not found: ${sourceCalendarId}`); } if (!sourceCalendar) { sourceCalendarId = `cal_${randomUUID().replace(/-/g, '').slice(0, 12)}`; connectorId = connectorId || `conn_ics_${randomUUID().replace(/-/g, '').slice(0, 12)}`; sourceCalendar = { source_calendar_id: sourceCalendarId, connector_id: connectorId, display_name: (input.displayName ?? 'Imported calendar').trim().slice(0, 120) || 'Imported calendar', color: null, user_group: null, provider: 'ics_file', ...buildSourceCalendarDefaults(), }; vaultStore.source_calendars.push(sourceCalendar); } else { connectorId = sourceCalendar.connector_id; } const normalized = parseIcsToEvents(icsText, { defaultTimezone: input.defaultTimezone ?? 'UTC', }); let imported = 0; let updated = 0; const byId = new Map(vaultStore.events.map((e) => [e.event_id, e])); for (const row of normalized) { const eventId = buildEventId(sourceCalendarId, row.external_uid); const existing = byId.get(eventId); const stored = { event_id: eventId, source_calendar_id: sourceCalendarId, external_uid: row.external_uid, start: row.start, end: row.end, timezone: row.timezone, summary: row.summary, busy: row.busy, status: row.status, recurrence_rule: row.recurrence_rule, linked_note_paths: existing?.linked_note_paths ?? null, deleted_at: existing?.deleted_at ?? null, }; if (existing) { Object.assign(existing, stored); updated += 1; } else { vaultStore.events.push(stored); byId.set(eventId, stored); imported += 1; } } saveCalendarStore(dataDir, store); return { source_calendar_id: sourceCalendarId, connector_id: connectorId, imported, updated, }; } /** * Query stored events overlapping a UTC range. * * @param {string} dataDir * @param {string} vaultId * @param {{ * fromIso: string, * toIso: string, * sourceCalendarIds?: string[], * displayOnly?: boolean, * }} query * @returns {StoredCalendarEvent[]} */ export function queryStoredEvents(dataDir, vaultId, query) { const vaultStore = getVaultCalendarStore(dataDir, vaultId); const fromMs = Date.parse(query.fromIso); const toMs = Date.parse(query.toIso); if (Number.isNaN(fromMs) || Number.isNaN(toMs) || toMs <= fromMs) { throw new RangeError('Invalid timeline range'); } const allowedCalendars = new Set( vaultStore.source_calendars .filter((c) => { if (query.displayOnly && !c.enabled_for_display) return false; return true; }) .map((c) => c.source_calendar_id), ); const filterIds = query.sourceCalendarIds?.length ? new Set(query.sourceCalendarIds.filter((id) => allowedCalendars.has(id))) : allowedCalendars; return vaultStore.events.filter((event) => { if (!filterIds.has(event.source_calendar_id)) return false; if (event.deleted_at) return false; const startMs = Date.parse(event.start); const endMs = Date.parse(event.end); return startMs < toMs && endMs > fromMs; }); }