Oasis 0.8.7 mobile (nodejs-project baseline)

This commit is contained in:
s1to 2026-07-09 09:56:30 +02:00
commit e563b86ede
226 changed files with 141968 additions and 0 deletions

387
src/models/audios_model.js Normal file
View file

@ -0,0 +1,387 @@
const pull = require("../server/node_modules/pull-stream");
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { dedupeBy, norm } = require('../backend/dedupe');
const { getConfig } = require("../configs/config-manager.js");
const categories = require("../backend/opinion_categories");
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeArr = (v) => (Array.isArray(v) ? v : []);
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return undefined;
if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
};
const parseBlobId = (blobMarkdown) => {
const s = String(blobMarkdown || "");
const match = s.match(/\(([^)]+)\)/);
return match ? match[1] : s || null;
};
const voteSum = (opinions = {}) =>
Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
});
const getMsg = async (ssbClient, key) =>
new Promise((resolve, reject) => {
ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
});
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const nodes = new Map();
const parent = new Map();
const child = new Map();
const strictChild = new Map();
const opinionMsgs = [];
for (const m of messages) {
const k = m.key;
const v = m.value || {};
const c = v.content;
if (!c) continue;
if (c.type === "tombstone") continue;
if (c.type === "audioOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; }
if (c.type !== "audio") continue;
const ts = v.timestamp || m.timestamp || 0;
let sizeBytes = 0;
try { sizeBytes = Buffer.byteLength(JSON.stringify(v), 'utf8'); } catch (_) { sizeBytes = 0; }
nodes.set(k, { key: k, ts, c, sizeBytes, author: v.author });
if (c.replaces) {
parent.set(k, c.replaces);
child.set(c.replaces, k);
}
}
for (const [k, node] of nodes) {
const t = node.c.replaces;
if (t) { const orig = nodes.get(t); if (orig && orig.author === node.author) strictChild.set(t, k); }
}
const rootOf = (id) => {
let cur = id, g = 0;
while (parent.has(cur) && nodes.has(parent.get(cur)) && g++ < 100000) cur = parent.get(cur);
return cur;
};
const contentTipOf = (root) => {
let cur = root, g = 0;
while (strictChild.has(cur) && g++ < 100000) cur = strictChild.get(cur);
const n = nodes.get(cur), rn = nodes.get(root);
return (n && rn && n.author === rn.author) ? cur : root;
};
const roots = new Set();
for (const id of nodes.keys()) roots.add(rootOf(id));
const opinionsByRoot = new Map();
for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); }
const forward = new Map();
for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
const resolveGroup = (root) => {
const contentTip = contentTipOf(root);
const contentNode = nodes.get(contentTip) || nodes.get(root);
const lc = contentNode ? contentNode.c : {};
const opinions = { ...(lc.opinions || {}) };
const voters = safeArr(lc.opinions_inhabitants).slice();
const voterSet = new Set(voters);
for (const op of (opinionsByRoot.get(root) || [])) {
if (voterSet.has(op.author)) continue;
voterSet.add(op.author); voters.push(op.author);
opinions[op.category] = (opinions[op.category] || 0) + 1;
}
return { contentTip, contentNode, opinions, voters };
};
const tipByRoot = new Map();
for (const r of roots) tipByRoot.set(r, contentTipOf(r));
return { tomb, nodes, parent, child, rootOf, tipByRoot, forward, resolveGroup };
};
const buildAudio = (node, rootId, viewerId, agg) => {
const c = node.c || {};
const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants);
const opinions = agg ? agg.opinions : (c.opinions || {});
const composition = Array.isArray(c.bcsComposition) ? c.bcsComposition : null;
const tagsArr = safeArr(c.tags);
const isBcs = (composition && composition.length > 0) || tagsArr.some(t => String(t).toLowerCase() === 'bcs');
return {
key: node.key,
rootId,
url: c.url,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
tags: tagsArr,
author: c.author,
title: c.title || "",
description: c.description || "",
mapUrl: c.mapUrl || "",
opinions,
opinions_inhabitants: voters,
hasVoted: viewerId ? voters.includes(viewerId) : false,
bcsComposition: composition,
isBcs,
sizeBytes: node.sizeBytes || 0
};
};
return {
type: "audio",
async resolveCurrentId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const tip = idx.tipByRoot.get(idx.rootOf(id)) || id;
if (idx.tomb.has(tip)) throw new Error("Audio not found");
return tip;
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
let tip = id;
while (idx.forward.has(tip)) tip = idx.forward.get(tip);
if (idx.tomb.has(tip)) throw new Error("Audio not found");
let root = tip;
while (idx.parent.has(root)) root = idx.parent.get(root);
return root;
},
async createAudio(blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb();
const blobId = parseBlobId(blobMarkdown);
const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString();
const content = {
type: "audio",
url: blobId,
createdAt: now,
updatedAt: null,
author: ssbClient.id,
tags,
title: title || "",
description: description || "",
mapUrl: mapUrl || "",
opinions: {},
opinions_inhabitants: []
};
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async createBcsAudio(blobId, title, description, composition) {
const ssbClient = await openSsb();
const now = new Date().toISOString();
const cleanComposition = safeArr(composition)
.map(n => ({
t: String(n.type || n.t || ''),
n: String(n.name || n.n || ''),
d: Number(n.durMs || n.d || 0),
id: typeof n.id === 'string' ? n.id : (typeof n.key === 'string' ? n.key : null)
}))
.filter(n => n.t && n.n);
const baseContent = {
type: "audio",
url: blobId,
createdAt: now,
updatedAt: null,
author: ssbClient.id,
tags: ["bcs"],
title: title || "",
description: description || "",
mapUrl: "",
bcsComposition: [],
opinions: {},
opinions_inhabitants: []
};
const SSB_MAX_BYTES = 8000;
const wrapEnvelope = (content) => ({
previous: "%0000000000000000000000000000000000000000000=.sha256",
sequence: 999999,
author: ssbClient.id,
timestamp: Date.now(),
hash: "sha256",
content,
signature: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000==.sig.ed25519"
});
const fitted = [];
for (const item of cleanComposition) {
fitted.push(item);
const probe = wrapEnvelope({ ...baseContent, bcsComposition: fitted });
if (Buffer.byteLength(JSON.stringify(probe, null, 2), 'utf8') > SSB_MAX_BYTES) {
fitted.pop();
break;
}
}
const content = { ...baseContent, bcsComposition: fitted };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async updateAudioById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "audio") throw new Error("Audio not found");
if (oldMsg.content.author !== userId) throw new Error("Not the author");
const aggAudio = await this.getAudioById(id, userId);
if (aggAudio && Object.keys(aggAudio.opinions || {}).some(k => (aggAudio.opinions[k] || 0) > 0)) throw new Error("Cannot edit audio after it has received opinions.");
const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
const now = new Date().toISOString();
const updated = {
...oldMsg.content,
replaces: tipId,
url: blobId || oldMsg.content.url,
tags,
title: title !== undefined ? title || "" : oldMsg.content.title || "",
description: description !== undefined ? description || "" : oldMsg.content.description || "",
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
createdAt: oldMsg.content.createdAt,
updatedAt: now
};
const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
return new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
});
},
async deleteAudioById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await this.resolveCurrentId(id);
const msg = await getMsg(ssbClient, tipId);
if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found");
if (msg.content.author !== userId) throw new Error("Not the author");
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
return new Promise((resolve, reject) => {
ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
});
},
async listAll(filterOrOpts = "all", maybeOpts = {}) {
const ssbClient = await openSsb();
const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
const q = String(opts.q || "").trim().toLowerCase();
const sort = String(opts.sort || "recent").trim();
const viewerId = opts.viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const items = [];
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue;
const node = idx.nodes.get(tipId);
if (!node) continue;
items.push(buildAudio(node, rootId, viewerId, idx.resolveGroup(rootId)));
}
let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null);
const now = Date.now();
if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId));
else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
else if (filter === "top") {
list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (filter === "bcs") {
list = list.filter((a) => a.isBcs === true);
}
if (q) {
list = list.filter((a) => {
const title = String(a.title || "").toLowerCase();
const desc = String(a.description || "").toLowerCase();
const tags = safeArr(a.tags).join(" ").toLowerCase();
const author = String(a.author || "").toLowerCase();
return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
});
}
if (sort === "top") {
list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
} else if (sort === "oldest") {
list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
} else {
list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return list;
},
async getAudioById(id, viewerId = null) {
const ssbClient = await openSsb();
const viewer = viewerId || ssbClient.id;
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const rootId = idx.rootOf(id);
const agg = idx.resolveGroup(rootId);
if (!agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Audio not found");
return buildAudio(agg.contentNode, rootId, viewer, agg);
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid voting category");
const ssbClient = await openSsb();
const userId = ssbClient.id;
const audio = await this.getAudioById(id, userId);
if (!audio) throw new Error("Audio not found");
if (safeArr(audio.opinions_inhabitants).includes(userId)) throw new Error("Already voted");
const content = { type: "audioOpinion", target: audio.rootId, category, createdAt: new Date().toISOString() };
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result)));
});
}
};
};