timeline.mjs
261 lines 7.6 KB
Raw
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