| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- const pull = require("../server/node_modules/pull-stream");
- const { getConfig } = require("../configs/config-manager.js");
- const categories = require("../backend/opinion_categories");
- const { buildValidatedTombstoneSet } = require('./tombstone_validator');
- const mediaFavorites = require("../backend/media-favorites");
- const logLimit = getConfig().ssbLogStream?.limit || 1000;
- const safeArr = (v) => (Array.isArray(v) ? v : []);
- const safeText = (v) => String(v || "").trim();
- const parseBlobId = (blobMarkdown) => {
- if (!blobMarkdown) return null;
- const s = String(blobMarkdown);
- const match = s.match(/\(([^)]+)\)/);
- return match ? match[1] : s.trim();
- };
- const parseCSV = (str) =>
- str === undefined || str === null ? undefined : String(str).split(",").map((s) => s.trim()).filter(Boolean);
- const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
- module.exports = ({ cooler }) => {
- let ssb;
- let userId;
- const openSsb = async () => {
- if (!ssb) {
- ssb = await cooler.open();
- userId = ssb.id;
- }
- 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 buildIndex = (messages) => {
- const tomb = buildValidatedTombstoneSet(messages);
- const nodes = new Map();
- const parent = new Map();
- const child = new Map();
- 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 !== "document") 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 });
- if (c.replaces) {
- parent.set(k, c.replaces);
- child.set(c.replaces, k);
- }
- }
- const rootOf = (id) => {
- let cur = id;
- while (parent.has(cur)) cur = parent.get(cur);
- return cur;
- };
- const tipOf = (id) => {
- let cur = id;
- while (child.has(cur)) cur = child.get(cur);
- return cur;
- };
- const roots = new Set();
- for (const id of nodes.keys()) roots.add(rootOf(id));
- const tipByRoot = new Map();
- for (const r of roots) tipByRoot.set(r, tipOf(r));
- const forward = new Map();
- for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
- return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
- };
- const pickDoc = (node, rootId) => {
- const c = node.c || {};
- return {
- key: node.key,
- rootId,
- url: c.url,
- createdAt: c.createdAt,
- updatedAt: c.updatedAt || null,
- tags: safeArr(c.tags),
- author: c.author,
- title: c.title || "",
- description: c.description || "",
- opinions: c.opinions || {},
- opinions_inhabitants: safeArr(c.opinions_inhabitants),
- sizeBytes: node.sizeBytes || 0
- };
- };
- const hasBlob = (ssbClient, blobId) =>
- new Promise((resolve) => {
- if (!blobId) return resolve(false);
- ssbClient.blobs.has(blobId, (err, has) => resolve(!err && !!has));
- });
- const favoritesSetForDocuments = async () => {
- try {
- return await mediaFavorites.getFavoriteSet("documents");
- } catch {
- return new Set();
- }
- };
- return {
- type: "document",
- async resolveCurrentId(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("Document 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("Document not found");
- let root = tip;
- while (idx.parent.has(root)) root = idx.parent.get(root);
- return root;
- },
- async createDocument(blobMarkdown, tagsRaw, title, description) {
- const ssbClient = await openSsb();
- const blobId = parseBlobId(blobMarkdown);
- if (!blobId) throw new Error("Missing document blob");
- const tags = parseCSV(tagsRaw) || [];
- const content = {
- type: "document",
- url: blobId,
- createdAt: new Date().toISOString(),
- author: userId,
- tags,
- title: title || "",
- description: description || "",
- opinions: {},
- opinions_inhabitants: []
- };
- return new Promise((resolve, reject) => {
- ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
- });
- },
- async updateDocumentById(id, blobMarkdown, tagsRaw, title, description) {
- const ssbClient = await openSsb();
- const tipId = await this.resolveCurrentId(id);
- const oldMsg = await new Promise((res, rej) =>
- ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error("Document not found")) : res(msg)))
- );
- if (oldMsg.content?.type !== "document") throw new Error("Document not found");
- if (Object.keys(oldMsg.content.opinions || {}).length > 0) {
- throw new Error("Cannot edit document after it has received opinions.");
- }
- if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
- const parsedTags = parseCSV(tagsRaw);
- const tags = parsedTags !== undefined ? parsedTags : safeArr(oldMsg.content.tags);
- const blobId = parseBlobId(blobMarkdown);
- const updatedAt = 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 || "",
- updatedAt
- };
- const tombstone = { type: "tombstone", target: tipId, deletedAt: updatedAt, author: userId };
- await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
- return new Promise((resolve, reject) => {
- ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
- });
- },
- async deleteDocumentById(id) {
- const ssbClient = await openSsb();
- const tipId = await this.resolveCurrentId(id);
- const msg = await new Promise((res, rej) =>
- ssbClient.get(tipId, (err, m) => (err || !m ? rej(new Error("Document not found")) : res(m)))
- );
- if (msg.content?.type !== "document") throw new Error("Document not found");
- if (String(msg.content.author) !== String(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, (err2, res) => (err2 ? reject(err2) : resolve(res)));
- });
- },
- async listAll(arg1 = "all") {
- const ssbClient = await openSsb();
- const opts = typeof arg1 === "object" && arg1 !== null ? arg1 : { filter: arg1 };
- const filter = safeText(opts.filter || "all");
- const q = safeText(opts.q || "").toLowerCase();
- const sort = safeText(opts.sort || "recent");
- const favorites = await favoritesSetForDocuments();
- 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(pickDoc(node, rootId));
- }
- let out = items;
- const now = Date.now();
- if (filter === "mine") out = out.filter((d) => String(d.author) === String(userId));
- else if (filter === "recent") out = out.filter((d) => new Date(d.createdAt).getTime() >= now - 86400000);
- else if (filter === "favorites") out = out.filter((d) => favorites.has(d.rootId || d.key));
- if (q) {
- out = out.filter((d) => {
- const t = String(d.title || "").toLowerCase();
- const desc = String(d.description || "").toLowerCase();
- const u = String(d.url || "").toLowerCase();
- const a = String(d.author || "").toLowerCase();
- const tags = safeArr(d.tags).join(" ").toLowerCase();
- return t.includes(q) || desc.includes(q) || u.includes(q) || a.includes(q) || tags.includes(q);
- });
- }
- const effectiveSort = filter === "top" ? "top" : sort;
- if (effectiveSort === "top") {
- out = out
- .slice()
- .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
- } else if (effectiveSort === "oldest") {
- out = out.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
- } else {
- out = out.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
- }
- const checked = await Promise.all(out.map(async (d) => ((await hasBlob(ssbClient, d.url)) ? d : null)));
- return checked
- .filter(Boolean)
- .map((d) => ({ ...d, isFavorite: favorites.has(d.rootId || d.key) }));
- },
- async getDocumentById(id) {
- const ssbClient = await openSsb();
- const tipId = await this.resolveCurrentId(id);
- const rootId = await this.resolveRootId(id);
- const favorites = await favoritesSetForDocuments();
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, msg) => {
- if (err || !msg || msg.content?.type !== "document") return reject(new Error("Document not found"));
- const c = msg.content;
- resolve({
- key: tipId,
- rootId,
- url: c.url,
- createdAt: c.createdAt,
- updatedAt: c.updatedAt || null,
- tags: c.tags || [],
- author: c.author,
- title: c.title || "",
- description: c.description || "",
- opinions: c.opinions || {},
- opinions_inhabitants: c.opinions_inhabitants || [],
- isFavorite: favorites.has(rootId || tipId)
- });
- });
- });
- },
- async createOpinion(id, category) {
- if (!categories.includes(category)) return Promise.reject(new Error("Invalid voting category"));
- const ssbClient = await openSsb();
- const tipId = await this.resolveCurrentId(id);
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, async (err, msg) => {
- if (err || !msg || msg.content?.type !== "document") return reject(new Error("Document not found"));
- if (safeArr(msg.content.opinions_inhabitants).includes(userId)) return reject(new Error("Already voted"));
- const now = new Date().toISOString();
- const updated = {
- ...msg.content,
- replaces: tipId,
- opinions: { ...msg.content.opinions, [category]: (msg.content.opinions?.[category] || 0) + 1 },
- opinions_inhabitants: safeArr(msg.content.opinions_inhabitants).concat(userId),
- 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())));
- ssbClient.publish(updated, (err2, result) => (err2 ? reject(err2) : resolve(result)));
- });
- });
- }
- };
- };
|