feat(rpast): chronicle-of-self timeline + markwhen projection
- New rpast module renders a cross-rApp personal timeline
- shared/markwhen/ projection layer hydrates from syncServer docs
- rCal gets a Timeline applet wiring into the same markwhen view
- rstack-markwhen-view component for embedding elsewhere
- Smoke-test fixtures under output/ and scripts/smoke-rpast.ts
- Adds @markwhen/{parser,timeline,calendar,mw} deps
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d11acd48b
commit
3a69234819
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* rCal applet definitions — Next Event.
|
* rCal applet definitions — Next Event, Timeline View.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
|
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
|
||||||
|
import { projectSpace } from "../../shared/markwhen";
|
||||||
|
|
||||||
const nextEvent: AppletDefinition = {
|
const nextEvent: AppletDefinition = {
|
||||||
id: "next-event",
|
id: "next-event",
|
||||||
|
|
@ -35,4 +36,41 @@ const nextEvent: AppletDefinition = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calApplets: AppletDefinition[] = [nextEvent];
|
const timelineView: AppletDefinition = {
|
||||||
|
id: "timeline-view",
|
||||||
|
label: "Timeline",
|
||||||
|
icon: "🕒",
|
||||||
|
accentColor: "#2563eb",
|
||||||
|
ports: [
|
||||||
|
{ name: "range-in", type: "json", direction: "input" },
|
||||||
|
{ name: "mw-out", type: "json", direction: "output" },
|
||||||
|
],
|
||||||
|
renderCompact(data: AppletLiveData): string {
|
||||||
|
const count = Number(data.snapshot.count ?? 0);
|
||||||
|
const preview = (data.snapshot.preview as string) || "";
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${count} event(s)</div>
|
||||||
|
<div style="font-size:11px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${preview || "Open to render timeline"}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
async fetchLiveData(space: string) {
|
||||||
|
const projection = await projectSpace(space, { modules: ["rcal"] });
|
||||||
|
const first = projection.events[0];
|
||||||
|
return {
|
||||||
|
count: projection.count,
|
||||||
|
preview: first ? first.title : "",
|
||||||
|
mw: projection.text,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onInputReceived(portName, value, ctx) {
|
||||||
|
if (portName === "range-in" && value && typeof value === "object") {
|
||||||
|
const range = value as { from?: number; to?: number };
|
||||||
|
projectSpace(ctx.space, { modules: ["rcal"], from: range.from, to: range.to })
|
||||||
|
.then(p => ctx.emitOutput("mw-out", p.text));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calApplets: AppletDefinition[] = [nextEvent, timelineView];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* <rpast-viewer> — Chronicle-of-self timeline, rendered as a canvas shape.
|
||||||
|
*
|
||||||
|
* Hits the server-rendered `/:space/rpast/render` endpoint via iframe for
|
||||||
|
* the actual timeline/calendar visualization. The chip bar drives query
|
||||||
|
* params; the iframe reloads on every change.
|
||||||
|
*
|
||||||
|
* Attribute: space — the space slug
|
||||||
|
*/
|
||||||
|
|
||||||
|
import '../../../shared/components/rstack-markwhen-view';
|
||||||
|
|
||||||
|
type ViewMode = 'timeline' | 'calendar';
|
||||||
|
|
||||||
|
interface ModuleInfo { module: string; label: string; icon: string; color?: string; }
|
||||||
|
|
||||||
|
export class RpastViewer extends HTMLElement {
|
||||||
|
static get observedAttributes() { return ['space']; }
|
||||||
|
|
||||||
|
#space = '';
|
||||||
|
#selected: Set<string> | null = null; // null = "all"
|
||||||
|
#mode: ViewMode = 'timeline';
|
||||||
|
#modules: ModuleInfo[] = [];
|
||||||
|
#shadow: ShadowRoot;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.#shadow = this.attachShadow({ mode: 'open' });
|
||||||
|
this.#shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: grid; grid-template-rows: auto 1fr auto; height: 100%; width: 100%; }
|
||||||
|
.bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
||||||
|
padding: 6px 10px; background: #0f172a; border-bottom: 1px solid #1e293b;
|
||||||
|
font: 12px system-ui; color: #cbd5e1; }
|
||||||
|
.chip { padding: 2px 10px; border-radius: 999px; background: #1e293b;
|
||||||
|
cursor: pointer; user-select: none; white-space: nowrap; }
|
||||||
|
.chip[aria-pressed="true"] { background: #2563eb; color: white; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.toggle { display: flex; gap: 4px; }
|
||||||
|
.toggle button { border: 0; background: #1e293b; color: #cbd5e1; padding: 2px 10px;
|
||||||
|
border-radius: 4px; font: 12px system-ui; cursor: pointer; }
|
||||||
|
.toggle button[aria-pressed="true"] { background: #2563eb; color: white; }
|
||||||
|
.foot { display: flex; gap: 16px; padding: 4px 10px; font: 11px system-ui;
|
||||||
|
color: #94a3b8; background: #0f172a; border-top: 1px solid #1e293b; }
|
||||||
|
.foot a { color: #60a5fa; text-decoration: none; }
|
||||||
|
.foot a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
<div class="bar"></div>
|
||||||
|
<rstack-markwhen-view></rstack-markwhen-view>
|
||||||
|
<div class="foot"><span class="count">Loading…</span><span class="spacer"></span><a class="download" href="#">Download .mw</a></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
set space(v: string) { this.#space = v; void this.#init(); }
|
||||||
|
get space() { return this.#space; }
|
||||||
|
|
||||||
|
attributeChangedCallback(name: string, _old: string | null, next: string | null) {
|
||||||
|
if (name === 'space') this.space = next ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() { void this.#init(); }
|
||||||
|
|
||||||
|
async #init() {
|
||||||
|
if (!this.#space) return;
|
||||||
|
try {
|
||||||
|
// The modules endpoint is space-agnostic; any space path works.
|
||||||
|
const res = await fetch(`/${this.#space}/rpast/api/modules`);
|
||||||
|
this.#modules = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[rpast-viewer] failed to load module list', err);
|
||||||
|
this.#modules = [];
|
||||||
|
}
|
||||||
|
this.#paintBar();
|
||||||
|
await this.#reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
#paintBar() {
|
||||||
|
const bar = this.#shadow.querySelector('.bar')!;
|
||||||
|
bar.innerHTML = '';
|
||||||
|
|
||||||
|
const allChip = document.createElement('span');
|
||||||
|
allChip.className = 'chip';
|
||||||
|
allChip.setAttribute('aria-pressed', String(this.#selected === null));
|
||||||
|
allChip.textContent = '✨ All';
|
||||||
|
allChip.addEventListener('click', () => { this.#selected = null; this.#paintBar(); void this.#reload(); });
|
||||||
|
bar.appendChild(allChip);
|
||||||
|
|
||||||
|
for (const m of this.#modules) {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'chip';
|
||||||
|
const active = this.#selected !== null && this.#selected.has(m.module);
|
||||||
|
chip.setAttribute('aria-pressed', String(active));
|
||||||
|
chip.textContent = `${m.icon} ${m.label}`;
|
||||||
|
chip.addEventListener('click', () => this.#toggle(m.module));
|
||||||
|
bar.appendChild(chip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spacer = document.createElement('div');
|
||||||
|
spacer.className = 'spacer';
|
||||||
|
bar.appendChild(spacer);
|
||||||
|
|
||||||
|
const toggle = document.createElement('div');
|
||||||
|
toggle.className = 'toggle';
|
||||||
|
for (const mode of ['timeline', 'calendar'] as const) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = mode[0].toUpperCase() + mode.slice(1);
|
||||||
|
btn.setAttribute('aria-pressed', String(this.#mode === mode));
|
||||||
|
btn.addEventListener('click', () => { this.#mode = mode; this.#paintBar(); void this.#reload(); });
|
||||||
|
toggle.appendChild(btn);
|
||||||
|
}
|
||||||
|
bar.appendChild(toggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggle(module: string) {
|
||||||
|
if (this.#selected === null) this.#selected = new Set([module]);
|
||||||
|
else if (this.#selected.has(module)) {
|
||||||
|
this.#selected.delete(module);
|
||||||
|
if (this.#selected.size === 0) this.#selected = null;
|
||||||
|
} else this.#selected.add(module);
|
||||||
|
this.#paintBar();
|
||||||
|
void this.#reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildUrl(base: string): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.#selected) params.set('modules', [...this.#selected].join(','));
|
||||||
|
params.set('view', this.#mode);
|
||||||
|
return `/${this.#space}/rpast/${base}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #reload() {
|
||||||
|
const view = this.#shadow.querySelector('rstack-markwhen-view') as HTMLElement & {
|
||||||
|
src?: string; view?: ViewMode;
|
||||||
|
};
|
||||||
|
view.view = this.#mode;
|
||||||
|
view.src = this.#buildUrl('render');
|
||||||
|
|
||||||
|
const dl = this.#shadow.querySelector('.download') as HTMLAnchorElement;
|
||||||
|
dl.href = this.#buildUrl('api/chronicle.mw');
|
||||||
|
dl.download = `${this.#space}-rpast.mw`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.#buildUrl('api/chronicle'));
|
||||||
|
const data = await res.json() as { count: number; sections: { id: string; count: number }[] };
|
||||||
|
const foot = this.#shadow.querySelector('.count') as HTMLElement;
|
||||||
|
foot.textContent = `${data.count.toLocaleString()} creation${data.count === 1 ? '' : 's'} across ${data.sections.length} module${data.sections.length === 1 ? '' : 's'}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[rpast-viewer] count fetch failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('rpast-viewer')) {
|
||||||
|
customElements.define('rpast-viewer', RpastViewer);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* rPast landing — chronicle-of-self viewer.
|
||||||
|
*/
|
||||||
|
export function renderLanding(): string {
|
||||||
|
return `
|
||||||
|
<div class="rl-hero">
|
||||||
|
<span class="rl-tagline">rPast</span>
|
||||||
|
<h1 class="rl-heading">Every creation, on one timeline.</h1>
|
||||||
|
<p class="rl-subtitle">
|
||||||
|
Your notes, events, tasks, votes, photos, files, trips, messages — all dated records from every rApp, unified in a single chronicle-of-self you can browse, filter, and share.
|
||||||
|
</p>
|
||||||
|
<p class="rl-subtext">
|
||||||
|
rPast reads the canonical state of every rApp in your space and projects it onto a markwhen timeline. Zero extra storage, zero drift: the chronicle is always a live projection of what you've actually made.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rl-section">
|
||||||
|
<div class="rl-container">
|
||||||
|
<rpast-viewer id="rpast-main" style="height: 70vh; display:block;"></rpast-viewer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script type="module" src="/modules/rpast/rpast-viewer.js?v=1"></script>
|
||||||
|
<script type="module">
|
||||||
|
const el = document.getElementById('rpast-main');
|
||||||
|
const space = (location.hostname.split('.')[0]) || 'demo';
|
||||||
|
el.setAttribute('space', space);
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* rPast — chronicle-of-self timeline.
|
||||||
|
*
|
||||||
|
* Projects every dated CRDT record across every rApp into a single
|
||||||
|
* timeline/calendar. Zero writes to source modules. The `.mw` text is
|
||||||
|
* regenerated on every request; saved "chronicles" are just filter configs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { RSpaceModule } from '../../shared/module';
|
||||||
|
import {
|
||||||
|
enumerateCreations, listCreationEnumerators, renderMarkwhen,
|
||||||
|
} from '../../shared/markwhen';
|
||||||
|
import { renderMarkwhenHtml } from '../../shared/markwhen/html-render';
|
||||||
|
import { pastSchema } from './schemas';
|
||||||
|
import { renderLanding } from './landing';
|
||||||
|
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get('/api/modules', c => {
|
||||||
|
return c.json(listCreationEnumerators().map(e => ({
|
||||||
|
module: e.module, label: e.label, icon: e.icon, color: e.color,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function buildProjection(c: any) {
|
||||||
|
const space = c.req.param('space');
|
||||||
|
const modules = c.req.query('modules')?.split(',').filter(Boolean);
|
||||||
|
const from = c.req.query('from') ? Number(c.req.query('from')) : undefined;
|
||||||
|
const to = c.req.query('to') ? Number(c.req.query('to')) : undefined;
|
||||||
|
const view = (c.req.query('view') as 'timeline' | 'calendar') ?? 'timeline';
|
||||||
|
|
||||||
|
// Derive the public base URL from the request so in-timeline "Open in
|
||||||
|
// rApp" links resolve against the same origin the user arrived from.
|
||||||
|
const host = c.req.header('x-forwarded-host') ?? c.req.header('host');
|
||||||
|
const proto = c.req.header('x-forwarded-proto') ?? (host?.includes('localhost') ? 'http' : 'https');
|
||||||
|
const baseUrl = host ? `${proto}://${host}` : undefined;
|
||||||
|
|
||||||
|
const sources = await enumerateCreations(space, { modules, from, to });
|
||||||
|
const projection = renderMarkwhen(sources, {
|
||||||
|
view, title: `${space} — rPast`, baseUrl,
|
||||||
|
});
|
||||||
|
return { space, sources, projection, view };
|
||||||
|
}
|
||||||
|
|
||||||
|
routes.get('/api/chronicle', async c => {
|
||||||
|
const { sources, projection } = await buildProjection(c);
|
||||||
|
return c.json({
|
||||||
|
text: projection.text,
|
||||||
|
count: projection.count,
|
||||||
|
sections: sources.map(s => ({ id: s.id, label: s.label, count: s.events.length })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get('/api/chronicle.mw', async c => {
|
||||||
|
const { projection } = await buildProjection(c);
|
||||||
|
return c.text(projection.text, 200, { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get('/render', async c => {
|
||||||
|
const { projection, view } = await buildProjection(c);
|
||||||
|
const html = renderMarkwhenHtml(projection.text, view);
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rpastModule: RSpaceModule = {
|
||||||
|
id: 'rpast',
|
||||||
|
name: 'rPast',
|
||||||
|
icon: '🕰️',
|
||||||
|
description: 'Chronicle-of-self — every rApp creation, ever, on one timeline.',
|
||||||
|
routes,
|
||||||
|
landingPage: renderLanding,
|
||||||
|
scoping: { defaultScope: 'space', userConfigurable: false },
|
||||||
|
docSchemas: [{
|
||||||
|
pattern: '{space}:rpast:chronicles',
|
||||||
|
description: 'Saved chronicle configs (module selection + filters + mode)',
|
||||||
|
init: pastSchema.init,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* rPast Automerge schemas — chronicle-of-self viewer.
|
||||||
|
*
|
||||||
|
* DocId format: {space}:rpast:chronicles
|
||||||
|
*
|
||||||
|
* A chronicle is a saved configuration over the universal creation log:
|
||||||
|
* - which modules to include (defaults: all registered)
|
||||||
|
* - date window
|
||||||
|
* - record-type filter
|
||||||
|
* - tag filter
|
||||||
|
* - view mode
|
||||||
|
*
|
||||||
|
* The projected `.mw` text is NEVER persisted — it is re-derived from
|
||||||
|
* canonical CRDT state every open. This guarantees rPast can never drift
|
||||||
|
* from source-of-truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DocSchema } from '../../shared/local-first/document';
|
||||||
|
|
||||||
|
export interface Chronicle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
/** Module ids included, in render-order (becomes section order). */
|
||||||
|
modules: string[];
|
||||||
|
/** Optional record-type narrowing (e.g. only "note" + "event"). */
|
||||||
|
recordTypes: string[];
|
||||||
|
/** Tag allow-list (match-any). Empty = no tag filter. */
|
||||||
|
tags: string[];
|
||||||
|
/** Unix ms; null = unbounded. */
|
||||||
|
fromMs: number | null;
|
||||||
|
toMs: number | null;
|
||||||
|
viewMode: 'timeline' | 'calendar' | 'gantt';
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PastDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
chronicles: Record<string, Chronicle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pastSchema: DocSchema<PastDoc> = {
|
||||||
|
module: 'rpast',
|
||||||
|
collection: 'chronicles',
|
||||||
|
version: 1,
|
||||||
|
init: (): PastDoc => ({
|
||||||
|
meta: {
|
||||||
|
module: 'rpast',
|
||||||
|
collection: 'chronicles',
|
||||||
|
version: 1,
|
||||||
|
spaceSlug: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
chronicles: {},
|
||||||
|
}),
|
||||||
|
migrate: (doc: PastDoc, _fromVersion: number): PastDoc => doc,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pastDocId(space: string) {
|
||||||
|
return `${space}:rpast:chronicles` as const;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
title: demo — rPast (smoke)
|
||||||
|
view: timeline
|
||||||
|
timezone: UTC
|
||||||
|
#rcal: blue
|
||||||
|
#rnotes: green
|
||||||
|
#rtasks: orange
|
||||||
|
#rvote: purple
|
||||||
|
---
|
||||||
|
|
||||||
|
// Generated by shared/markwhen/projection.ts — do not hand-edit.
|
||||||
|
|
||||||
|
section 📅 Calendar #rcal
|
||||||
|
2026-01-16 20:10: Personal #calendar-source
|
||||||
|
[Open in Calendar](https://demo.rspace.online/demo/rcal?focus=s1)
|
||||||
|
2026-03-16 20:10: TEC retrospective kickoff #event #research #virtual
|
||||||
|
[Open in Calendar](https://demo.rspace.online/demo/rcal?focus=e1)
|
||||||
|
2026-04-01 20:10: Markwhen exploration #event #booked
|
||||||
|
[Open in Calendar](https://demo.rspace.online/demo/rcal?focus=e2)
|
||||||
|
2026-04-15 20:10: rPast ship review #event
|
||||||
|
[Open in Calendar](https://demo.rspace.online/demo/rcal?focus=e3)
|
||||||
|
endSection
|
||||||
|
|
||||||
|
section 📓 Notes #rnotes
|
||||||
|
2026-04-10 20:10: April 10 daily #note #daily
|
||||||
|
[Open in Notes](https://demo.rspace.online/demo/rnotes/vault-a/daily%2F2026-04-10.md)
|
||||||
|
2026-04-13 20:10: markwhen idea #note
|
||||||
|
[Open in Notes](https://demo.rspace.online/demo/rnotes/vault-a/ideas%2Fmarkwhen.md)
|
||||||
|
2026-04-15 20:10: April 15 daily #note #daily
|
||||||
|
[Open in Notes](https://demo.rspace.online/demo/rnotes/vault-a/daily%2F2026-04-15.md)
|
||||||
|
endSection
|
||||||
|
|
||||||
|
section ✅ Tasks #rtasks
|
||||||
|
2026-04-11 20:10 / 2026-04-14 20:10: Build universal enumerator #task #done #high
|
||||||
|
[Open in Tasks](https://demo.rspace.online/demo/rtasks?focus=t2)
|
||||||
|
2026-04-14 20:10: Wire rPast bootstrap #task #done #high
|
||||||
|
[Open in Tasks](https://demo.rspace.online/demo/rtasks?focus=t1)
|
||||||
|
2026-04-15 20:10: Add rVote adapter #task #todo
|
||||||
|
[Open in Tasks](https://demo.rspace.online/demo/rtasks?focus=t3)
|
||||||
|
endSection
|
||||||
|
|
||||||
|
section 🗳️ Votes #rvote
|
||||||
|
2026-03-27 20:10 / 2026-04-06 20:10: Fund rPast v1 #proposal #executed
|
||||||
|
[Open in ️ Votes](https://demo.rspace.online/demo/rvote?focus=p1)
|
||||||
|
2026-04-14 20:10: Enable x402 export #proposal #open
|
||||||
|
[Open in ️ Votes](https://demo.rspace.online/demo/rvote?focus=p2)
|
||||||
|
endSection
|
||||||
|
|
@ -24,6 +24,10 @@
|
||||||
"@google/genai": "^1.43.0",
|
"@google/genai": "^1.43.0",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@lit/reactive-element": "^2.0.4",
|
"@lit/reactive-element": "^2.0.4",
|
||||||
|
"@markwhen/calendar": "^1.3.6",
|
||||||
|
"@markwhen/mw": "^1.2.4",
|
||||||
|
"@markwhen/parser": "^1.0.1",
|
||||||
|
"@markwhen/timeline": "^1.4.5",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@noble/curves": "^1.8.0",
|
"@noble/curves": "^1.8.0",
|
||||||
"@noble/hashes": "^1.7.0",
|
"@noble/hashes": "^1.7.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* Smoke test: universal creation-log projection end-to-end.
|
||||||
|
* Run with: bun run scripts/smoke-rpast.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { initMarkwhen, enumerateCreations, renderMarkwhen } from '../shared/markwhen';
|
||||||
|
import { renderMarkwhenHtml } from '../shared/markwhen/html-render';
|
||||||
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const d = (offsetDays: number) => now + offsetDays * 86_400_000;
|
||||||
|
|
||||||
|
const docs: Record<string, any> = {
|
||||||
|
'demo:cal:events': {
|
||||||
|
events: {
|
||||||
|
'e1': { id: 'e1', title: 'TEC retrospective kickoff', startTime: d(-30), endTime: d(-30), createdAt: d(-31), updatedAt: d(-31), allDay: false, tags: ['research'], isVirtual: true, virtualUrl: 'https://meet.example' },
|
||||||
|
'e2': { id: 'e2', title: 'Markwhen exploration', startTime: d(-14), endTime: d(-14), createdAt: d(-15), updatedAt: d(-15), allDay: false, bookingStatus: 'booked' },
|
||||||
|
'e3': { id: 'e3', title: 'rPast ship review', startTime: d(3), endTime: d(3), createdAt: d(-1), updatedAt: d(-1), allDay: false },
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
's1': { id: 's1', name: 'Personal', createdAt: d(-90), updatedAt: d(-90) },
|
||||||
|
},
|
||||||
|
views: {},
|
||||||
|
},
|
||||||
|
'demo:rnotes:vaults:vault-a': {
|
||||||
|
notes: {
|
||||||
|
'daily/2026-04-10.md': { path: 'daily/2026-04-10.md', title: 'April 10 daily', tags: ['daily'], lastModifiedAt: d(-6) },
|
||||||
|
'daily/2026-04-15.md': { path: 'daily/2026-04-15.md', title: 'April 15 daily', tags: ['daily'], lastModifiedAt: d(-1) },
|
||||||
|
'ideas/markwhen.md': { path: 'ideas/markwhen.md', title: 'markwhen idea', lastModifiedAt: d(-3), frontmatter: { date: '2026-04-13' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'demo:rtasks:boards:main': {
|
||||||
|
tasks: {
|
||||||
|
't1': { id: 't1', title: 'Wire rPast bootstrap', status: 'done', priority: 'high', createdAt: d(-2), updatedAt: d(-1) },
|
||||||
|
't2': { id: 't2', title: 'Build universal enumerator', status: 'done', priority: 'high', createdAt: d(-5), updatedAt: d(-2) },
|
||||||
|
't3': { id: 't3', title: 'Add rVote adapter', status: 'todo', createdAt: d(-1) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'demo:rvote:proposals:round-1': {
|
||||||
|
proposals: {
|
||||||
|
'p1': { id: 'p1', title: 'Fund rPast v1', status: 'executed', createdAt: d(-20), updatedAt: d(-10) },
|
||||||
|
'p2': { id: 'p2', title: 'Enable x402 export', status: 'open', createdAt: d(-2) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
initMarkwhen({
|
||||||
|
async loadDoc<T>(id: string) { return (docs[id] as T) ?? null; },
|
||||||
|
async listDocIds(prefix: string) { return Object.keys(docs).filter(k => k.startsWith(prefix)); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sources = await enumerateCreations('demo');
|
||||||
|
const projection = renderMarkwhen(sources, {
|
||||||
|
view: 'timeline',
|
||||||
|
title: 'demo — rPast (smoke)',
|
||||||
|
baseUrl: 'https://demo.rspace.online',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n== Sources ==`);
|
||||||
|
for (const s of sources) console.log(` ${s.id}: ${s.events.length} events`);
|
||||||
|
console.log(`\n== Total ==\n ${projection.count} creations across ${sources.length} modules`);
|
||||||
|
|
||||||
|
console.log(`\n== .mw (first 40 lines) ==`);
|
||||||
|
console.log(projection.text.split('\n').slice(0, 40).join('\n'));
|
||||||
|
|
||||||
|
mkdirSync('output', { recursive: true });
|
||||||
|
writeFileSync('output/rpast-smoke.mw', projection.text);
|
||||||
|
writeFileSync('output/rpast-smoke.html', renderMarkwhenHtml(projection.text, 'timeline'));
|
||||||
|
|
||||||
|
console.log(`\n== Wrote ==`);
|
||||||
|
console.log(` output/rpast-smoke.mw (${projection.text.length} bytes)`);
|
||||||
|
console.log(` output/rpast-smoke.html (${renderMarkwhenHtml(projection.text, 'timeline').length} bytes)`);
|
||||||
|
|
@ -93,6 +93,8 @@ import { exchangeModule } from "../modules/rexchange/mod";
|
||||||
import { auctionsModule } from "../modules/rauctions/mod";
|
import { auctionsModule } from "../modules/rauctions/mod";
|
||||||
import { credModule } from "../modules/rcred/mod";
|
import { credModule } from "../modules/rcred/mod";
|
||||||
import { feedsModule } from "../modules/rfeeds/mod";
|
import { feedsModule } from "../modules/rfeeds/mod";
|
||||||
|
import { rpastModule } from "../modules/rpast/mod";
|
||||||
|
import { initMarkwhen } from "../shared/markwhen";
|
||||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
|
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
|
||||||
|
|
@ -186,6 +188,19 @@ registerModule(tripsModule);
|
||||||
registerModule(booksModule);
|
registerModule(booksModule);
|
||||||
registerModule(sheetsModule);
|
registerModule(sheetsModule);
|
||||||
registerModule(docsModule); // Full TipTap editor (split from rNotes)
|
registerModule(docsModule); // Full TipTap editor (split from rNotes)
|
||||||
|
registerModule(rpastModule); // Chronicle-of-self timeline across all rApps
|
||||||
|
|
||||||
|
// Wire rPast / markwhen projection layer to syncServer.
|
||||||
|
// Scans the in-memory doc cache for `loadDoc` / `listDocIds` — loadAllDocs
|
||||||
|
// hydrates the cache at startup, so this sees every persisted doc.
|
||||||
|
initMarkwhen({
|
||||||
|
async loadDoc<T>(docId: string): Promise<T | null> {
|
||||||
|
return (syncServer.getDoc<T>(docId) as T | undefined) ?? null;
|
||||||
|
},
|
||||||
|
async listDocIds(prefix: string): Promise<string[]> {
|
||||||
|
return syncServer.getDocIds().filter(id => id.startsWith(prefix));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── Config ──
|
// ── Config ──
|
||||||
const PORT = Number(process.env.PORT) || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* <rstack-markwhen-view> — iframe-based markwhen viewer.
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* - `src` attribute: fetch a server-rendered `/render` endpoint (preferred)
|
||||||
|
* - `text` property: render an ad-hoc .mw string via the server-side
|
||||||
|
* render pipeline, delivered via srcdoc for isolation.
|
||||||
|
*
|
||||||
|
* The upstream @markwhen/{timeline,calendar} packages are pre-built Vue
|
||||||
|
* apps, not importable libraries — so the iframe route is the sane embed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MarkwhenViewMode = 'timeline' | 'calendar';
|
||||||
|
|
||||||
|
export class RstackMarkwhenView extends HTMLElement {
|
||||||
|
static get observedAttributes() { return ['src', 'view']; }
|
||||||
|
|
||||||
|
#src = '';
|
||||||
|
#view: MarkwhenViewMode = 'timeline';
|
||||||
|
#shadow: ShadowRoot;
|
||||||
|
#iframe: HTMLIFrameElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.#shadow = this.attachShadow({ mode: 'open' });
|
||||||
|
this.#shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; width: 100%; height: 100%; min-height: 320px;
|
||||||
|
background: #0b1221; border-radius: 8px; overflow: hidden; }
|
||||||
|
iframe { width: 100%; height: 100%; border: 0; background: #0b1221; }
|
||||||
|
.empty { display: grid; place-items: center; height: 100%;
|
||||||
|
font: 13px/1.4 system-ui; color: #94a3b8; padding: 24px; text-align: center; }
|
||||||
|
</style>
|
||||||
|
<iframe sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" title="Timeline"></iframe>
|
||||||
|
`;
|
||||||
|
this.#iframe = this.#shadow.querySelector('iframe')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
set src(v: string) { this.#src = v; this.#render(); }
|
||||||
|
get src() { return this.#src; }
|
||||||
|
set view(v: MarkwhenViewMode) { this.#view = v; this.#render(); }
|
||||||
|
get view() { return this.#view; }
|
||||||
|
|
||||||
|
attributeChangedCallback(name: string, _old: string | null, next: string | null) {
|
||||||
|
if (name === 'src') this.src = next ?? '';
|
||||||
|
if (name === 'view') this.view = (next === 'calendar' ? 'calendar' : 'timeline');
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() { this.#render(); }
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
if (!this.#src) {
|
||||||
|
this.#iframe.removeAttribute('src');
|
||||||
|
this.#iframe.srcdoc = `<body style="display:grid;place-items:center;height:100vh;margin:0;font:13px system-ui;color:#94a3b8;background:#0b1221">No timeline source yet.</body>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Append view param if caller used a bare endpoint URL.
|
||||||
|
const url = new URL(this.#src, window.location.origin);
|
||||||
|
if (!url.searchParams.has('view')) url.searchParams.set('view', this.#view);
|
||||||
|
this.#iframe.removeAttribute('srcdoc');
|
||||||
|
this.#iframe.src = url.pathname + url.search;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('rstack-markwhen-view')) {
|
||||||
|
customElements.define('rstack-markwhen-view', RstackMarkwhenView);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
/**
|
||||||
|
* Declarative creation-specs for every rApp that stores dated records.
|
||||||
|
*
|
||||||
|
* Each spec says: "here's my doc pattern, here's where my records live,
|
||||||
|
* here's how to pick a title / href / tags." The universal enumerator
|
||||||
|
* consumes these to produce the rPast chronicle-of-self.
|
||||||
|
*
|
||||||
|
* Adding a new rApp: append a new `CreationSpec` literal below and
|
||||||
|
* register it in `shared/markwhen/index.ts`. No bespoke code required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CreationSpec } from './universal-enumerator';
|
||||||
|
|
||||||
|
const hrefFor = (module: string) =>
|
||||||
|
({ space, id }: { space: string; id: string }) => `/${space}/${module}?focus=${encodeURIComponent(id)}`;
|
||||||
|
|
||||||
|
export const rcalSpec: CreationSpec = {
|
||||||
|
module: 'rcal',
|
||||||
|
label: 'Calendar',
|
||||||
|
icon: '📅',
|
||||||
|
color: 'blue',
|
||||||
|
docPatterns: ['{space}:cal:events'],
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
path: 'events',
|
||||||
|
recordType: 'event',
|
||||||
|
href: hrefFor('rcal'),
|
||||||
|
tags: r => {
|
||||||
|
const t: string[] = [];
|
||||||
|
if (Array.isArray(r.tags)) t.push(...(r.tags as string[]));
|
||||||
|
if (typeof r.bookingStatus === 'string') t.push(r.bookingStatus as string);
|
||||||
|
if (r.isVirtual) t.push('virtual');
|
||||||
|
return t.length ? t : undefined;
|
||||||
|
},
|
||||||
|
description: r => (typeof r.description === 'string' ? (r.description as string).slice(0, 200) : undefined),
|
||||||
|
},
|
||||||
|
{ path: 'sources', recordType: 'calendar-source', href: hrefFor('rcal') },
|
||||||
|
{ path: 'views', recordType: 'saved-view' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rnotesSpec: CreationSpec = {
|
||||||
|
module: 'rnotes',
|
||||||
|
label: 'Notes',
|
||||||
|
icon: '📓',
|
||||||
|
color: 'green',
|
||||||
|
docPatterns: ['{space}:rnotes:vaults:*'],
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
path: 'notes',
|
||||||
|
recordType: 'note',
|
||||||
|
timestampField: 'lastModifiedAt',
|
||||||
|
title: (r, id) => (typeof r.title === 'string' && r.title ? r.title as string : id),
|
||||||
|
href: ({ space, docId, id }) => {
|
||||||
|
const vaultId = docId.split(':').pop() ?? '';
|
||||||
|
return `/${space}/rnotes/${vaultId}/${encodeURIComponent(id)}`;
|
||||||
|
},
|
||||||
|
tags: r => (Array.isArray(r.tags) ? (r.tags as string[]) : undefined),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rtasksSpec: CreationSpec = {
|
||||||
|
module: 'rtasks',
|
||||||
|
label: 'Tasks',
|
||||||
|
icon: '✅',
|
||||||
|
color: 'orange',
|
||||||
|
docPatterns: ['{space}:rtasks:boards:*'],
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
path: 'tasks',
|
||||||
|
recordType: 'task',
|
||||||
|
href: hrefFor('rtasks'),
|
||||||
|
tags: r => {
|
||||||
|
const t: string[] = [];
|
||||||
|
if (typeof r.status === 'string') t.push(r.status as string);
|
||||||
|
if (typeof r.priority === 'string') t.push(r.priority as string);
|
||||||
|
if (Array.isArray(r.tags)) t.push(...(r.tags as string[]));
|
||||||
|
return t.length ? t : undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rvoteSpec: CreationSpec = {
|
||||||
|
module: 'rvote',
|
||||||
|
label: 'Votes',
|
||||||
|
icon: '🗳️',
|
||||||
|
color: 'purple',
|
||||||
|
docPatterns: ['{space}:rvote:proposals:*'],
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
path: 'proposals',
|
||||||
|
recordType: 'proposal',
|
||||||
|
href: hrefFor('rvote'),
|
||||||
|
tags: r => (typeof r.status === 'string' ? [r.status as string] : undefined),
|
||||||
|
},
|
||||||
|
{ path: 'votes', recordType: 'vote' },
|
||||||
|
{ path: 'finalVotes', recordType: 'final-vote' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rphotosSpec: CreationSpec = {
|
||||||
|
module: 'rphotos',
|
||||||
|
label: 'Photos',
|
||||||
|
icon: '📸',
|
||||||
|
color: 'rose',
|
||||||
|
docPatterns: ['{space}:rphotos:albums'],
|
||||||
|
collections: [
|
||||||
|
{ path: 'sharedAlbums', recordType: 'album', href: hrefFor('rphotos') },
|
||||||
|
{ path: 'subAlbums', recordType: 'album', href: hrefFor('rphotos') },
|
||||||
|
{ path: 'annotations', recordType: 'annotation' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rfilesSpec: CreationSpec = {
|
||||||
|
module: 'rfiles',
|
||||||
|
label: 'Files',
|
||||||
|
icon: '📁',
|
||||||
|
color: 'teal',
|
||||||
|
docPatterns: ['{space}:rfiles:library'],
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
path: 'files',
|
||||||
|
recordType: 'file',
|
||||||
|
title: (r, id) => (typeof r.name === 'string' ? r.name as string : id),
|
||||||
|
href: hrefFor('rfiles'),
|
||||||
|
},
|
||||||
|
{ path: 'memoryCards', recordType: 'memory-card', href: hrefFor('rfiles') },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rdocsSpec: CreationSpec = {
|
||||||
|
module: 'rdocs',
|
||||||
|
label: 'Docs',
|
||||||
|
icon: '📄',
|
||||||
|
color: 'indigo',
|
||||||
|
docPatterns: ['{space}:rdocs:notebooks:*'],
|
||||||
|
collections: [
|
||||||
|
{ path: 'items', recordType: 'doc', href: hrefFor('rdocs') },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rsheetsSpec: CreationSpec = {
|
||||||
|
module: 'rsheets',
|
||||||
|
label: 'Sheets',
|
||||||
|
icon: '📊',
|
||||||
|
color: 'lime',
|
||||||
|
docPatterns: ['{space}:rsheets:sheets:*'],
|
||||||
|
collections: [
|
||||||
|
{ path: 'rows', recordType: 'row' },
|
||||||
|
{ path: 'columns', recordType: 'column' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rtripsSpec: CreationSpec = {
|
||||||
|
module: 'rtrips',
|
||||||
|
label: 'Trips',
|
||||||
|
icon: '🧳',
|
||||||
|
color: 'amber',
|
||||||
|
docPatterns: ['{space}:rtrips:trips:*'],
|
||||||
|
collections: [
|
||||||
|
{ path: 'stops', recordType: 'stop', href: hrefFor('rtrips') },
|
||||||
|
{ path: 'expenses', recordType: 'expense' },
|
||||||
|
{ path: 'packingList', recordType: 'packing' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rchoicesSpec: CreationSpec = {
|
||||||
|
module: 'rchoices',
|
||||||
|
label: 'Choices',
|
||||||
|
icon: '🎯',
|
||||||
|
color: 'fuchsia',
|
||||||
|
docPatterns: ['{space}:rchoices:sessions'],
|
||||||
|
collections: [
|
||||||
|
{ path: 'sessions', recordType: 'session', href: hrefFor('rchoices') },
|
||||||
|
{ path: 'votes', recordType: 'vote' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rinboxSpec: CreationSpec = {
|
||||||
|
module: 'rinbox',
|
||||||
|
label: 'Inbox',
|
||||||
|
icon: '📧',
|
||||||
|
color: 'gray',
|
||||||
|
docPatterns: ['{space}:rinbox:mailboxes:*'],
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
path: 'messages',
|
||||||
|
recordType: 'message',
|
||||||
|
title: r => (typeof r.subject === 'string' ? r.subject as string : '(no subject)'),
|
||||||
|
href: hrefFor('rinbox'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rcartSpec: CreationSpec = {
|
||||||
|
module: 'rcart',
|
||||||
|
label: 'Cart',
|
||||||
|
icon: '🛒',
|
||||||
|
color: 'yellow',
|
||||||
|
docPatterns: ['{space}:rcart:catalog', '{space}:rcart:orders:*'],
|
||||||
|
collections: [
|
||||||
|
{ path: 'items', recordType: 'item', href: hrefFor('rcart') },
|
||||||
|
{
|
||||||
|
path: 'orders',
|
||||||
|
recordType: 'order',
|
||||||
|
title: (r, id) => `Order ${id.slice(0, 8)}`,
|
||||||
|
href: hrefFor('rcart'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_CREATION_SPECS: CreationSpec[] = [
|
||||||
|
rcalSpec, rnotesSpec, rtasksSpec, rvoteSpec, rphotosSpec, rfilesSpec,
|
||||||
|
rdocsSpec, rsheetsSpec, rtripsSpec, rchoicesSpec, rinboxSpec, rcartSpec,
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Universal creation-log enumerators.
|
||||||
|
*
|
||||||
|
* Where `MarkwhenSourceFactory` projects *scheduled* events (when will X
|
||||||
|
* happen), a `CreationEnumerator` projects *creations* (when did X come
|
||||||
|
* into being). Every rApp that stores CRDT records with a `createdAt`
|
||||||
|
* field registers one here — and automatically appears in rPast, the
|
||||||
|
* unified chronicle-of-self viewer.
|
||||||
|
*
|
||||||
|
* The contract is deliberately minimal: the enumerator walks whichever
|
||||||
|
* docs it owns and emits one `Creation` per record. The meta-adapter
|
||||||
|
* below turns the merged stream into a single `MwSource` per module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MwSource, MwEvent } from './types';
|
||||||
|
|
||||||
|
export interface Creation {
|
||||||
|
/** Unix ms — when the record was created. Required. */
|
||||||
|
createdAt: number;
|
||||||
|
/** Unix ms — last edit. Omit if never updated. */
|
||||||
|
updatedAt?: number;
|
||||||
|
/** Single-line display name. */
|
||||||
|
title: string;
|
||||||
|
/** Record-type label (e.g. "event", "note", "task", "vote"). Used as a sub-tag. */
|
||||||
|
recordType: string;
|
||||||
|
/** Stable ID within the module (prefix not required; registry prefixes with module). */
|
||||||
|
recordId: string;
|
||||||
|
/** Optional deep-link back to the canonical record. */
|
||||||
|
href?: string;
|
||||||
|
/** Extra per-record tags (status, category, etc.). */
|
||||||
|
tags?: string[];
|
||||||
|
/** Short body shown indented under the event. */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreationEnumerator {
|
||||||
|
/** Module id, e.g. "rcal". */
|
||||||
|
module: string;
|
||||||
|
/** Human label shown in the rPast chip bar / filter UI. */
|
||||||
|
label: string;
|
||||||
|
/** Emoji for chip + section header. */
|
||||||
|
icon: string;
|
||||||
|
/** Color for the module tag in frontmatter. */
|
||||||
|
color?: string;
|
||||||
|
/** Walk owned docs and emit one Creation per record. */
|
||||||
|
enumerate(ctx: { space: string; from?: number; to?: number }): Promise<Creation[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumerators: Map<string, CreationEnumerator> = new Map();
|
||||||
|
|
||||||
|
export function registerCreationEnumerator(e: CreationEnumerator): void {
|
||||||
|
enumerators.set(e.module, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCreationEnumerators(): CreationEnumerator[] {
|
||||||
|
return [...enumerators.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enumerateCreations(
|
||||||
|
space: string,
|
||||||
|
opts: { modules?: string[]; from?: number; to?: number } = {},
|
||||||
|
): Promise<MwSource[]> {
|
||||||
|
const chosen = opts.modules
|
||||||
|
? opts.modules.map(m => enumerators.get(m)).filter((e): e is CreationEnumerator => !!e)
|
||||||
|
: [...enumerators.values()];
|
||||||
|
|
||||||
|
const sources: MwSource[] = [];
|
||||||
|
for (const e of chosen) {
|
||||||
|
try {
|
||||||
|
const creations = await e.enumerate({ space, from: opts.from, to: opts.to });
|
||||||
|
if (creations.length === 0) continue;
|
||||||
|
|
||||||
|
const events: MwEvent[] = creations.map(c => ({
|
||||||
|
start: c.createdAt,
|
||||||
|
// A creation is a point in time by default. If updatedAt is
|
||||||
|
// meaningfully later, span it so the timeline can show the
|
||||||
|
// "alive" range of the record.
|
||||||
|
end: c.updatedAt && c.updatedAt - c.createdAt > 24 * 3600_000 ? c.updatedAt : undefined,
|
||||||
|
title: `${c.title}`,
|
||||||
|
description: c.description,
|
||||||
|
tags: [c.recordType, ...(c.tags ?? [])],
|
||||||
|
href: c.href,
|
||||||
|
sourceId: `${e.module}:${c.recordType}:${c.recordId}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
sources.push({
|
||||||
|
id: e.module,
|
||||||
|
label: `${e.icon} ${e.label}`,
|
||||||
|
tag: e.module,
|
||||||
|
color: e.color,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[rpast] enumerator ${e.module} failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Thin shim over rSpace's local-first storage for markwhen source adapters.
|
||||||
|
*
|
||||||
|
* Keeps adapters free of direct dependencies on the concrete storage class,
|
||||||
|
* so they remain unit-testable with plain in-memory docs. Wire the real
|
||||||
|
* implementation in server/local-first at bootstrap via `setDocLoader`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DocLoader {
|
||||||
|
loadDoc<T>(docId: string): Promise<T | null>;
|
||||||
|
listDocIds(prefix: string): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let impl: DocLoader | null = null;
|
||||||
|
|
||||||
|
export function setDocLoader(loader: DocLoader): void {
|
||||||
|
impl = loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDoc<T>(docId: string): Promise<T | null> {
|
||||||
|
if (!impl) throw new Error('markwhen doc-loader not wired — call setDocLoader at bootstrap');
|
||||||
|
return impl.loadDoc<T>(docId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDocIds(prefix: string): Promise<string[]> {
|
||||||
|
if (!impl) throw new Error('markwhen doc-loader not wired — call setDocLoader at bootstrap');
|
||||||
|
return impl.listDocIds(prefix);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* Server-side HTML renderer for markwhen projections.
|
||||||
|
*
|
||||||
|
* Shells out to `@markwhen/mw` CLI (same binary the official tool uses)
|
||||||
|
* rather than importing the parser directly. Why: the view templates'
|
||||||
|
* inlined Vue app was built against `@markwhen/parser@0.10.x`, whose
|
||||||
|
* output shape differs from the npm-current `@markwhen/parser@1.x`. The
|
||||||
|
* CLI ships its own matched parser, so delegating to it avoids any
|
||||||
|
* version-skew silently breaking the render.
|
||||||
|
*
|
||||||
|
* The binary writes HTML to a destination file, so we round-trip through
|
||||||
|
* a per-request temp file pair. ~5ms overhead; cheap vs. the correctness.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const require_ = createRequire(import.meta.url);
|
||||||
|
let mwBinPath: string | null = null;
|
||||||
|
|
||||||
|
function findMwBinary(): string {
|
||||||
|
if (mwBinPath) return mwBinPath;
|
||||||
|
const pkgJson = require_.resolve('@markwhen/mw/package.json');
|
||||||
|
const pkgDir = pkgJson.slice(0, pkgJson.length - '/package.json'.length);
|
||||||
|
mwBinPath = join(pkgDir, 'lib', 'index.js');
|
||||||
|
return mwBinPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkwhenHtml(
|
||||||
|
mwText: string,
|
||||||
|
view: 'timeline' | 'calendar' = 'timeline',
|
||||||
|
): string {
|
||||||
|
if (!mwText.trim()) {
|
||||||
|
return `<!doctype html><meta charset="utf-8"><title>empty</title>
|
||||||
|
<body style="display:grid;place-items:center;height:100vh;margin:0;font:13px system-ui;color:#94a3b8;background:#0b1221">
|
||||||
|
<div>No dated events to show yet.</div>
|
||||||
|
</body>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'rpast-'));
|
||||||
|
const input = join(dir, 'in.mw');
|
||||||
|
const output = join(dir, `out.${view === 'calendar' ? 'calendar' : 'timeline'}.html`);
|
||||||
|
writeFileSync(input, mwText, 'utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(process.execPath, [findMwBinary(), input, '-o', view, '-d', output], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.toString() ?? '';
|
||||||
|
throw new Error(`mw CLI exited ${result.status}: ${stderr.slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
return readFileSync(output, 'utf-8');
|
||||||
|
} finally {
|
||||||
|
try { unlinkSync(input); } catch {}
|
||||||
|
try { unlinkSync(output); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Bootstrap + re-exports for the markwhen projection layer.
|
||||||
|
*
|
||||||
|
* Two complementary projections:
|
||||||
|
* - **Scheduled** (MarkwhenSourceFactory): "what's happening / will happen" —
|
||||||
|
* events with explicit start/end (rCal, rSchedule).
|
||||||
|
* - **Creations** (CreationEnumerator / CreationSpec): "what has been made" —
|
||||||
|
* every CRDT record's birth moment across every rApp. This is what rPast
|
||||||
|
* renders. Declared statically in `creation-specs.ts`.
|
||||||
|
*
|
||||||
|
* Call `initMarkwhen(loader)` once at server/client start.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { setDocLoader, type DocLoader } from './doc-loader';
|
||||||
|
import { registerMarkwhenSource } from './registry';
|
||||||
|
import { registerCreationEnumerator } from './creations';
|
||||||
|
import { createCreationEnumerator } from './universal-enumerator';
|
||||||
|
import { ALL_CREATION_SPECS } from './creation-specs';
|
||||||
|
|
||||||
|
// Scheduled-event adapters (keep alongside the creation log).
|
||||||
|
import { rcalMarkwhenSource } from './sources/rcal';
|
||||||
|
import { rnotesMarkwhenSource } from './sources/rnotes';
|
||||||
|
|
||||||
|
export { projectSpace, listMarkwhenSources, getMarkwhenSource, registerMarkwhenSource } from './registry';
|
||||||
|
export { renderMarkwhen } from './projection';
|
||||||
|
export {
|
||||||
|
enumerateCreations, registerCreationEnumerator, listCreationEnumerators,
|
||||||
|
} from './creations';
|
||||||
|
export type { Creation, CreationEnumerator } from './creations';
|
||||||
|
export type { CreationSpec, CreationCollection } from './universal-enumerator';
|
||||||
|
export { createCreationEnumerator } from './universal-enumerator';
|
||||||
|
export type { MwEvent, MwSource, MwProjection, MarkwhenSourceFactory } from './types';
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
export function initMarkwhen(loader: DocLoader): void {
|
||||||
|
if (initialized) return;
|
||||||
|
setDocLoader(loader);
|
||||||
|
|
||||||
|
// Scheduled-event sources (rCal standalone timeline, etc.).
|
||||||
|
registerMarkwhenSource(rcalMarkwhenSource);
|
||||||
|
registerMarkwhenSource(rnotesMarkwhenSource);
|
||||||
|
|
||||||
|
// Creation-log enumerators — the declarative universal pass.
|
||||||
|
for (const spec of ALL_CREATION_SPECS) {
|
||||||
|
registerCreationEnumerator(createCreationEnumerator(spec));
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Central registry of markwhen source factories.
|
||||||
|
*
|
||||||
|
* Each rApp that has date-bearing records registers a factory here.
|
||||||
|
* The composite viewer (rSaga) and per-module timeline applets pull
|
||||||
|
* from this registry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MarkwhenSourceFactory, MwSource } from './types';
|
||||||
|
import { renderMarkwhen, type RenderOptions } from './projection';
|
||||||
|
|
||||||
|
const factories: Map<string, MarkwhenSourceFactory> = new Map();
|
||||||
|
|
||||||
|
export function registerMarkwhenSource(factory: MarkwhenSourceFactory): void {
|
||||||
|
factories.set(factory.module, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMarkwhenSources(): MarkwhenSourceFactory[] {
|
||||||
|
return [...factories.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMarkwhenSource(module: string): MarkwhenSourceFactory | undefined {
|
||||||
|
return factories.get(module);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectOptions extends RenderOptions {
|
||||||
|
/** Module ids to include. If omitted, projects all registered sources. */
|
||||||
|
modules?: string[];
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function projectSpace(space: string, opts: ProjectOptions = {}) {
|
||||||
|
const chosen = opts.modules
|
||||||
|
? opts.modules.map(m => factories.get(m)).filter((f): f is MarkwhenSourceFactory => !!f)
|
||||||
|
: [...factories.values()];
|
||||||
|
|
||||||
|
const sources: MwSource[] = [];
|
||||||
|
for (const f of chosen) {
|
||||||
|
try {
|
||||||
|
const src = await f.build({ space, from: opts.from, to: opts.to });
|
||||||
|
if (src && src.events.length > 0) sources.push(src);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[markwhen] source ${f.module} failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renderMarkwhen(sources, opts);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* rCal → markwhen projection.
|
||||||
|
*
|
||||||
|
* Maps `CalendarEvent` records to `MwEvent`. Handles:
|
||||||
|
* - all-day events (normalized to date-only via midnight-UTC)
|
||||||
|
* - per-event timezone (passes through to MwEvent.timezone)
|
||||||
|
* - virtual vs physical events (location shown in description)
|
||||||
|
* - tags: existing event tags + booking status + source name
|
||||||
|
*
|
||||||
|
* NOT handled yet (future work, flagged in markwhen_integration spec):
|
||||||
|
* - RRULE expansion — only stored master events are emitted.
|
||||||
|
* For recurring, expand the next N instances via rrule.js first.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MarkwhenSourceFactory, MwEvent, MwSource } from '../types';
|
||||||
|
import type { CalendarDoc, CalendarEvent } from '../../../modules/rcal/schemas';
|
||||||
|
import { calendarDocId } from '../../../modules/rcal/schemas';
|
||||||
|
import { loadDoc } from '../doc-loader'; // thin wrapper around local-first storage
|
||||||
|
|
||||||
|
const SECTION_LABEL = 'Calendar';
|
||||||
|
const SECTION_TAG = 'rcal';
|
||||||
|
|
||||||
|
function eventToMw(ev: CalendarEvent): MwEvent | null {
|
||||||
|
if (!ev.startTime) return null;
|
||||||
|
const start = ev.allDay ? Math.floor(ev.startTime / 86_400_000) * 86_400_000 : ev.startTime;
|
||||||
|
const end = ev.endTime && ev.endTime > ev.startTime
|
||||||
|
? (ev.allDay ? Math.floor(ev.endTime / 86_400_000) * 86_400_000 : ev.endTime)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const tags = [...(ev.tags ?? [])];
|
||||||
|
if (ev.bookingStatus) tags.push(ev.bookingStatus);
|
||||||
|
if (ev.sourceType && ev.sourceType !== 'local') tags.push(ev.sourceType);
|
||||||
|
if (ev.isVirtual) tags.push('virtual');
|
||||||
|
|
||||||
|
const descParts: string[] = [];
|
||||||
|
if (ev.description) descParts.push(ev.description.slice(0, 500));
|
||||||
|
if (ev.locationName) descParts.push(`📍 ${ev.locationName}`);
|
||||||
|
else if (ev.locationBreadcrumb) descParts.push(`📍 ${ev.locationBreadcrumb}`);
|
||||||
|
if (ev.isVirtual && ev.virtualUrl) descParts.push(`🔗 ${ev.virtualUrl}`);
|
||||||
|
if (ev.attendeeCount) descParts.push(`👥 ${ev.attendeeCount} attendee(s)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
title: ev.title || '(untitled)',
|
||||||
|
description: descParts.length > 0 ? descParts.join('\n') : undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
timezone: ev.timezone ?? undefined,
|
||||||
|
sourceId: `rcal:ev:${ev.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rcalMarkwhenSource: MarkwhenSourceFactory = {
|
||||||
|
module: 'rcal',
|
||||||
|
label: 'Calendar',
|
||||||
|
icon: '📅',
|
||||||
|
async build({ space, from, to }): Promise<MwSource | null> {
|
||||||
|
const doc = await loadDoc<CalendarDoc>(calendarDocId(space));
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
const events: MwEvent[] = [];
|
||||||
|
for (const ev of Object.values(doc.events)) {
|
||||||
|
if (from !== undefined && ev.endTime && ev.endTime < from) continue;
|
||||||
|
if (to !== undefined && ev.startTime && ev.startTime > to) continue;
|
||||||
|
const mw = eventToMw(ev);
|
||||||
|
if (mw) events.push(mw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'rcal',
|
||||||
|
label: SECTION_LABEL,
|
||||||
|
tag: SECTION_TAG,
|
||||||
|
color: 'blue',
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* rNotes → markwhen projection.
|
||||||
|
*
|
||||||
|
* Two strategies, applied in order:
|
||||||
|
* 1. **Daily notes**: paths matching `YYYY-MM-DD` (anywhere in the path)
|
||||||
|
* become all-day events at that date. Title = note title.
|
||||||
|
* 2. **Frontmatter dates**: any note with `frontmatter.date` (ISO) or
|
||||||
|
* `frontmatter.created` / `frontmatter.event_date` becomes a point
|
||||||
|
* event at that moment.
|
||||||
|
*
|
||||||
|
* Notes without extractable dates are silently skipped — this is a view
|
||||||
|
* layer, not a completeness contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MarkwhenSourceFactory, MwEvent, MwSource } from '../types';
|
||||||
|
import type { VaultDoc, VaultNoteMeta } from '../../../modules/rnotes/schemas';
|
||||||
|
import { vaultDocId } from '../../../modules/rnotes/schemas';
|
||||||
|
import { loadDoc, listDocIds } from '../doc-loader';
|
||||||
|
|
||||||
|
const DAILY_RE = /(\d{4}-\d{2}-\d{2})(?!\d)/;
|
||||||
|
|
||||||
|
function extractDate(note: VaultNoteMeta): number | null {
|
||||||
|
// Strategy 1: path-embedded YYYY-MM-DD
|
||||||
|
const m = note.path.match(DAILY_RE);
|
||||||
|
if (m) {
|
||||||
|
const d = new Date(m[1] + 'T00:00:00Z');
|
||||||
|
if (!isNaN(d.getTime())) return d.getTime();
|
||||||
|
}
|
||||||
|
// Strategy 2: frontmatter
|
||||||
|
const fm = note.frontmatter ?? {};
|
||||||
|
for (const key of ['date', 'created', 'event_date', 'published']) {
|
||||||
|
const v = fm[key];
|
||||||
|
if (typeof v === 'string' || typeof v === 'number') {
|
||||||
|
const d = new Date(v);
|
||||||
|
if (!isNaN(d.getTime())) return d.getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteToMw(vaultId: string, note: VaultNoteMeta): MwEvent | null {
|
||||||
|
const ts = extractDate(note);
|
||||||
|
if (ts === null) return null;
|
||||||
|
|
||||||
|
const tags = note.tags?.length ? [...note.tags] : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: ts,
|
||||||
|
title: note.title || note.path,
|
||||||
|
description: undefined, // content is on-demand from ZIP — don't inline
|
||||||
|
tags,
|
||||||
|
href: `rspace://rnotes/${vaultId}/${encodeURIComponent(note.path)}`,
|
||||||
|
sourceId: `rnotes:${vaultId}:${note.path}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rnotesMarkwhenSource: MarkwhenSourceFactory = {
|
||||||
|
module: 'rnotes',
|
||||||
|
label: 'Notes',
|
||||||
|
icon: '📓',
|
||||||
|
async build({ space, from, to }): Promise<MwSource | null> {
|
||||||
|
// Each vault is its own doc — fan out over all vaults in the space.
|
||||||
|
const prefix = `${space}:rnotes:vaults:`;
|
||||||
|
const docIds = await listDocIds(prefix);
|
||||||
|
if (docIds.length === 0) return null;
|
||||||
|
|
||||||
|
const events: MwEvent[] = [];
|
||||||
|
for (const docId of docIds) {
|
||||||
|
const vaultId = docId.slice(prefix.length);
|
||||||
|
const doc = await loadDoc<VaultDoc>(vaultDocId(space, vaultId));
|
||||||
|
if (!doc) continue;
|
||||||
|
for (const note of Object.values(doc.notes)) {
|
||||||
|
const mw = noteToMw(vaultId, note);
|
||||||
|
if (!mw) continue;
|
||||||
|
if (from !== undefined && mw.start < from) continue;
|
||||||
|
if (to !== undefined && mw.start > to) continue;
|
||||||
|
events.push(mw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'rnotes',
|
||||||
|
label: 'Notes',
|
||||||
|
tag: 'rnotes',
|
||||||
|
color: 'green',
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* Universal markwhen projection types for rSpace.
|
||||||
|
*
|
||||||
|
* Any rApp can expose date-bearing Automerge records as a MwSource.
|
||||||
|
* The renderer unions sources into a single `.mw` document that feeds
|
||||||
|
* timeline / calendar / gantt views.
|
||||||
|
*
|
||||||
|
* Storage model: `.mw` is NEVER the source of truth. It is a pure
|
||||||
|
* projection over canonical CRDT state. Do not round-trip edits through it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hex color or markwhen named color (e.g. "blue"). */
|
||||||
|
export type MwColor = string;
|
||||||
|
|
||||||
|
/** A single event in markwhen's mental model. */
|
||||||
|
export interface MwEvent {
|
||||||
|
/** UTC epoch ms. For all-day events, the renderer normalizes to date-only. */
|
||||||
|
start: number;
|
||||||
|
/** UTC epoch ms. Omit for a point event. */
|
||||||
|
end?: number;
|
||||||
|
/** Single-line headline (truncated at ~90 chars when serialized). */
|
||||||
|
title: string;
|
||||||
|
/** Optional body — becomes indented description under the event. */
|
||||||
|
description?: string;
|
||||||
|
/** Freeform tags. Prefixed with `#` on serialize. Used for coloring + filtering. */
|
||||||
|
tags?: string[];
|
||||||
|
/** Optional deep-link back into the canonical module (e.g. rspace:/cal/ev/123). */
|
||||||
|
href?: string;
|
||||||
|
/** Treat date as wall-clock in the named IANA zone, not UTC. */
|
||||||
|
timezone?: string;
|
||||||
|
/** Stable ID for dedup across projections (e.g. module:collection:recordId). */
|
||||||
|
sourceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A module's contribution to a composite timeline. */
|
||||||
|
export interface MwSource {
|
||||||
|
/** Short section slug, e.g. "rcal", "rnotes", "rvote". */
|
||||||
|
id: string;
|
||||||
|
/** Display label for the markwhen `section` header. */
|
||||||
|
label: string;
|
||||||
|
/** Section-level tag; applied to every event from this source. */
|
||||||
|
tag: string;
|
||||||
|
/** Optional color for the tag in frontmatter. */
|
||||||
|
color?: MwColor;
|
||||||
|
/** The events themselves. */
|
||||||
|
events: MwEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of projecting + rendering. */
|
||||||
|
export interface MwProjection {
|
||||||
|
/** Serialized `.mw` text. */
|
||||||
|
text: string;
|
||||||
|
/** Flat list of all events across sources (post-dedup). */
|
||||||
|
events: MwEvent[];
|
||||||
|
/** Total event count. */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A module registers a factory that builds its MwSource on demand.
|
||||||
|
* The composite rApp (rSaga) calls these at projection time.
|
||||||
|
*/
|
||||||
|
export interface MarkwhenSourceFactory {
|
||||||
|
/** Module id, e.g. "rcal". */
|
||||||
|
module: string;
|
||||||
|
/** Human label for picker UI. */
|
||||||
|
label: string;
|
||||||
|
/** Emoji for picker UI. */
|
||||||
|
icon: string;
|
||||||
|
/**
|
||||||
|
* Build the source. Receives the current space slug and an optional
|
||||||
|
* date window — implementations should filter events to the window
|
||||||
|
* to keep DOM render sizes bounded (~5k event ceiling for smooth UX).
|
||||||
|
*/
|
||||||
|
build(ctx: { space: string; from?: number; to?: number }): Promise<MwSource | null>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* Universal declarative creation enumerator.
|
||||||
|
*
|
||||||
|
* Rather than write a bespoke enumerator per rApp, each module declares a
|
||||||
|
* `CreationSpec` that names its Automerge doc patterns, the collection keys
|
||||||
|
* holding records, and a few per-collection accessors. One generic engine
|
||||||
|
* walks the docs and emits creations for all modules uniformly.
|
||||||
|
*
|
||||||
|
* Adding a new module to rPast = ~10 lines in `creation-specs.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadDoc, listDocIds } from './doc-loader';
|
||||||
|
import type { Creation, CreationEnumerator } from './creations';
|
||||||
|
|
||||||
|
export interface CreationCollection {
|
||||||
|
/** Top-level key in the doc (e.g. "events", "tasks", "notes"). */
|
||||||
|
path: string;
|
||||||
|
/** Short record-type tag, e.g. "event", "task", "note". */
|
||||||
|
recordType: string;
|
||||||
|
/** Field on the record holding the creation timestamp. Defaults to "createdAt". */
|
||||||
|
timestampField?: string;
|
||||||
|
/** Pick the display title. Default: record.title || record.name || record.path || id. */
|
||||||
|
title?: (record: Record<string, unknown>, id: string) => string;
|
||||||
|
/** Build a deep-link URL for a record. */
|
||||||
|
href?: (args: { space: string; docId: string; id: string; record: Record<string, unknown> }) => string | undefined;
|
||||||
|
/** Emit extra per-record tags. */
|
||||||
|
tags?: (record: Record<string, unknown>) => string[] | undefined;
|
||||||
|
/** Return false to skip a record (e.g. abstain votes, tombstones). */
|
||||||
|
filter?: (record: Record<string, unknown>) => boolean;
|
||||||
|
/** Pull a short description body. */
|
||||||
|
description?: (record: Record<string, unknown>) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreationSpec {
|
||||||
|
module: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
color?: string;
|
||||||
|
/**
|
||||||
|
* Doc-ID patterns this module owns.
|
||||||
|
* `{space}` is replaced with the space slug.
|
||||||
|
* Trailing `*` is a prefix wildcard (expanded via listDocIds).
|
||||||
|
* Otherwise the pattern is loaded as a single doc.
|
||||||
|
* Examples:
|
||||||
|
* `{space}:cal:events` (single doc)
|
||||||
|
* `{space}:rnotes:vaults:*` (fan-out over all vaults)
|
||||||
|
*/
|
||||||
|
docPatterns: string[];
|
||||||
|
collections: CreationCollection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTitle(record: Record<string, unknown>, id: string, col: CreationCollection): string {
|
||||||
|
if (col.title) return col.title(record, id);
|
||||||
|
const candidates = ['title', 'name', 'path', 'subject', 'label'] as const;
|
||||||
|
for (const k of candidates) {
|
||||||
|
const v = record[k];
|
||||||
|
if (typeof v === 'string' && v.trim()) return v;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expandDocIds(pattern: string, space: string): Promise<string[]> {
|
||||||
|
const resolved = pattern.replace(/\{space\}/g, space);
|
||||||
|
if (resolved.endsWith('*')) {
|
||||||
|
return listDocIds(resolved.slice(0, -1));
|
||||||
|
}
|
||||||
|
return [resolved];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCreationEnumerator(spec: CreationSpec): CreationEnumerator {
|
||||||
|
return {
|
||||||
|
module: spec.module,
|
||||||
|
label: spec.label,
|
||||||
|
icon: spec.icon,
|
||||||
|
color: spec.color,
|
||||||
|
async enumerate({ space, from, to }) {
|
||||||
|
const out: Creation[] = [];
|
||||||
|
for (const pattern of spec.docPatterns) {
|
||||||
|
const docIds = await expandDocIds(pattern, space);
|
||||||
|
for (const docId of docIds) {
|
||||||
|
const doc = await loadDoc<Record<string, unknown>>(docId);
|
||||||
|
if (!doc) continue;
|
||||||
|
for (const col of spec.collections) {
|
||||||
|
const records = doc[col.path];
|
||||||
|
if (!records || typeof records !== 'object') continue;
|
||||||
|
const tsField = col.timestampField ?? 'createdAt';
|
||||||
|
for (const [id, raw] of Object.entries(records as Record<string, unknown>)) {
|
||||||
|
if (!raw || typeof raw !== 'object') continue;
|
||||||
|
const rec = raw as Record<string, unknown>;
|
||||||
|
if (col.filter && !col.filter(rec)) continue;
|
||||||
|
const ts = rec[tsField];
|
||||||
|
if (typeof ts !== 'number' || !Number.isFinite(ts)) continue;
|
||||||
|
if (from !== undefined && ts < from) continue;
|
||||||
|
if (to !== undefined && ts > to) continue;
|
||||||
|
const updatedAt = typeof rec.updatedAt === 'number' ? rec.updatedAt as number : undefined;
|
||||||
|
out.push({
|
||||||
|
createdAt: ts,
|
||||||
|
updatedAt,
|
||||||
|
title: pickTitle(rec, id, col),
|
||||||
|
recordType: col.recordType,
|
||||||
|
recordId: id,
|
||||||
|
href: col.href?.({ space, docId, id, record: rec }),
|
||||||
|
tags: col.tags?.(rec),
|
||||||
|
description: col.description?.(rec),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -848,6 +848,24 @@ export default defineConfig({
|
||||||
resolve(__dirname, "dist/modules/rcal/cal.css"),
|
resolve(__dirname, "dist/modules/rcal/cal.css"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build rpast module component
|
||||||
|
await wasmBuild({
|
||||||
|
configFile: false,
|
||||||
|
root: resolve(__dirname, "modules/rpast/components"),
|
||||||
|
build: {
|
||||||
|
emptyOutDir: false,
|
||||||
|
outDir: resolve(__dirname, "dist/modules/rpast"),
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, "modules/rpast/components/rpast-viewer.ts"),
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: () => "rpast-viewer.js",
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: { entryFileNames: "rpast-viewer.js" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Build network module component
|
// Build network module component
|
||||||
await wasmBuild({
|
await wasmBuild({
|
||||||
configFile: false,
|
configFile: false,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue