140 lines
4.2 KiB
TypeScript
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',
|
|
rschedule: '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,
|
|
};
|
|
}
|