openvibers/openvibe.wiki Commits
Wiki: per-space feeds (Atom and JSON Feed) (#59)
Changes
@@@ -166,9 +166,12 @@ records who attached each Media object and what Media said about it at that momewritten before the flag existed are recognised by their "AI assistance" label, and at boot Wikire-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 objectsonly), 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/textmatch; network search is OpenVibe.Search).## Routes@@@ -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({@@@ -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>`;}@@@ -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_idWHERE 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_idWHERE 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). */@@@ -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();
@@ -166,9 +166,12 @@ records who attached each Media object and what Media said about it at that mome
@@ -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
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).
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
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
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).
match; network search is OpenVibe.Search).
## Routes
## Routes
@@ -80,8 +80,8 @@ function createMachine({ svc, config }) {
@@ -80,8 +80,8 @@ function createMachine({ svc, config }) {
setCache(res).type('application/xml').send(files[n - 1]);
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}`,
id: `tag:openvibe.wiki,2026:page/${page.id}/revision/${page.published_revision}`,
url: svc.pageUrl(space, page),
url: svc.pageUrl(space, page),
title: rev.fields.title,
title: rev.fields.title,
@@ -113,6 +113,23 @@ function createMachine({ svc, config }) {
@@ -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())));
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) => {
router.get('/llms.txt', wrap(async (_req, res) => {
const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');
const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');
setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
setCache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
@@ -106,6 +106,7 @@ ${space.description ? html`<p>${space.description}</p>` : ''}
@@ -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>` : ''}
${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>` : ''}
${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>`}
<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>`;
</section>`;
}
}
@@ -135,6 +135,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -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
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'
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 ?`),
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
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'
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
ORDER BY p.id LIMIT 50000`),
ORDER BY p.id LIMIT 50000`),
@@ -1138,6 +1141,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1138,6 +1141,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
async publishedPublic() { return await expandPublished(await q.allPublishedPublic.all()); },
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)))); },
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 and media ----------------------------------------------------------------
/** Citations go on an unpublished head revision (a published revision's sources are fixed). */
/** Citations go on an unpublished head revision (a published revision's sources are fixed). */
@@ -84,6 +84,23 @@ function atom(text) {
@@ -84,6 +84,23 @@ function atom(text) {
f = atom((await H.req(h, 'GET', '/feed.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.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page']);
assert.strictEqual(f.updated, f.entries[0].updated);
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');
console.log('feeds ok');
} finally {
} finally {
await h.stop();
await h.stop();