openvibers/openvibe.today Commits

Publish the Today card as a frame.menu-cards array (#111)

OP OpenVibers committed 6 days ago · 9a1f85d68a · 5 files, +252 −5

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a83d009..beaa801 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0"
@@@ -1150,9 +1150,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.312.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
++ "version": "0.317.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
++ "integrity": "sha512-qy9hGj4cl5MSHHcYmOKcrL1BmMrbQ9uJCGYH5zxhp3gu6RwZqu7kHLVWBCcy5y7SY8odkxNuVU5Ts/RksiivxQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 3a28d48..53ba179 100644
---- a/package.json
++++ b/package.json
@@@ -23,7 +23,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0"
ddiff --git a/server/app.js b/server/app.js
iindex 98dc3e9..d1c989b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -178,6 +178,21 @@ async function createApp(opts = {}) {
},
}));
++ // The menu's network card (frame.menu-cards@1): the next public events today, or tomorrow's. Only what the
++ // public schedule already shows (events.listPublic) is read, so private and followers-only events never
++ // reach it; the loader reads the next two UTC days and the card picks today's, else tomorrow's.
++ {
++ const { createMenuCard, startOfDay, DAY } = require('./menu-card');
++ const menuCard = createMenuCard({
++ load: async (at) => await events.listPublic({
++ from: new Date(startOfDay(at)).toISOString(),
++ to: new Date(startOfDay(at) + 2 * DAY).toISOString(),
++ limit: 50, exceptKinds: ['holiday'],
++ }),
++ });
++ app.get('/.well-known/openvibe-menu.json', menuCard.handler);
++ }
++
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.TODAY_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', createApi(ctx));
ddiff --git a/server/menu-card.js b/server/menu-card.js
nnew file mode 100644
iindex 0000000..6d6dd7e
---- /dev/null
++++ b/server/menu-card.js
@@@ -0,0 +1,130 @@
++'use strict';
++// The menu's network card for OpenVibe.Today (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
++//
++// Today publishes "Today": the next real public events scheduled for today (UTC), each with its own time and
++// channel or place, linking to its event page. When nothing is left today the card moves to tomorrow and its
++// title says "Tomorrow"; with nothing on either day it is still published, with no items, and Network skips it.
++//
++// Only public, non-cancelled events the public schedule already shows (events.listPublic) are read. Personal
++// agendas, reminders and followers-only events never reach the card. The file is CORS *, cached for five
++// minutes; a failed database keeps the last good card.
++const BASE = 'https://openvibe.today';
++const CARD_ID = 'today';
++const SITE = 'today';
++const CARD_TTL_S = 300;
++const CACHE_MS = CARD_TTL_S * 1000;
++const DAY = 24 * 60 * 60_000;
++const LIMIT = 6;
++
++/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
++function plain(value, max) {
++ return String(value == null ? '' : value)
++ .replace(/<[^>]*>/g, ' ')
++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
++ .replace(/\s+/g, ' ')
++ .trim()
++ .slice(0, max);
++}
++
++/** The UTC day of an instant, YYYY-MM-DD. The card is the same for every reader, so "today" is UTC. */
++const dayKey = (value) => new Date(value).toISOString().slice(0, 10);
++
++/** The start of a UTC day, in milliseconds. */
++const startOfDay = (value) => Date.parse(`${dayKey(value)}T00:00:00.000Z`);
++
++/** "14:00 UTC" in the event's own zone (UTC when it has none or it is unknown). */
++function timeLabel(iso, tz) {
++ const d = new Date(iso);
++ if (!Number.isFinite(d.getTime())) return '';
++ const zone = tz || 'UTC';
++ let parts;
++ try {
++ parts = new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short' }).formatToParts(d);
++ } catch {
++ parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short' }).formatToParts(d);
++ }
++ const get = (type) => (parts.find((p) => p.type === type) || {}).value || '';
++ const time = `${get('hour')}:${get('minute')}`;
++ const zoneName = get('timeZoneName');
++ return [time, zoneName].filter(Boolean).join(' ');
++}
++
++/** Where an event happens: its channel when it has one, else the host its link names, else the site. */
++function placeOf(e) {
++ if (e.channel) return `@${String(e.channel)}`;
++ try {
++ const host = new URL(String(e.url || '')).hostname.replace(/^www\./, '');
++ if (host) return host;
++ } catch { /* no link */ }
++ return 'OpenVibe.Today';
++}
++
++/** The card itself. `events` are public events (events.listPublic rows); `at` is the build time. */
++function cardFrom(events, at = Date.now()) {
++ const upcoming = (Array.isArray(events) ? events : [])
++ .filter((e) => e && e.starts_at && e.status !== 'cancelled' && e.status !== 'done')
++ .filter((e) => !e.visibility || e.visibility === 'public')
++ .filter((e) => {
++ const start = Date.parse(String(e.starts_at));
++ return Number.isFinite(start) && start >= at;
++ })
++ .sort((a, b) => Date.parse(a.starts_at) - Date.parse(b.starts_at));
++ const today = dayKey(at);
++ const tomorrow = dayKey(at + DAY);
++ const todayItems = upcoming.filter((e) => dayKey(e.starts_at) === today).slice(0, LIMIT);
++ const next = todayItems.length ? todayItems : upcoming.filter((e) => dayKey(e.starts_at) === tomorrow).slice(0, LIMIT);
++ const items = [];
++ for (const e of next) {
++ const title = plain(e.title, 80);
++ if (!title) continue;
++ const when = timeLabel(e.starts_at, e.tz);
++ const meta = [when, plain(placeOf(e), 60)].filter(Boolean).join(' · ');
++ items.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}/e/${encodeURIComponent(String(e.id))}` });
++ }
++ return {
++ id: CARD_ID,
++ site: SITE,
++ title: todayItems.length ? 'Today' : (items.length ? 'Tomorrow' : 'Today'),
++ kind: 'rows',
++ items,
++ more: { label: 'Full schedule', url: `${BASE}/` },
++ updated_at: new Date(at).toISOString(),
++ ttl_s: CARD_TTL_S,
++ };
++}
++
++/** The route. `load(at)` returns the next two UTC days of public events; `now` is injectable for the test. */
++function createMenuCard({ load, now = Date.now } = {}) {
++ let cached = null;
++ let cachedAt = 0;
++ let pending = null;
++
++ async function card() {
++ const at = now();
++ if (cached && at - cachedAt < CACHE_MS) return cached;
++ if (pending) return pending;
++ pending = (async () => {
++ try {
++ cached = cardFrom(await load(at), at);
++ cachedAt = at;
++ } catch {
++ // Keep the last good card rather than publish an empty one on a database hiccup.
++ if (!cached) { cached = cardFrom([], at); cachedAt = at; }
++ }
++ return cached;
++ })().finally(() => { pending = null; });
++ return pending;
++ }
++
++ async function handler(_req, res) {
++ const body = await card();
++ res.set('Cache-Control', 'public, max-age=60')
++ .set('Access-Control-Allow-Origin', '*')
++ .set('Cross-Origin-Resource-Policy', 'cross-origin')
++ .json([body]);
++ }
++
++ return { card, handler, cardFrom, cacheMs: CACHE_MS };
++}
++
++module.exports = { createMenuCard, cardFrom, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, LIMIT };
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
nnew file mode 100644
iindex 0000000..a14e62a
---- /dev/null
++++ b/test/menu-card.test.js
@@@ -0,0 +1,102 @@
++'use strict';
++// The menu's network card for OpenVibe.Today (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves
++// "Today" — the next real public events left today, or tomorrow's with the title "Tomorrow". The array validates
++// against frame.menu-cards@1, never carries a cancelled, done or private event, is CORS * and cached for five minutes.
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { createMenuCard, cardFrom, timeLabel, placeOf, CARD_TTL_S } = require('../server/menu-card');
++
++const NOW = Date.parse('2026-11-07T12:00:00Z');
++const event = (over = {}) => ({
++ id: 'ev1', title: 'A real stream', channel: 'nova', starts_at: '2026-11-07T14:00:00Z',
++ tz: 'UTC', status: 'scheduled', kind: 'stream', visibility: 'public', url: null, ...over,
++});
++
++// ── Today's card: shape, words and contract ─────────────────────────────
++const card = cardFrom([
++ event({ id: 'past', starts_at: '2026-11-07T09:00:00Z' }),
++ event({ id: 'done', status: 'done' }),
++ event({ id: 'gone', status: 'cancelled' }),
++ event({ id: 'ev2', title: '<b>Later</b>\nshow', channel: null, url: 'https://openvibe.live/@kitchen', starts_at: '2026-11-07T18:30:00Z' }),
++ event({ id: 'ev1', title: 'A real stream', starts_at: '2026-11-07T14:00:00Z' }),
++], NOW);
++const checked = contracts.validate('frame.menu-cards@1', [card]);
++assert.ok(checked.valid, JSON.stringify(checked.errors));
++assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);
++assert.strictEqual(card.id, 'today');
++assert.strictEqual(card.site, 'today');
++assert.strictEqual(card.title, 'Today');
++assert.strictEqual(card.kind, 'rows');
++assert.strictEqual(card.ttl_s, CARD_TTL_S);
++assert.deepStrictEqual(card.items, [
++ { title: 'A real stream', meta: '14:00 UTC · @nova', url: 'https://openvibe.today/e/ev1' },
++ { title: 'Later show', meta: '18:30 UTC · openvibe.live', url: 'https://openvibe.today/e/ev2' },
++]);
++assert.deepStrictEqual(card.more, { label: 'Full schedule', url: 'https://openvibe.today/' });
++assert.ok(card.title.length <= 60);
++for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.today/e/'));
++
++// Nothing left today: tomorrow's events are shown and the card says "Tomorrow".
++const tomorrow = cardFrom([
++ event({ id: 'ev3', title: 'Tomorrow workshop', starts_at: '2026-11-08T10:00:00Z' }),
++ event({ id: 'ev4', title: 'Tomorrow stream', starts_at: '2026-11-08T20:00:00Z' }),
++], NOW);
++assert.strictEqual(tomorrow.title, 'Tomorrow');
++assert.deepStrictEqual(tomorrow.items.map((i) => i.title), ['Tomorrow workshop', 'Tomorrow stream']);
++assert.ok(contracts.validate('frame.menu-cards@1', [tomorrow]).valid);
++
++// No entries at all (or none the card may show): an empty items array, still a valid card, and Network skips it.
++const empty = cardFrom([event({ starts_at: '2026-11-07T09:00:00Z' })], NOW);
++assert.deepStrictEqual(empty.items, []);
++assert.strictEqual(empty.title, 'Today');
++assert.ok(contracts.validate('frame.menu-cards@1', [empty]).valid);
++assert.deepStrictEqual(cardFrom(null, NOW).items, []);
++assert.deepStrictEqual(cardFrom([event({ visibility: 'followers' }), event({ title: ' ' })], NOW).items, []);
++
++// At most six entries, in start order.
++const many = Array.from({ length: 9 }, (_, i) => event({ id: `ev${i}`, title: `Event ${i}`, starts_at: `2026-11-07T${String(13 + i).padStart(2, '0')}:00:00Z` }));
++assert.strictEqual(cardFrom(many, NOW).items.length, 6);
++
++// Hours are shown in the event's own zone, and a place is the channel, the link's host, or the site.
++assert.strictEqual(timeLabel('2026-11-07T14:00:00Z', 'UTC'), '14:00 UTC');
++assert.ok(timeLabel('2026-11-07T14:00:00Z', 'America/New_York').startsWith('09:00 '), 'the event\'s own zone is named');
++assert.strictEqual(placeOf(event()), '@nova');
++assert.strictEqual(placeOf(event({ channel: null, url: 'https://openvibe.live/@kitchen' })), 'openvibe.live');
++assert.strictEqual(placeOf(event({ channel: null })), 'OpenVibe.Today');
++
++// ── The five-minute cache and the route's headers ───────────────────────
++(async () => {
++ let t = NOW;
++ let loads = 0;
++ const menu = createMenuCard({ load: async () => { loads++; return [event()]; }, now: () => t });
++ const first = await menu.card();
++ assert.strictEqual(loads, 1);
++ assert.strictEqual(await menu.card(), first);
++ assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');
++ t += CARD_TTL_S * 1000 + 1;
++ await menu.card();
++ assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');
++
++ const headers = {};
++ let body = null;
++ const res = {
++ set(k, v) { headers[k] = v; return this; },
++ json(v) { body = v; return this; },
++ };
++ await menu.handler({}, res);
++ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');
++ assert.strictEqual(headers['Cross-Origin-Resource-Policy'], 'cross-origin');
++ assert.strictEqual(headers['Cache-Control'], 'public, max-age=60');
++ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');
++ assert.ok(contracts.validate('frame.menu-cards@1', body).valid);
++
++ // A failing database keeps the last good card rather than publishing an empty schedule.
++ let fail = false;
++ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [event()]; }, now: () => t });
++ const good = await flaky.card();
++ fail = true;
++ t += CARD_TTL_S * 1000 + 1;
++ assert.strictEqual(await flaky.card(), good);
++
++ console.log('menu card: shape, contract, today/tomorrow, empty schedule, plain text, 5 min cache, CORS and last-good card');
++})().catch((err) => { console.error(err); process.exit(1); });