rspace-online/shared/markwhen/projection.ts

140 lines
4.2 KiB
TypeScript

/**
* Render a set of MwSource into a single markwhen `.mw` document.
*
* The output is deterministic for a given input (stable sort + escaping)
* so that projections can be cached and diffed.
*/
import type { MwEvent, MwProjection, MwSource } from './types';
const MAX_TITLE = 90;
const TAG_COLOR_DEFAULTS: Record<string, string> = {
rcal: 'blue',
rnotes: 'green',
rtasks: 'orange',
rvote: 'purple',
rminders: 'teal',
rtrips: 'amber',
rinbox: 'gray',
};
export interface RenderOptions {
title?: string;
timezone?: string;
/** Default view when a markwhen renderer opens the doc. */
view?: 'timeline' | 'calendar' | 'gantt';
/** Extra frontmatter tag colors to merge with inferred defaults. */
colors?: Record<string, string>;
/**
* Absolute URL base (protocol + host, no trailing slash). If set, any
* relative `href` on events is prefixed with this so the markwhen
* parser recognizes it as a link. Required for in-timeline "open in
* rApp" clickthrough.
*/
baseUrl?: string;
}
function iso(ts: number): string {
// UTC YYYY-MM-DD HH:mm:ss form; markwhen accepts ISO-like.
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function sanitizeTitle(raw: string): string {
const t = raw.replace(/[\r\n]+/g, ' ').trim();
return t.length > MAX_TITLE ? t.slice(0, MAX_TITLE - 1) + '…' : t;
}
function formatRange(ev: MwEvent): string {
if (ev.end && ev.end > ev.start) return `${iso(ev.start)} / ${iso(ev.end)}`;
return iso(ev.start);
}
function formatTags(tags: string[] | undefined): string {
if (!tags || tags.length === 0) return '';
return ' ' + tags.map(t => `#${t.replace(/\s+/g, '_')}`).join(' ');
}
function dedup(events: MwEvent[]): MwEvent[] {
const seen = new Set<string>();
const out: MwEvent[] = [];
for (const ev of events) {
if (seen.has(ev.sourceId)) continue;
seen.add(ev.sourceId);
out.push(ev);
}
return out;
}
function absolutizeHref(href: string, baseUrl?: string): string {
if (/^[a-z]+:\/\//i.test(href)) return href;
if (!baseUrl) return href;
const base = baseUrl.replace(/\/$/, '');
return href.startsWith('/') ? `${base}${href}` : `${base}/${href}`;
}
function stripEmoji(s: string): string {
// Pull leading emoji + space off a label like "📅 Calendar" → "Calendar".
return s.replace(/^\p{Extended_Pictographic}\s*/u, '').trim() || s;
}
export function renderMarkwhen(sources: MwSource[], opts: RenderOptions = {}): MwProjection {
const title = opts.title ?? 'rSpace Timeline';
const tz = opts.timezone ?? 'UTC';
const view = opts.view ?? 'timeline';
const colors: Record<string, string> = { ...opts.colors };
for (const s of sources) {
if (s.color) colors[s.tag] = s.color;
else if (!colors[s.tag] && TAG_COLOR_DEFAULTS[s.id]) colors[s.tag] = TAG_COLOR_DEFAULTS[s.id];
}
const frontColors = Object.entries(colors)
.map(([t, c]) => `#${t}: ${c}`)
.join('\n');
const lines: string[] = [];
lines.push('---');
lines.push(`title: ${title}`);
lines.push(`view: ${view}`);
lines.push(`timezone: ${tz}`);
if (frontColors) lines.push(frontColors);
lines.push('---', '');
lines.push('// Generated by shared/markwhen/projection.ts — do not hand-edit.', '');
const allEvents: MwEvent[] = [];
for (const src of sources) {
const events = dedup([...src.events].sort((a, b) => a.start - b.start));
if (events.length === 0) continue;
const openLabel = `Open in ${stripEmoji(src.label)}`;
lines.push(`section ${src.label} #${src.tag}`);
for (const ev of events) {
const titleLine = `${formatRange(ev)}: ${sanitizeTitle(ev.title)}${formatTags(ev.tags)}`;
lines.push(titleLine);
if (ev.description) {
for (const dLine of ev.description.split(/\r?\n/)) {
if (dLine.trim()) lines.push(' ' + dLine);
}
}
if (ev.href) {
const url = absolutizeHref(ev.href, opts.baseUrl);
// Markdown-style link so the markwhen parser detects it as a
// clickable link in the event's description tooltip/popup.
lines.push(` [${openLabel}](${url})`);
}
}
lines.push('endSection', '');
allEvents.push(...events);
}
return {
text: lines.join('\n'),
events: allEvents,
count: allEvents.length,
};
}