timeline.mjs
270 lines 7.9 KB
Raw
sha256:94ec65bd2b200240ac785a97cf14c5db066832bd608a24d6a9c151f17b918b02 feat(calendar): hosted bridge/gateway route parity and time… 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 * noteRecords?: Array<{
145 * path: string,
146 * frontmatter?: object,
147 * date?: string|null,
148 * updated?: string|null,
149 * project?: string|null,
150 * tags?: string[],
151 * }>,
152 * from: string,
153 * to: string,
154 * layers?: unknown,
155 * sourceCalendarIds?: unknown,
156 * scope?: { projects?: string[], folders?: string[] },
157 * }} input
158 */
159 export function buildCalendarTimeline(input) {
160 const range = parseTimelineRange(input.from, input.to);
161 const layers = parseTimelineLayers(input.layers);
162 const sourceCalendarIds = parseSourceCalendarIds(input.sourceCalendarIds);
163
164 /** @type {(TimelineNoteItem|TimelineEventItem)[]} */
165 const items = [];
166
167 if (layers.includes('notes')) {
168 const baseNotes =
169 input.noteRecords != null
170 ? input.noteRecords
171 : getNotesWithMeta(input.vaultPath ?? '', input.vaultConfig ?? {});
172 const notes = filterNotesByListOptions(baseNotes, { since: range.fromDate, until: range.toDate });
173 const scoped = input.scope?.projects?.length || input.scope?.folders?.length
174 ? applyNoteScope(notes, input.scope)
175 : notes;
176
177 for (const note of scoped) {
178 const day = noteCalendarDayKey(note);
179 if (!day || day < range.fromDate || day > range.toDate) continue;
180 const fm = note.frontmatter ?? {};
181 items.push({
182 kind: 'note',
183 date: day,
184 path: note.path,
185 title: typeof fm.title === 'string' ? fm.title : null,
186 project: note.project ?? null,
187 tags: note.tags ?? [],
188 sort_at: `${day}T00:00:00.000Z`,
189 });
190 }
191 }
192
193 if (layers.includes('events')) {
194 const vaultStore = getVaultCalendarStore(input.dataDir, input.vaultId);
195 const labelByCalendarId = new Map(
196 vaultStore.source_calendars.map((c) => [c.source_calendar_id, c.display_name]),
197 );
198 const events = queryStoredEvents(input.dataDir, input.vaultId, {
199 fromIso: range.fromIso,
200 toIso: range.toIso,
201 sourceCalendarIds,
202 displayOnly: true,
203 });
204 for (const event of events) {
205 items.push({
206 kind: 'event',
207 event_id: event.event_id,
208 source_calendar_id: event.source_calendar_id,
209 start: event.start,
210 end: event.end,
211 timezone: event.timezone,
212 summary: event.summary,
213 busy: event.busy,
214 status: event.status,
215 calendar_label: labelByCalendarId.get(event.source_calendar_id) ?? null,
216 sort_at: event.start,
217 });
218 }
219 }
220
221 items.sort((a, b) => {
222 const cmp = a.sort_at.localeCompare(b.sort_at);
223 if (cmp !== 0) return cmp;
224 if (a.kind !== b.kind) return a.kind === 'note' ? -1 : 1;
225 if (a.kind === 'note' && b.kind === 'note') return a.path.localeCompare(b.path);
226 if (a.kind === 'event' && b.kind === 'event') return a.event_id.localeCompare(b.event_id);
227 return 0;
228 });
229
230 return {
231 schema: 'knowtation.calendar_timeline/v0',
232 vault_id: input.vaultId,
233 from: range.fromDate,
234 to: range.toDate,
235 layers,
236 items,
237 };
238 }
239
240 /**
241 * @param {Array<{ path: string }>} notes
242 * @param {{ projects?: string[], folders?: string[] }} scope
243 */
244 function applyNoteScope(notes, scope) {
245 let out = notes.slice();
246 if (scope.projects?.length) {
247 const set = new Set(scope.projects);
248 out = out.filter((n) => {
249 const project = /** @type {{ project?: string }} */ (n).project;
250 return project && set.has(project);
251 });
252 }
253 if (scope.folders?.length) {
254 out = out.filter((n) =>
255 scope.folders.some((folder) => n.path === folder || n.path.startsWith(`${folder}/`)),
256 );
257 }
258 return out;
259 }
260
261 /**
262 * List source calendars for API responses (no oauth_ref).
263 *
264 * @param {string} dataDir
265 * @param {string} vaultId
266 */
267 export function listSourceCalendarsForClient(dataDir, vaultId) {
268 const vaultStore = getVaultCalendarStore(dataDir, vaultId);
269 return vaultStore.source_calendars.map(sourceCalendarForClient);
270 }
File History 1 commit
sha256:94ec65bd2b200240ac785a97cf14c5db066832bd608a24d6a9c151f17b918b02 feat(calendar): hosted bridge/gateway route parity and time… Human minor 1 day ago