timeline.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Unified calendar timeline merge — notes by date + stored external events. |
| 3 | * |
| 4 | * @see docs/CALENDAR-EVENTS-V0-SPEC.md — GET /api/v1/calendar/timeline |
| 5 | */ |
| 6 | |
| 7 | import { getNotesWithMeta, filterNotesByListOptions } from '../list-notes.mjs'; |
| 8 | import { queryStoredEvents, getVaultCalendarStore, sourceCalendarForClient } from './event-store.mjs'; |
| 9 | |
| 10 | /** @typedef {'note' | 'event'} TimelineKind */ |
| 11 | |
| 12 | const VALID_LAYERS = new Set(['notes', 'events']); |
| 13 | |
| 14 | /** |
| 15 | * @typedef {Object} TimelineNoteItem |
| 16 | * @property {'note'} kind |
| 17 | * @property {string} date — YYYY-MM-DD |
| 18 | * @property {string} path |
| 19 | * @property {string|null} title |
| 20 | * @property {string|null} project |
| 21 | * @property {string[]} tags |
| 22 | * @property {string} sort_at — ISO8601 for ordering |
| 23 | */ |
| 24 | |
| 25 | /** |
| 26 | * @typedef {Object} TimelineEventItem |
| 27 | * @property {'event'} kind |
| 28 | * @property {string} event_id |
| 29 | * @property {string} source_calendar_id |
| 30 | * @property {string} start |
| 31 | * @property {string} end |
| 32 | * @property {string} timezone |
| 33 | * @property {string|null} summary |
| 34 | * @property {boolean} busy |
| 35 | * @property {'confirmed'|'cancelled'|'tentative'} status |
| 36 | * @property {string|null} calendar_label |
| 37 | * @property {string} sort_at |
| 38 | */ |
| 39 | |
| 40 | /** |
| 41 | * Parse `from` / `to` query values into UTC ISO bounds and YYYY-MM-DD day keys. |
| 42 | * |
| 43 | * @param {string} fromRaw |
| 44 | * @param {string} toRaw |
| 45 | * @returns {{ fromIso: string, toIso: string, fromDate: string, toDate: string }} |
| 46 | */ |
| 47 | export function parseTimelineRange(fromRaw, toRaw) { |
| 48 | const fromParsed = parseBoundary(fromRaw, 'start'); |
| 49 | const toParsed = parseBoundary(toRaw, 'end'); |
| 50 | if (Date.parse(fromParsed.iso) >= Date.parse(toParsed.iso)) { |
| 51 | throw new RangeError('`from` must be before `to`'); |
| 52 | } |
| 53 | return { |
| 54 | fromIso: fromParsed.iso, |
| 55 | toIso: toParsed.iso, |
| 56 | fromDate: fromParsed.date, |
| 57 | toDate: toParsed.date, |
| 58 | }; |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * @param {string} raw |
| 63 | * @param {'start'|'end'} edge |
| 64 | */ |
| 65 | function parseBoundary(raw, edge) { |
| 66 | const trimmed = String(raw ?? '').trim(); |
| 67 | if (!trimmed) { |
| 68 | throw new RangeError('`from` and `to` are required'); |
| 69 | } |
| 70 | if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { |
| 71 | return { |
| 72 | date: trimmed, |
| 73 | iso: edge === 'start' ? `${trimmed}T00:00:00.000Z` : `${trimmed}T23:59:59.999Z`, |
| 74 | }; |
| 75 | } |
| 76 | const ms = Date.parse(trimmed); |
| 77 | if (Number.isNaN(ms)) { |
| 78 | throw new RangeError('Invalid date boundary'); |
| 79 | } |
| 80 | const date = new Date(ms).toISOString().slice(0, 10); |
| 81 | return { date, iso: new Date(ms).toISOString() }; |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * @param {unknown} raw |
| 86 | * @returns {string[]} |
| 87 | */ |
| 88 | export function parseTimelineLayers(raw) { |
| 89 | if (raw == null || raw === '') { |
| 90 | return ['notes', 'events']; |
| 91 | } |
| 92 | const parts = Array.isArray(raw) |
| 93 | ? raw.flatMap((v) => String(v).split(',')) |
| 94 | : String(raw).split(','); |
| 95 | const layers = [...new Set(parts.map((p) => p.trim()).filter(Boolean))]; |
| 96 | if (layers.length === 0) { |
| 97 | return ['notes', 'events']; |
| 98 | } |
| 99 | for (const layer of layers) { |
| 100 | if (!VALID_LAYERS.has(layer)) { |
| 101 | throw new RangeError(`Unsupported timeline layer: ${layer}`); |
| 102 | } |
| 103 | } |
| 104 | return layers; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * @param {unknown} raw |
| 109 | * @returns {string[]|undefined} |
| 110 | */ |
| 111 | export function parseSourceCalendarIds(raw) { |
| 112 | if (raw == null || raw === '') return undefined; |
| 113 | const parts = Array.isArray(raw) |
| 114 | ? raw.flatMap((v) => String(v).split(',')) |
| 115 | : String(raw).split(','); |
| 116 | const ids = [...new Set(parts.map((p) => p.trim()).filter(Boolean))]; |
| 117 | return ids.length ? ids : undefined; |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * @param {{ date?: string|null, updated?: string|null, frontmatter?: { date?: string, knowtation_edited_at?: string, title?: string } }} note |
| 122 | * @returns {string} |
| 123 | */ |
| 124 | export function noteCalendarDayKey(note) { |
| 125 | const fm = note.frontmatter ?? {}; |
| 126 | const raw = note.date ?? fm.date ?? fm.knowtation_edited_at ?? note.updated ?? ''; |
| 127 | if (raw == null) return ''; |
| 128 | const s = String(raw).trim(); |
| 129 | if (!s) return ''; |
| 130 | if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; |
| 131 | const ms = Date.parse(s); |
| 132 | if (Number.isNaN(ms)) return s.slice(0, 10); |
| 133 | return new Date(ms).toISOString().slice(0, 10); |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Build merged timeline items for a vault and range. |
| 138 | * |
| 139 | * @param {{ |
| 140 | * dataDir: string, |
| 141 | * vaultId: string, |
| 142 | * vaultPath: string, |
| 143 | * vaultConfig?: { ignore?: string[] }, |
| 144 | * from: string, |
| 145 | * to: string, |
| 146 | * layers?: unknown, |
| 147 | * sourceCalendarIds?: unknown, |
| 148 | * scope?: { projects?: string[], folders?: string[] }, |
| 149 | * }} input |
| 150 | */ |
| 151 | export function buildCalendarTimeline(input) { |
| 152 | const range = parseTimelineRange(input.from, input.to); |
| 153 | const layers = parseTimelineLayers(input.layers); |
| 154 | const sourceCalendarIds = parseSourceCalendarIds(input.sourceCalendarIds); |
| 155 | |
| 156 | /** @type {(TimelineNoteItem|TimelineEventItem)[]} */ |
| 157 | const items = []; |
| 158 | |
| 159 | if (layers.includes('notes')) { |
| 160 | const notes = filterNotesByListOptions( |
| 161 | getNotesWithMeta(input.vaultPath, input.vaultConfig ?? {}), |
| 162 | { since: range.fromDate, until: range.toDate }, |
| 163 | ); |
| 164 | const scoped = input.scope?.projects?.length || input.scope?.folders?.length |
| 165 | ? applyNoteScope(notes, input.scope) |
| 166 | : notes; |
| 167 | |
| 168 | for (const note of scoped) { |
| 169 | const day = noteCalendarDayKey(note); |
| 170 | if (!day || day < range.fromDate || day > range.toDate) continue; |
| 171 | const fm = note.frontmatter ?? {}; |
| 172 | items.push({ |
| 173 | kind: 'note', |
| 174 | date: day, |
| 175 | path: note.path, |
| 176 | title: typeof fm.title === 'string' ? fm.title : null, |
| 177 | project: note.project ?? null, |
| 178 | tags: note.tags ?? [], |
| 179 | sort_at: `${day}T00:00:00.000Z`, |
| 180 | }); |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | if (layers.includes('events')) { |
| 185 | const vaultStore = getVaultCalendarStore(input.dataDir, input.vaultId); |
| 186 | const labelByCalendarId = new Map( |
| 187 | vaultStore.source_calendars.map((c) => [c.source_calendar_id, c.display_name]), |
| 188 | ); |
| 189 | const events = queryStoredEvents(input.dataDir, input.vaultId, { |
| 190 | fromIso: range.fromIso, |
| 191 | toIso: range.toIso, |
| 192 | sourceCalendarIds, |
| 193 | displayOnly: true, |
| 194 | }); |
| 195 | for (const event of events) { |
| 196 | items.push({ |
| 197 | kind: 'event', |
| 198 | event_id: event.event_id, |
| 199 | source_calendar_id: event.source_calendar_id, |
| 200 | start: event.start, |
| 201 | end: event.end, |
| 202 | timezone: event.timezone, |
| 203 | summary: event.summary, |
| 204 | busy: event.busy, |
| 205 | status: event.status, |
| 206 | calendar_label: labelByCalendarId.get(event.source_calendar_id) ?? null, |
| 207 | sort_at: event.start, |
| 208 | }); |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | items.sort((a, b) => { |
| 213 | const cmp = a.sort_at.localeCompare(b.sort_at); |
| 214 | if (cmp !== 0) return cmp; |
| 215 | if (a.kind !== b.kind) return a.kind === 'note' ? -1 : 1; |
| 216 | if (a.kind === 'note' && b.kind === 'note') return a.path.localeCompare(b.path); |
| 217 | if (a.kind === 'event' && b.kind === 'event') return a.event_id.localeCompare(b.event_id); |
| 218 | return 0; |
| 219 | }); |
| 220 | |
| 221 | return { |
| 222 | schema: 'knowtation.calendar_timeline/v0', |
| 223 | vault_id: input.vaultId, |
| 224 | from: range.fromDate, |
| 225 | to: range.toDate, |
| 226 | layers, |
| 227 | items, |
| 228 | }; |
| 229 | } |
| 230 | |
| 231 | /** |
| 232 | * @param {Array<{ path: string }>} notes |
| 233 | * @param {{ projects?: string[], folders?: string[] }} scope |
| 234 | */ |
| 235 | function applyNoteScope(notes, scope) { |
| 236 | let out = notes.slice(); |
| 237 | if (scope.projects?.length) { |
| 238 | const set = new Set(scope.projects); |
| 239 | out = out.filter((n) => { |
| 240 | const project = /** @type {{ project?: string }} */ (n).project; |
| 241 | return project && set.has(project); |
| 242 | }); |
| 243 | } |
| 244 | if (scope.folders?.length) { |
| 245 | out = out.filter((n) => |
| 246 | scope.folders.some((folder) => n.path === folder || n.path.startsWith(`${folder}/`)), |
| 247 | ); |
| 248 | } |
| 249 | return out; |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * List source calendars for API responses (no oauth_ref). |
| 254 | * |
| 255 | * @param {string} dataDir |
| 256 | * @param {string} vaultId |
| 257 | */ |
| 258 | export function listSourceCalendarsForClient(dataDir, vaultId) { |
| 259 | const vaultStore = getVaultCalendarStore(dataDir, vaultId); |
| 260 | return vaultStore.source_calendars.map(sourceCalendarForClient); |
| 261 | } |
File History
1 commit
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago