bookmarking_model.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. const pull = require("../server/node_modules/pull-stream");
  2. const moment = require("../server/node_modules/moment");
  3. const { getConfig } = require("../configs/config-manager.js");
  4. const categories = require("../backend/opinion_categories");
  5. const { buildValidatedTombstoneSet } = require('./tombstone_validator');
  6. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  7. const safeArr = (v) => (Array.isArray(v) ? v : []);
  8. const safeText = (v) => String(v || "").trim();
  9. const normalizeTags = (raw) => {
  10. if (raw === undefined || raw === null) return [];
  11. if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
  12. return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
  13. };
  14. const coerceLastVisit = (lastVisit) => {
  15. const now = moment();
  16. if (!lastVisit) return now.toISOString();
  17. const m = moment(lastVisit, moment.ISO_8601, true);
  18. if (!m.isValid()) return now.toISOString();
  19. if (m.isAfter(now)) return now.toISOString();
  20. return m.toISOString();
  21. };
  22. const voteSum = (opinions = {}) =>
  23. Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
  24. module.exports = ({ cooler }) => {
  25. let ssb;
  26. const openSsb = async () => {
  27. if (!ssb) ssb = await cooler.open();
  28. return ssb;
  29. };
  30. const getAllMessages = async (ssbClient) =>
  31. new Promise((resolve, reject) => {
  32. pull(
  33. ssbClient.createLogStream({ limit: logLimit }),
  34. pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
  35. );
  36. });
  37. const getMsg = async (ssbClient, key) =>
  38. new Promise((resolve, reject) => {
  39. ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
  40. });
  41. const buildIndex = (messages) => {
  42. const tomb = buildValidatedTombstoneSet(messages);
  43. const nodes = new Map();
  44. const parent = new Map();
  45. const child = new Map();
  46. for (const m of messages) {
  47. const k = m.key;
  48. const v = m.value || {};
  49. const c = v.content;
  50. if (!c) continue;
  51. if (c.type === 'tombstone') continue;
  52. if (c.type !== "bookmark") continue;
  53. const ts = v.timestamp || m.timestamp || 0;
  54. let sizeBytes = 0;
  55. try { sizeBytes = Buffer.byteLength(JSON.stringify(v), "utf8"); } catch (_) { sizeBytes = 0; }
  56. nodes.set(k, { key: k, ts, c, sizeBytes });
  57. if (c.replaces) {
  58. parent.set(k, c.replaces);
  59. child.set(c.replaces, k);
  60. }
  61. }
  62. const rootOf = (id) => {
  63. let cur = id;
  64. while (parent.has(cur)) cur = parent.get(cur);
  65. return cur;
  66. };
  67. const tipOf = (id) => {
  68. let cur = id;
  69. while (child.has(cur)) cur = child.get(cur);
  70. return cur;
  71. };
  72. const roots = new Set();
  73. for (const id of nodes.keys()) roots.add(rootOf(id));
  74. const tipByRoot = new Map();
  75. for (const r of roots) tipByRoot.set(r, tipOf(r));
  76. const forward = new Map();
  77. for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
  78. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
  79. };
  80. const buildBookmark = (node, rootId, viewerId) => {
  81. const c = node.c || {};
  82. const voters = safeArr(c.opinions_inhabitants);
  83. return {
  84. id: node.key,
  85. rootId,
  86. url: c.url || "",
  87. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  88. updatedAt: c.updatedAt || null,
  89. lastVisit: c.lastVisit || null,
  90. tags: safeArr(c.tags),
  91. category: c.category || "",
  92. description: c.description || "",
  93. opinions: c.opinions || {},
  94. opinions_inhabitants: voters,
  95. author: c.author,
  96. hasVoted: viewerId ? voters.includes(viewerId) : false,
  97. sizeBytes: node.sizeBytes || 0
  98. };
  99. };
  100. return {
  101. type: "bookmark",
  102. async resolveCurrentId(id) {
  103. const ssbClient = await openSsb();
  104. const messages = await getAllMessages(ssbClient);
  105. const idx = buildIndex(messages);
  106. let tip = id;
  107. while (idx.forward.has(tip)) tip = idx.forward.get(tip);
  108. if (idx.tomb.has(tip)) throw new Error("Bookmark not found");
  109. return tip;
  110. },
  111. async resolveRootId(id) {
  112. const ssbClient = await openSsb();
  113. const messages = await getAllMessages(ssbClient);
  114. const idx = buildIndex(messages);
  115. let tip = id;
  116. while (idx.forward.has(tip)) tip = idx.forward.get(tip);
  117. if (idx.tomb.has(tip)) throw new Error("Bookmark not found");
  118. let root = tip;
  119. while (idx.parent.has(root)) root = idx.parent.get(root);
  120. return root;
  121. },
  122. async createBookmark(url, tagsRaw, description, category, lastVisit) {
  123. const ssbClient = await openSsb();
  124. const now = new Date().toISOString();
  125. const u = safeText(url);
  126. if (!u) throw new Error("URL is required");
  127. const content = {
  128. type: "bookmark",
  129. author: ssbClient.id,
  130. url: u,
  131. tags: normalizeTags(tagsRaw),
  132. description: description || "",
  133. category: category || "",
  134. createdAt: now,
  135. updatedAt: now,
  136. lastVisit: coerceLastVisit(lastVisit),
  137. opinions: {},
  138. opinions_inhabitants: []
  139. };
  140. return new Promise((resolve, reject) => {
  141. ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
  142. });
  143. },
  144. async updateBookmarkById(id, updatedData) {
  145. const ssbClient = await openSsb();
  146. const userId = ssbClient.id;
  147. const tipId = await this.resolveCurrentId(id);
  148. const oldMsg = await getMsg(ssbClient, tipId);
  149. if (!oldMsg || oldMsg.content?.type !== "bookmark") throw new Error("Bookmark not found");
  150. if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit bookmark after it has received opinions.");
  151. if (String(oldMsg.content.author) !== String(userId)) throw new Error("Not the author");
  152. const url = safeText(updatedData.url || oldMsg.content.url);
  153. if (!url) throw new Error("URL is required");
  154. const now = new Date().toISOString();
  155. const updated = {
  156. ...oldMsg.content,
  157. replaces: tipId,
  158. url,
  159. tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
  160. description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
  161. category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "",
  162. lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
  163. createdAt: oldMsg.content.createdAt,
  164. updatedAt: now
  165. };
  166. const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
  167. await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
  168. return new Promise((resolve, reject) => {
  169. ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
  170. });
  171. },
  172. async deleteBookmarkById(id) {
  173. const ssbClient = await openSsb();
  174. const userId = ssbClient.id;
  175. const tipId = await this.resolveCurrentId(id);
  176. const msg = await getMsg(ssbClient, tipId);
  177. if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
  178. if (String(msg.content.author) !== String(userId)) throw new Error("Not the author");
  179. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
  180. return new Promise((resolve, reject) => {
  181. ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
  182. });
  183. },
  184. async listAll(filterOrOpts = "all", maybeOpts = {}) {
  185. const ssbClient = await openSsb();
  186. const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
  187. const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
  188. const q = safeText(opts.q || "").toLowerCase();
  189. const sort = safeText(opts.sort || "recent");
  190. const viewerId = opts.viewerId || ssbClient.id;
  191. const messages = await getAllMessages(ssbClient);
  192. const idx = buildIndex(messages);
  193. const items = [];
  194. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  195. if (idx.tomb.has(tipId)) continue;
  196. const node = idx.nodes.get(tipId);
  197. if (!node) continue;
  198. items.push(buildBookmark(node, rootId, viewerId));
  199. }
  200. let list = items;
  201. const now = Date.now();
  202. if (filter === "mine") list = list.filter((b) => String(b.author) === String(viewerId));
  203. else if (filter === "recent") list = list.filter((b) => new Date(b.createdAt).getTime() >= now - 86400000);
  204. else if (filter === "top") {
  205. list = list
  206. .slice()
  207. .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
  208. }
  209. if (q) {
  210. list = list.filter((b) => {
  211. const url = String(b.url || "").toLowerCase();
  212. const cat = String(b.category || "").toLowerCase();
  213. const desc = String(b.description || "").toLowerCase();
  214. const tags = safeArr(b.tags).join(" ").toLowerCase();
  215. const author = String(b.author || "").toLowerCase();
  216. return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
  217. });
  218. }
  219. if (sort === "top") {
  220. list = list
  221. .slice()
  222. .sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
  223. } else if (sort === "oldest") {
  224. list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
  225. } else {
  226. list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  227. }
  228. return list;
  229. },
  230. async getBookmarkById(id, viewerId = null) {
  231. const ssbClient = await openSsb();
  232. const viewer = viewerId || ssbClient.id;
  233. const tipId = await this.resolveCurrentId(id);
  234. const rootId = await this.resolveRootId(id);
  235. const msg = await getMsg(ssbClient, tipId);
  236. if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
  237. return buildBookmark({ key: tipId, ts: msg.timestamp || 0, c: msg.content }, rootId, viewer);
  238. },
  239. async createOpinion(id, category) {
  240. if (!categories.includes(category)) throw new Error("Invalid voting category");
  241. const ssbClient = await openSsb();
  242. const userId = ssbClient.id;
  243. const tipId = await this.resolveCurrentId(id);
  244. const msg = await getMsg(ssbClient, tipId);
  245. if (!msg || msg.content?.type !== "bookmark") throw new Error("Bookmark not found");
  246. const voters = safeArr(msg.content.opinions_inhabitants);
  247. if (voters.includes(userId)) throw new Error("Already voted");
  248. const now = new Date().toISOString();
  249. const updated = {
  250. ...msg.content,
  251. replaces: tipId,
  252. opinions: {
  253. ...msg.content.opinions,
  254. [category]: (msg.content.opinions?.[category] || 0) + 1
  255. },
  256. opinions_inhabitants: voters.concat(userId),
  257. updatedAt: now
  258. };
  259. const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
  260. await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
  261. return new Promise((resolve, reject) => {
  262. ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
  263. });
  264. }
  265. };
  266. };