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)
Changes
@@@ -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) } });@@@ -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@@@ -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);@@@ -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 });@@@ -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' })}
@@ -50,6 +50,23 @@ const fetchImpl = async (url, opts) => {
@@ -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.length, 30, 'capped at the namespace limit');
assert.strictEqual(modules.get(USR).data.recent[0].tool, 'tool39');
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.
// The page middleware: a signed-in document request for a tool host counts; assets, APIs and unknown hosts do not.
const got = [];
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) } });
const mw = usagePages({ snapshot: () => ({ tools: [{ id: 'jsonminify', hosts: ['json-minifier.openvibe.tools', 'jsonminify.openvibe.tools'] }] }), rec: { enabled: true, record: (s, id) => got.push(id) } });
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// tools.usage (platform S9): a signed-in person's recently used tools, kept in their Network user
// 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
// 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
// app.use(usagePages({ snapshot: toolRegistry.snapshot })) after guard.identify: a page view of
// a tool by a signed-in person counts
// a tool by a signed-in person counts
@@ -22,6 +24,7 @@
@@ -22,6 +24,7 @@
const NS = 'tools.usage';
const NS = 'tools.usage';
const MAX = 30;
const MAX = 30;
+const FAV_MAX = 24;
const SAME_TOOL_MS = 10 * 60 * 1000;
const SAME_TOOL_MS = 10 * 60 * 1000;
const FLUSH_MS = 60 * 1000;
const FLUSH_MS = 60 * 1000;
const MAX_PENDING = 2000;
const MAX_PENDING = 2000;
@@ -76,7 +79,9 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
@@ -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 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 add = [...fresh].reverse().map(([tool, at]) => ({ tool, at }));
const recent = [...add, ...had.filter((e) => e && !fresh.has(e.tool))].slice(0, MAX);
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 === 412 || w.status === 409) { stats.conflicts++; return false; }
if (w.status >= 300) throw new Error(`write: ${w.status} ${(w.body && w.body.code) || ''}`);
if (w.status >= 300) throw new Error(`write: ${w.status} ${(w.body && w.body.code) || ''}`);
stats.written++;
stats.written++;
@@ -110,7 +115,34 @@ function createRecorder({ env = process.env, fetchImpl = globalThis.fetch, now =
@@ -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 [...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
// Anonymous history: the same list, per browser, in a first-party cookie on the tools zone, so every
@@ -68,15 +68,36 @@
@@ -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(); }
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); } }
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 (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 () {
return load().then(function () {
var byId = {}; catalog.forEach(function (c) { byId[c.t.id] = c.t; });
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;
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';
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;
recentReady = true; if (!input.value.trim()) recentSec.hidden = false;
mountIcons(g);
mountIcons(g);
@@ -212,9 +212,28 @@ app.get('/api/v1/me/recent-tools', async (req, res) => {
@@ -212,9 +212,28 @@ app.get('/api/v1/me/recent-tools', async (req, res) => {
if (merged.length) recent = [...merged.map((tool) => ({ tool })), ...recent];
if (merged.length) recent = [...merged.map((tool) => ({ tool })), ...recent];
} catch { /* the cookie list stands in */ }
} 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) ──
// ── Net.OpenVibe and Dev.OpenVibe routes (the pages' API; the run API calls the same handlers) ──
const netRouter = createNetRoutes(null, requireAuth, { guard });
const netRouter = createNetRoutes(null, requireAuth, { guard });
const devRouter = createDevRoutes(null, requireAuth, { guard });
const devRouter = createDevRoutes(null, requireAuth, { guard });
@@ -65,6 +65,7 @@ section{padding:clamp(24px,4vw,44px) 0 0}
@@ -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-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 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[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}
.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}
.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}
.lead{font-size:clamp(15px,1.5vw,18px);color:var(--text-secondary,#a8b3c4);max-width:820px}
@@ -162,7 +163,7 @@ ${searchForm('')}
@@ -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>
<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>
<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="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>
<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('')}
${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' })}
${frame.shipped({ service: 'tools', title: 'Recently shipped on OpenVibe.Tools' })}