openvibers/openvibe.community Commits

Paste list for feeds: GET /api/pastes takes sort=top (views, a like worth five views, newest first on a tie), since= (created at or after; ISO 8601 or 'YYYY-MM-DD HH:MM:SS' UTC, compared with datetime() so imported ISO timestamps work; an unreadable value is ignored) and pinned_first=0 (a pinned paste stays in sort order instead of leading every list). With the existing origin=user|ai this is what OpenVibe.Live's Content feed (people's pastes) and Moments feed (AI pastes) read: a list merged with VODs and clips by date or score needs every source in its own sort order, and "top this week" needs a window. Defaults are unchanged

OP OpenVibers committed 23 Sept 2026 · 6d534d8609 · 5 files, +109 −5

test/pastes-feed-list.test.js covers the origin split with hidden and burn-after-read pastes left out of both, pinned ordering both ways, top ranking, since in both forms with the total counting the window, and offset paging over the sorted list. README and the api.js route comment list the parameters

Changes

ddiff --git a/README.md b/README.md
iindex c5c4cac..d4a4bbe 100644
---- a/README.md
++++ b/README.md
@@@ -46,7 +46,11 @@ With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API
(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and
response shapes the browser clients already use, and every page, `/p/:slug/raw`,
`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which
--redirects its old paste URLs here after cutover).
++redirects its old paste URLs here after cutover). The list (`GET /api/pastes`) also takes
++`origin=user|ai` (people's pastes or AI output), `sort=newest|oldest|top` (`top`: views, a like
++worth five), `since=` (created at or after; ISO 8601 or `YYYY-MM-DD HH:MM:SS` UTC) and
++`pinned_first=0` (pinned pastes stay in sort order), which is how OpenVibe.Live's Content and
++Moments feeds read it.
- **People are Network subjects** (`usr_…`, `gst_…`), never service-local integers. A browser's
owner is the JWT's `subject_id` (older tokens are resolved once through the Network and
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex ab072a4..8bd87d1 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -6,7 +6,10 @@
* Same paths, bodies, status codes and response shapes the browser clients already use against
* OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.
*
-- * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted)
++ * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted&since&pinned_first)
++ * sort=newest (default) | oldest | top (views, a like worth five views);
++ * since= created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS' UTC);
++ * pinned_first=0 leaves pinned pastes in sort order (feeds that merge lists)
* ?needs_ai=1 — AI work queue, community.paste.moderate only
* POST / create (JSON text paste, or multipart with a `screenshot` file)
* GET /config limits
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 51d2cbe..4332373 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -54,6 +54,14 @@ const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v ==
const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
++/** A list's ?since= as 'YYYY-MM-DD HH:MM:SS' (UTC), from that form or ISO 8601; null when absent or unreadable. */
++function sinceParam(value) {
++ if (value == null || value === '') return null;
++ const s = String(value).trim();
++ if (/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/.test(s)) return s.length === 10 ? `${s} 00:00:00` : s;
++ const t = Date.parse(s);
++ return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
++}
function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
@@@ -291,7 +299,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (needsAi && !hasCap(v, 'community.paste.moderate')) fail(403, 'needs_ai requires community.paste.moderate');
const limit = intIn(q.limit, 50, 1, 200);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
-- const opts = { limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null };
++ const opts = {
++ limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null,
++ since: sinceParam(q.since), pinnedFirst: !['0', 'false'].includes(String(q.pinned_first)),
++ };
// ?username= lists one person's pastes (Live resolved it to its own id; we go through
// the projection cache). Their own unlisted/private ones only for them (or staff).
if (q.username && q.username !== 'all') {
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex ec669e5..13c2940 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -186,7 +186,10 @@ function softDelete(db, id) {
* opts.search substring of title or content
* opts.origin 'user' | 'ai' (default: all)
* opts.needsAi pastes without an AI summary, any visibility (staff work queue)
-- * opts.sort 'oldest' | newest (default)
++ * opts.sort 'oldest' | 'top' (views, a like worth five) | newest (default)
++ * opts.since created at or after this 'YYYY-MM-DD HH:MM:SS' (UTC) moment
++ * opts.pinnedFirst pinned pastes lead the list (default true); feeds that merge this list
++ * with others by date or score turn it off so the order is the sort alone
* Returns { rows, total }.
*/
function listPastes(db, opts = {}) {
@@@ -205,11 +208,17 @@ function listPastes(db, opts = {}) {
where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");
params.push(q, q);
}
++ // datetime() on both sides: imported rows may carry ISO text, which must compare by time.
++ if (opts.since) { where.push('datetime(created_at) >= datetime(?)'); params.push(opts.since); }
const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';
++ const pinned = opts.pinnedFirst === false ? '' : 'pinned DESC, ';
++ const order = opts.sort === 'top'
++ ? `${pinned}(views + 5 * likes) DESC, created_at DESC, id DESC`
++ : `${pinned}created_at ${dir}, id ${dir}`;
const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);
const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);
const clause = where.join(' AND ');
-- const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY pinned DESC, created_at ${dir}, id ${dir} LIMIT ? OFFSET ?`)
++ const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY ${order} LIMIT ? OFFSET ?`)
.all(...params, limit, offset);
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);
return { rows, total };
ddiff --git a/test/pastes-feed-list.test.js b/test/pastes-feed-list.test.js
nnew file mode 100644
iindex 0000000..6eabec1
---- /dev/null
++++ b/test/pastes-feed-list.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++/**
++ * The paste list as OpenVibe.Live's Content (people's pastes) and Moments (AI pastes) feeds read it:
++ * ?origin splits people's work from AI output, ?sort=top ranks by views with a like worth five,
++ * ?since= bounds a "top this week" window, and ?pinned_first=0 keeps a pinned paste in sort order so
++ * a list merged with others by date or score stays in order. Hidden pastes never appear.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { openDb } = require('../server/db');
++const store = require('../server/pastes/store');
++const { createPasteService } = require('../server/pastes/service');
++const { check, done } = require('./helpers/app');
++
++const db = openDb(':memory:');
++const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });
++const alice = ids.newId('user');
++const viewer = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
++const daysAgo = (n) => new Date(Date.now() - n * 86400_000).toISOString().replace('T', ' ').slice(0, 19);
++
++const add = (slug, { origin = 'user', visibility = 'public', views = 0, likes = 0, pinned = 0, age = 1, burn = 0, iso = false } = {}) => {
++ const row = store.insertPaste(db, { slug, owner_subject: origin === 'ai' ? null : alice, origin, type: 'paste', title: slug, content: slug, language: 'text', visibility, pinned, burn_after_read: burn });
++ const at = iso ? new Date(Date.now() - age * 86400_000).toISOString() : daysAgo(age);
++ db.prepare('UPDATE pastes SET views = ?, likes = ?, created_at = ? WHERE id = ?').run(views, likes, at, row.id);
++};
++add('person-new', { age: 1, views: 4 });
++add('person-liked', { age: 2, views: 1, likes: 3 }); // score 16
++add('person-pinned-old', { age: 30, views: 2, pinned: 1 });
++add('person-private', { age: 1, views: 99, visibility: 'private' });
++add('person-unlisted', { age: 1, views: 99, visibility: 'unlisted' });
++add('person-burn', { age: 1, views: 99, burn: 1 });
++add('person-imported-iso', { age: 3, views: 5, iso: true });
++add('ai-moment', { origin: 'ai', age: 1, views: 7 });
++add('ai-old', { origin: 'ai', age: 20, views: 50 });
++
++const slugs = async (q) => (await svc.list(viewer, q)).pastes.map((p) => p.slug);
++
++(async () => {
++ await check('origin=user and origin=ai split the public list; hidden pastes are in neither', async () => {
++ const people = await slugs({ origin: 'user', pinned_first: '0' });
++ assert.deepStrictEqual(people, ['person-new', 'person-liked', 'person-imported-iso', 'person-pinned-old']);
++ assert.deepStrictEqual(await slugs({ origin: 'ai' }), ['ai-moment', 'ai-old']);
++ const out = await svc.list(viewer, { origin: 'ai' });
++ assert.strictEqual(out.total, 2);
++ assert.ok(out.pastes.every((p) => p.origin === 'ai' && p.owner_subject === null));
++ });
++
++ await check('pinned pastes lead by default; pinned_first=0 keeps them in date order', async () => {
++ assert.strictEqual((await slugs({ origin: 'user' }))[0], 'person-pinned-old');
++ assert.strictEqual((await slugs({ origin: 'user', pinned_first: 'false' })).at(-1), 'person-pinned-old');
++ });
++
++ await check('sort=top ranks by views with a like worth five views, newest first on a tie', async () => {
++ assert.deepStrictEqual(await slugs({ origin: 'user', sort: 'top', pinned_first: '0' }),
++ ['person-liked', 'person-imported-iso', 'person-new', 'person-pinned-old']);
++ });
++
++ await check('since bounds the window (ISO and SQL forms; imported ISO timestamps compare by time)', async () => {
++ const week = new Date(Date.now() - 7 * 86400_000).toISOString();
++ assert.deepStrictEqual(await slugs({ origin: 'user', sort: 'top', since: week, pinned_first: '0' }),
++ ['person-liked', 'person-imported-iso', 'person-new']);
++ const out = await svc.list(viewer, { origin: 'ai', since: daysAgo(7) });
++ assert.deepStrictEqual(out.pastes.map((p) => p.slug), ['ai-moment']);
++ assert.strictEqual(out.total, 1, 'the total counts the window');
++ assert.strictEqual((await svc.list(viewer, { since: 'yesterday-ish' })).total, 6, 'an unreadable since is ignored');
++ });
++
++ await check('paging with offset walks the sorted list without repeats', async () => {
++ const all = await slugs({ origin: 'user', sort: 'top', pinned_first: '0', limit: 50 });
++ const paged = [];
++ for (let offset = 0; offset < all.length; offset += 2) paged.push(...await slugs({ origin: 'user', sort: 'top', pinned_first: '0', limit: 2, offset }));
++ assert.deepStrictEqual(paged, all);
++ });
++
++ db.close();
++ done();
++})();