openvibers/openvibe.tools Commits

Favourite tools (tools.usage v2, Contracts 0.41.0, WS-B task 9): the launcher's Your tools shows starred tools first with a star on each card, PUT/DELETE /api/v1/me/favorites/:tool, /api/v1/me/recent-tools returns favorites; every recent-tools write keeps the record's other fields (it replaced the whole record, which would have dropped favourites starred on my.openvibe.network)

OP OpenVibers committed 25 Sept 2026 · df821b3bbf · 5 files, +99 −9


Changes

ddiff --git a/apps/_shared/test/usage.test.js b/apps/_shared/test/usage.test.js
iindex 7b56362..ffbabc7 100644
---- a/apps/_shared/test/usage.test.js
++++ b/apps/_shared/test/usage.test.js
@@@ -50,6 +50,23 @@ const fetchImpl = async (url, opts) => {
assert.strictEqual(modules.get(USR).data.recent.length, 30, 'capped at the namespace limit');
assert.strictEqual(modules.get(USR).data.recent[0].tool, 'tool39');
++ // Favourites (tools.usage v2): starred newest first, kept by every recent-tools write, unstarred.
++ assert.deepStrictEqual(await r.setFavorite(USR, 'yaml', true), ['yaml']);
++ assert.deepStrictEqual(await r.setFavorite(USR, 'uuid', true), ['uuid', 'yaml']);
++ assert.deepStrictEqual(await r.setFavorite(USR, 'uuid', true), ['uuid', 'yaml'], 'starring twice changes nothing');
++ const revBefore = modules.get(USR).revision;
++ await r.setFavorite(USR, 'png', false);
++ assert.strictEqual(modules.get(USR).revision, revBefore, 'unstarring a tool that is not starred writes nothing');
++ t += 11 * 60 * 1000; r.record(USR, 'base64');
++ await r.flush();
++ assert.strictEqual(modules.get(USR).data.recent[0].tool, 'base64');
++ assert.deepStrictEqual(modules.get(USR).data.favorites, ['uuid', 'yaml'], 'a recent-tools write keeps the favourites');
++ conflictOnce = true;
++ await assert.doesNotReject(r.setFavorite(USR, 'md5', true), 'a moved revision is read again');
++ assert.deepStrictEqual(await r.favorites(USR), ['md5'], 'after the conflict: starred on top of what the other writer left');
++ assert.deepStrictEqual(await r.setFavorite(USR, 'md5', false), []);
++ await assert.rejects(r.setFavorite(USR, '../x', true), /bad subject or tool/);
++
// The page middleware: a signed-in document request for a tool host counts; assets, APIs and unknown hosts do not.
const got = [];
const mw = usagePages({ snapshot: () => ({ tools: [{ id: 'jsonminify', hosts: ['json-minifier.openvibe.tools', 'jsonminify.openvibe.tools'] }] }), rec: { enabled: true, record: (s, id) => got.push(id) } });
ddiff --git a/apps/_shared/usage.js b/apps/_shared/usage.js
iindex 4688aa7..76418c4 100644
---- a/apps/_shared/usage.js
++++ b/apps/_shared/usage.js
@@@ -2,7 +2,9 @@
// ═══════════════════════════════════════════════════════════════
// tools.usage (platform S9): a signed-in person's recently used tools, kept in their Network user
// module `tools.usage` ({ recent: [{ tool, at }] }, newest first, 30 at most; openvibe-contracts
--// manifests/namespaces/tools.usage.json) for the launchers across the network.
++// manifests/namespaces/tools.usage.json) for the launchers across the network. Since v2 (contracts
++// 0.41.0) it also holds `favorites` (tool ids, 24 at most), which the person stars here or on
++// my.openvibe.network; every write keeps the fields it did not change.
//
// app.use(usagePages({ snapshot: toolRegistry.snapshot })) after guard.identify: a page view of
// a tool by a signed-in person counts
@@@ -22,6 +24,7 @@
const NS = 'tools.usage';
const MAX = 30;
++const FAV_MAX = 24;
const SAME_TOOL_MS = 10 * 60 * 1000;
const FLUSH_MS = 60 * 1000;
const MAX_PENDING = 2000;
@@@ -76,7 +79,9 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
const had = cur.status === 200 && cur.body && cur.body.data && Array.isArray(cur.body.data.recent) ? cur.body.data.recent : [];
const add = [...fresh].reverse().map(([tool, at]) => ({ tool, at }));
const recent = [...add, ...had.filter((e) => e && !fresh.has(e.tool))].slice(0, MAX);
-- const w = await call('PUT', subject, { body: { data: { recent } }, revision: cur.status === 200 ? cur.body.revision : 0 });
++ // Keep the record's other fields (favorites, which the person writes too: tools.usage v2).
++ const rest = cur.status === 200 && cur.body && cur.body.data && typeof cur.body.data === 'object' ? cur.body.data : {};
++ const w = await call('PUT', subject, { body: { data: { ...rest, recent } }, revision: cur.status === 200 ? cur.body.revision : 0 });
if (w.status === 412 || w.status === 409) { stats.conflicts++; return false; }
if (w.status >= 300) throw new Error(`write: ${w.status} ${(w.body && w.body.code) || ''}`);
stats.written++;
@@@ -110,7 +115,34 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
return [...top, ...had.filter((e) => e && (!fresh || !fresh.has(e.tool)))].slice(0, MAX);
}
-- return { enabled, record, recent, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };
++ /** The person's favourite tools (tools.usage v2 `favorites`, newest first): [toolId]. */
++ async function favorites(subject) {
++ if (!enabled || !USR_RE.test(String(subject || ''))) return [];
++ const cur = await call('GET', subject);
++ const fav = cur.status === 200 && cur.body && cur.body.data && Array.isArray(cur.body.data.favorites) ? cur.body.data.favorites : [];
++ return fav.filter((id) => TOOL_RE.test(String(id)));
++ }
++
++ /** Star (on) or unstar a tool: a read-modify-write naming the revision read; a 412 reads again. → favorites */
++ async function setFavorite(subject, tool, on) {
++ if (!enabled) throw Object.assign(new Error('favourites are off on this server'), { status: 503 });
++ if (!USR_RE.test(String(subject || '')) || !TOOL_RE.test(String(tool || ''))) throw Object.assign(new Error('bad subject or tool'), { status: 400 });
++ for (let attempt = 0; attempt < 3; attempt++) {
++ const cur = await call('GET', subject);
++ if (cur.status !== 200 && cur.status !== 404) throw Object.assign(new Error(`read: ${cur.status}`), { status: 503 });
++ const data = cur.status === 200 && cur.body && cur.body.data && typeof cur.body.data === 'object' ? cur.body.data : {};
++ const had = Array.isArray(data.favorites) ? data.favorites : [];
++ const next = on ? [tool, ...had.filter((id) => id !== tool)].slice(0, FAV_MAX) : had.filter((id) => id !== tool);
++ if (JSON.stringify(next) === JSON.stringify(had)) return had;
++ const w = await call('PUT', subject, { body: { data: { ...data, favorites: next } }, revision: cur.status === 200 ? cur.body.revision : 0 });
++ if (w.status === 412) { stats.conflicts++; continue; }
++ if (w.status >= 300) throw Object.assign(new Error(`write: ${w.status} ${(w.body && w.body.code) || ''}`), { status: 503 });
++ return next;
++ }
++ throw Object.assign(new Error('your favourites kept changing; try again'), { status: 409 });
++ }
++
++ return { enabled, record, recent, favorites, setFavorite, flush, stop, stats: () => ({ enabled, pending: pending.size, ...stats }) };
}
// Anonymous history: the same list, per browser, in a first-party cookie on the tools zone, so every
ddiff --git a/apps/gateway/public/js/site.js b/apps/gateway/public/js/site.js
iindex 613fca5..9db36eb 100644
---- a/apps/gateway/public/js/site.js
++++ b/apps/gateway/public/js/site.js
@@@ -68,15 +68,36 @@
if ((k === 'k' && (e.ctrlKey || e.metaKey) && !e.altKey) || (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName))) { e.preventDefault(); input.focus(); input.select(); }
});
-- // Recent tools: this person's (every device they sign in to) or this browser's. Filled in after load.
++ // Your tools: favourites first (signed in: starred here or on my.openvibe.network), then recent tools,
++ // this person's (every device they sign in to) or this browser's. Filled in after load.
function mountIcons(el) { if (window.OpenVibeIcons) OpenVibeIcons.mount(el); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); } }
if (recentSec) fetch('/api/v1/me/recent-tools', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
-- if (!d || !d.recent || !d.recent.length) return null;
++ var favs = (d && d.favorites) || [];
++ if (!d || ((!d.recent || !d.recent.length) && !favs.length)) return null;
return load().then(function () {
var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });
-- var list = d.recent.map(function (e) { return byId[e.tool]; }).filter(Boolean).slice(0, 8);
++ var ids = favs.concat(d.recent.map(function (e) { return e.tool; }).filter(function (id) { return favs.indexOf(id) < 0; }));
++ var list = ids.map(function (id) { return byId[id]; }).filter(Boolean).slice(0, Math.max(8, favs.length));
if (!list.length) return;
-- var g = document.getElementById('recent-grid'); g.replaceChildren.apply(g, list.map(card));
++ var g = document.getElementById('recent-grid');
++ // Signed in: a star on each card (favourites live in the account's tools.usage module).
++ var starred = function (t) {
++ if (d.mode !== 'account') return card(t);
++ var w = document.createElement('div'); w.className = 'tool-wrap';
++ var b = document.createElement('button'); b.type = 'button'; b.className = 'fav';
++ var set = function (on) { b.setAttribute('aria-pressed', on ? 'true' : 'false'); b.textContent = on ? '\u2605' : '\u2606'; b.setAttribute('aria-label', (on ? 'Remove ' : 'Add ') + t.name + (on ? ' from' : ' to') + ' favourites'); };
++ set(favs.indexOf(t.id) >= 0);
++ b.addEventListener('click', function () {
++ var on = b.getAttribute('aria-pressed') !== 'true'; b.disabled = true;
++ fetch('/api/v1/me/favorites/' + encodeURIComponent(t.id), { method: on ? 'PUT' : 'DELETE', credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (res) { if (res) { favs = res.favorites; set(favs.indexOf(t.id) >= 0); } })
++ .catch(function () { /* unchanged */ })
++ .then(function () { b.disabled = false; });
++ });
++ w.appendChild(card(t)); w.appendChild(b); return w;
++ };
++ g.replaceChildren.apply(g, list.map(starred));
document.getElementById('recent-note').textContent = d.mode === 'account' ? 'On every device you sign in to' : 'On this browser';
recentReady = true; if (!input.value.trim()) recentSec.hidden = false;
mountIcons(g);
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex f6846e2..29cc741 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -212,9 +212,28 @@ app.get('/api/v1/me/recent-tools', async (req, res) => {
if (merged.length) recent = [...merged.map((tool) => ({ tool })), ...recent];
} catch { /* the cookie list stands in */ }
}
-- res.json({ mode, recent: recent.filter((e) => e && known.has(e.tool)).slice(0, 12) });
++ let favorites = [];
++ if (mode === 'account') { try { favorites = (await usage.recorder().favorites(sid)).filter((id) => known.has(id)); } catch { /* optional */ } }
++ res.json({ mode, recent: recent.filter((e) => e && known.has(e.tool)).slice(0, 12), favorites });
});
++// Favourite tools (tools.usage v2 `favorites`): PUT stars a tool, DELETE unstars it, for the signed-in
++// person. Cross-site pages cannot send these (PUT/DELETE need a CORS preflight the gateway refuses).
++for (const method of ['put', 'delete']) {
++ app[method]('/api/v1/me/favorites/:tool', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const sid = req.user && req.user.subject_id;
++ if (!sid) return res.status(401).json({ error: 'Sign in to keep favourite tools', code: 'auth.required' });
++ const tool = String(req.params.tool || '');
++ if (!(toolRegistry.snapshot().tools || []).some((t) => t.id === tool)) return res.status(404).json({ error: 'No such tool', code: 'tools.not_found' });
++ try {
++ res.json({ favorites: await require('../../_shared/usage').recorder().setFavorite(sid, tool, method === 'put') });
++ } catch (err) {
++ res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not save your favourites', code: 'tools.favorites.unavailable' });
++ }
++ });
++}
++
// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──
const netRouter = createNetRoutes(null, requireAuth, { guard });
const devRouter = createDevRoutes(null, requireAuth, { guard });
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex 004175a..e5cfd45 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -65,6 +65,7 @@ section{padding:clamp(24px,4vw,44px) 0 0}
.tool-t{min-width:0;display:block}.tool b{display:block;font-size:15px}.tool small{display:block;color:var(--text-secondary,#a8b3c4);font-size:13px;line-height:1.35;margin-top:2px}
.tool i{display:block;font-style:normal;font-size:11.5px;color:var(--text-muted,#7d8aa0);margin-top:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tool[hidden]{display:none}
++.tool-wrap{position:relative;display:grid}.tool-wrap .tool{padding-right:40px}.fav{position:absolute;top:8px;right:8px;width:32px;height:32px;border:0;border-radius:999px;background:none;color:var(--text-muted,#7d8aa0);font-size:18px;line-height:1;cursor:pointer}.fav[aria-pressed="true"]{color:var(--accent,#3b82f6)}.fav:hover,.fav:focus-visible{color:var(--accent,#3b82f6);outline:2px solid var(--accent,#3b82f6);outline-offset:-2px}
.crumbs{font-size:13.5px;color:var(--text-muted,#7d8aa0);padding-top:18px}.crumbs a{color:inherit}
.page-h{display:flex;gap:16px;align-items:center;padding:14px 0 4px}.page-h h1{font-size:clamp(26px,4vw,40px);letter-spacing:-.03em;margin:0;line-height:1.1}
.lead{font-size:clamp(15px,1.5vw,18px);color:var(--text-secondary,#a8b3c4);max-width:820px}
@@@ -162,7 +163,7 @@ ${searchForm('')}
<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>0</b> installs</li><li><b>1</b> account for all of OpenVibe</li></ul></div>
<nav class="jump" aria-label="Tool families">${fams.map(f => `<a href="#f-${f.id}">${icon(f.icon, 22)}${esc(f.name)}<i>${tools.filter(t => t.family === f.id).length}</i></a>`).join('')}</nav>
<section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>
--<section id="recent" hidden aria-labelledby="recent-h"><div class="sec-h"><h2 id="recent-h">Your recent tools</h2><span class="more" id="recent-note"></span></div><div class="grid" id="recent-grid"></div></section>
++<section id="recent" hidden aria-labelledby="recent-h"><div class="sec-h"><h2 id="recent-h">Your tools</h2><span class="more" id="recent-note"></span></div><div class="grid" id="recent-grid"></div></section>
<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">What people reach for</h2><a class="more" href="/all-tools">A to Z list</a></div><div class="grid">${popular.slice(0, 8).map(toolCard).join('')}</div></section>
${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next = planned.filter(t => t.family === f.id); return `<section class="fam" aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 40)}<div><h2 id="f-${f.id}"><a href="${esc(f.path)}">${esc(f.name)}</a></h2><p class="fam-tag">${esc(f.tagline)}</p></div><a class="more" href="${esc(f.path)}">About these ${list.length}</a></div><div class="tiles">${list.map(tile).join('')}${next.map(soon).join('')}</div></section>`; }).join('')}
${frame.shipped({ service: 'tools', title: 'Recently shipped on OpenVibe.Tools' })}