openvibers/openvibe.wiki Commits

Wiki: per-space feeds (Atom and JSON Feed) (#59)

OP OpenVibers committed 29 days ago · 8a553f379e · 5 files, +50 −3

Each public space now has its own feed of published pages, newest first:

- `/s/<space>/feed.atom` and `/s/<space>/feed.json` (same items, caching and listable rules as the site-wide `/feed.atom` and `/feed.json`)
- a private, deleted or unknown space answers 404; an empty public space answers a valid feed with no entries
- the space page links both ("Follow this space: Atom · JSON Feed")

The README's "Not built yet" list no longer names per-space feeds. `test/feeds.test.js` covers the space filter, the private and unknown 404s and the link.

Changes

ddiff --git a/README.md b/README.md
iindex 478b42d..3dabf42 100644
---- a/README.md
++++ b/README.md
@@@ -166,9 +166,12 @@ records who attached each Media object and what Media said about it at that mome
written before the flag existed are recognised by their "AI assistance" label, and at boot Wiki
re-sends Search any document whose indexability changed (`reconcileIndex`, idempotent).
++Each public space has its own feed of published pages, `/s/<space>/feed.atom` and `/s/<space>/feed.json`, linked from
++the space page (the site-wide `/feed.atom` and `/feed.json` stay).
++
Not built yet: page-level talk moderation, media upload from Wiki (attach existing Media objects
only), consuming Media/Sources change events (Media does not emit deletion events yet; checks are
--pull-based), per-space feeds, full-text ranking (Wiki's own `/api/v1/search` is a simple title/text
++pull-based), full-text ranking (Wiki's own `/api/v1/search` is a simple title/text
match; network search is OpenVibe.Search).
## Routes
ddiff --git a/server/http/machine.js b/server/http/machine.js
iindex 57d45ca..7ddf1c6 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -80,8 +80,8 @@ function createMachine({ svc, config }) {
setCache(res).type('application/xml').send(files[n - 1]);
}));
-- async function feedItems() {
-- return (await svc.recentChanges(50)).map(({ space, page, rev, decision }) => ({
++ async function feedItems(changes = null) {
++ return (changes || await svc.recentChanges(50)).map(({ space, page, rev, decision }) => ({
id: `tag:openvibe.wiki,2026:page/${page.id}/revision/${page.published_revision}`,
url: svc.pageUrl(space, page),
title: rev.fields.title,
@@@ -113,6 +113,23 @@ function createMachine({ svc, config }) {
setCache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));
}));
++ // One space's recent changes (Atom and JSON Feed), for people who follow that space only. A private, deleted or
++ // unknown space has no feed (404); an empty public space has a valid feed with no entries.
++ async function spaceFeed(req, res) {
++ const found = /^[a-z0-9-]{1,64}$/.test(req.params.space) ? await svc.recentChangesIn(req.params.space, 50) : null;
++ if (!found) return res.status(404).type('text/plain').send('Not found');
++ const { space } = found;
++ const items = await feedItems(found.changes);
++ const base = `${origin}/s/${encodeURIComponent(space.slug)}`;
++ const title = `OpenVibe.Wiki: ${space.name}`;
++ if (req.params.format === 'atom') {
++ const updated = items.some((i) => i.decision.listable) ? null : new Date(space.updated_at || 0).toISOString();
++ return setCache(res).type('application/atom+xml').send(seo.atomFeed({ title, link: base, feedUrl: `${base}/feed.atom`, id: `${base}/feed.atom`, ...(updated ? { updated } : {}) }, items));
++ }
++ return setCache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title, link: base, feedUrl: `${base}/feed.json`, description: `Pages published in the ${space.name} space of OpenVibe.Wiki, newest first.` }, items)));
++ }
++ router.get('/s/:space/feed.:format(atom|json)', wrap(spaceFeed));
++
router.get('/llms.txt', wrap(async (_req, res) => {
const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');
setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
ddiff --git a/server/render/views.js b/server/render/views.js
iindex aadded1..3c26fdf 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -106,6 +106,7 @@ ${space.description ? html`<p>${space.description}</p>` : ''}
${canEdit ? html`<p><a class="wk-button" href="/s/${e(space.slug)}/new">New page</a>${canManage ? html` <a href="/s/${e(space.slug)}/import">Import pages</a> <a href="/s/${e(space.slug)}/settings">Space settings</a>` : ''}</p>` : ''}
${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length} AI proposal(s) wait for review.`)} <a href="/s/${e(space.slug)}/proposals">Review them</a></p>` : ''}
<h2>Pages</h2>${tree.length ? raw(treeHtml(tree, space)) : html`<p class="wk-muted">No pages yet.</p>`}
++${space.visibility === 'public' ? html`<p class="wk-muted">Follow this space: <a href="/s/${e(space.slug)}/feed.atom" type="application/atom+xml">Atom</a> · <a href="/s/${e(space.slug)}/feed.json" type="application/feed+json">JSON Feed</a></p>` : ''}
</section>`;
}
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex e004513..e462fba 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -135,6 +135,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
recent: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
ORDER BY p.revision_published_at DESC, p.id LIMIT ?`),
++ recentInSpace: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
++ WHERE s.slug = ? AND p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
++ ORDER BY p.revision_published_at DESC, p.id LIMIT ?`),
allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
ORDER BY p.id LIMIT 50000`),
@@@ -1138,6 +1141,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
async publishedPublic() { return await expandPublished(await q.allPublishedPublic.all()); },
async recentChanges(limit = 50) { return await expandPublished(await q.recent.all(Math.min(200, Math.max(1, limit)))); },
++ /** The same, for one public space (its feed): null when the slug names no public, live space. */
++ async recentChangesIn(slug, limit = 50) {
++ const space = await q.spaceBySlug.get(String(slug));
++ if (!space || space.deleted_at || space.visibility !== 'public') return null;
++ return { space, changes: await expandPublished(await q.recentInSpace.all(space.slug, Math.min(200, Math.max(1, limit)))) };
++ },
// Citations and media ----------------------------------------------------------------
/** Citations go on an unpublished head revision (a published revision's sources are fixed). */
ddiff --git a/test/feeds.test.js b/test/feeds.test.js
iindex 110402a..b78c0f1 100644
---- a/test/feeds.test.js
++++ b/test/feeds.test.js
@@@ -84,6 +84,23 @@ function atom(text) {
f = atom((await H.req(h, 'GET', '/feed.atom')).text);
assert.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page']);
assert.strictEqual(f.updated, f.entries[0].updated);
++
++ // One space's feed: only its own listable pages; a private or unknown space has none; the space page links it.
++ const other = await h.svc.createSpace({ name: 'Other', slug: 'other' }, owner);
++ const elsewhere = (await h.svc.createPage(other.id, { title: 'Elsewhere page', body: H.LONG, citations: cite }, owner)).page;
++ await h.svc.publish(elsewhere.id, {}, owner);
++ r = await H.req(h, 'GET', '/s/notes/feed.atom');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);
++ f = atom(r.text);
++ assert.strictEqual(f.id, 'http://wiki.test/s/notes/feed.atom');
++ assert.strictEqual(f.title, 'OpenVibe.Wiki: Notes');
++ assert.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page'], 'only the Notes space, only listable pages');
++ const json = (await H.req(h, 'GET', '/s/other/feed.json')).json;
++ assert.deepStrictEqual(json.items.map((i) => i.url), ['http://wiki.test/w/other/elsewhere-page']);
++ assert.strictEqual((await H.req(h, 'GET', '/s/secret/feed.atom')).status, 404, 'a private space has no feed');
++ assert.strictEqual((await H.req(h, 'GET', '/s/nope/feed.json')).status, 404, 'an unknown space has no feed');
++ assert.ok((await H.req(h, 'GET', '/s/notes')).text.includes('href="/s/notes/feed.atom"'), 'the space page links its feed');
console.log('feeds ok');
} finally {
await h.stop();