search_model.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  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 logLimit = getConfig().ssbLogStream?.limit || 1000;
  5. module.exports = ({ cooler, padsModel }) => {
  6. let ssb;
  7. const openSsb = async () => {
  8. if (!ssb) ssb = await cooler.open();
  9. return ssb;
  10. };
  11. const searchableTypes = [
  12. 'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
  13. 'votes', 'report', 'task', 'event', 'bookmark', 'document',
  14. 'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim',
  15. 'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
  16. ];
  17. const getRelevantFields = (type, content) => {
  18. switch (type) {
  19. case 'post':
  20. return [content?.text, content?.contentWarning, ...(content?.tags || [])];
  21. case 'about':
  22. return [content?.about, content?.name, content?.description];
  23. case 'feed':
  24. return [content?.text, content?.author, content?.createdAt, ...(content?.tags || []), content?.refeeds];
  25. case 'event':
  26. return [content?.title, content?.description, content?.date, content?.location, content?.price, content?.eventUrl, ...(content?.tags || []), content?.attendees, content?.organizer, content?.status, content?.isPublic];
  27. case 'votes':
  28. return [content?.question, content?.deadline, content?.status, ...(Object.values(content?.votes || {})), content?.totalVotes];
  29. case 'tribe':
  30. return [content?.title, content?.description, content?.image, content?.location, ...(content?.tags || []), content?.isLARP, content?.isAnonymous, content?.members?.length, content?.createdAt, content?.author];
  31. case 'audio':
  32. return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
  33. case 'image':
  34. return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.meme];
  35. case 'video':
  36. return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
  37. case 'document':
  38. return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.key];
  39. case 'torrent':
  40. return [content?.title, content?.description, ...(content?.tags || []), content?.url];
  41. case 'market':
  42. return [content?.item_type, content?.title, content?.description, content?.price, ...(content?.tags || []), content?.status, content?.item_status, content?.deadline, content?.includesShipping, content?.seller, content?.image, content?.auctions_poll, content?.stock];
  43. case 'bookmark':
  44. return [content?.author, content?.url, ...(content?.tags || []), content?.description, content?.category, content?.lastVisit];
  45. case 'task':
  46. return [content?.title, content?.description, content?.startTime, content?.endTime, content?.priority, content?.location, ...(content?.tags || []), content?.isPublic, content?.assignees?.length, content?.status, content?.author];
  47. case 'report':
  48. return [content?.title, content?.description, content?.category, content?.createdAt, content?.author, content?.image, ...(content?.tags || []), content?.confirmations, content?.severity, content?.status, content?.isAnonymous];
  49. case 'transfer':
  50. return [content?.from, content?.to, content?.concept, content?.amount, content?.deadline, content?.status, ...(content?.tags || []), content?.confirmedBy?.length];
  51. case 'curriculum':
  52. return [content?.author, content?.name, content?.description, content?.photo, ...(content?.personalSkills || []), ...(content?.personalExperiences || []), ...(content?.oasisExperiences || []), ...(content?.oasisSkills || []), ...(content?.educationExperiences || []), ...(content?.educationalSkills || []), ...(content?.languages || []), ...(content?.professionalExperiences || []), ...(content?.professionalSkills || []), content?.location, content?.status, content?.preferences, content?.createdAt];
  53. case 'bankWallet':
  54. return [content?.address];
  55. case 'bankClaim':
  56. return [content?.amount, content?.epochId, content?.allocationId, content?.txid];
  57. case 'project':
  58. return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor];
  59. case 'job':
  60. return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length];
  61. case 'forum':
  62. return [content?.root, content?.category, content?.title, content?.text, content?.key];
  63. case 'vote':
  64. return [content?.vote?.link];
  65. case 'contact':
  66. return [content?.contact];
  67. case 'pub':
  68. return [content?.address?.host, content?.address?.key];
  69. case 'map':
  70. return [content?.title, content?.description, content?.mapType, ...(content?.tags || []), content?.lat, content?.lng];
  71. case 'shop':
  72. return [content?.title, content?.shortDescription, content?.description, content?.location, ...(content?.tags || []), content?.visibility, content?.url];
  73. case 'shopProduct':
  74. return [content?.title, content?.description, content?.price, ...(content?.tags || []), content?.shopId];
  75. case 'chat':
  76. return [content?.title, content?.description, content?.category, ...(content?.tags || []), content?.status, content?.author];
  77. case 'pad':
  78. return [content?.title, content?.status, content?.deadline, ...(content?.tags || []), content?.author];
  79. case 'gameScore':
  80. return [content?.game, content?.player];
  81. default:
  82. return [];
  83. }
  84. };
  85. const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
  86. const getDedupeKey = (msg) => {
  87. const c = msg?.value?.content || {};
  88. const t = c?.type || 'unknown';
  89. const author = c.author || msg?.value?.author || '';
  90. if (t === 'post') return `post:${msg.key}`;
  91. if (t === 'about') return `about:${c.about || author || msg.key}`;
  92. if (t === 'curriculum') return `curriculum:${c.author || msg?.value?.author || msg.key}`;
  93. if (t === 'contact') return `contact:${c.contact || msg.key}`;
  94. if (t === 'vote') return `vote:${c?.vote?.link || msg.key}`;
  95. if (t === 'pub') return `pub:${c?.address?.key || c?.address?.host || msg.key}`;
  96. if (t === 'bankWallet') return `bankWallet:${c?.address || msg.key}`;
  97. if (t === 'bankClaim') return `bankClaim:${c?.txid || `${c?.epochId || ''}:${c?.allocationId || ''}:${c?.amount || ''}` || msg.key}`;
  98. if (t === 'document') return `document:${c.key || c.url || `${author}|${norm(c.title)}` || msg.key}`;
  99. if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
  100. if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
  101. if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
  102. if (t === 'torrent') return `torrent:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
  103. if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
  104. if (t === 'tribe') {
  105. return [
  106. 'tribe',
  107. author,
  108. norm(c.title),
  109. norm(c.location),
  110. norm(c.image)
  111. ].join('|');
  112. }
  113. if (t === 'event') {
  114. return [
  115. 'event',
  116. c.organizer || author,
  117. norm(c.title),
  118. norm(c.date),
  119. norm(c.location)
  120. ].join('|');
  121. }
  122. if (t === 'task') {
  123. return [
  124. 'task',
  125. c.author || author,
  126. norm(c.title),
  127. norm(c.startTime),
  128. norm(c.endTime),
  129. norm(c.location)
  130. ].join('|');
  131. }
  132. if (t === 'report') {
  133. return [
  134. 'report',
  135. c.author || author,
  136. norm(c.title),
  137. norm(c.category),
  138. norm(c.severity)
  139. ].join('|');
  140. }
  141. if (t === 'votes') {
  142. return [
  143. 'votes',
  144. c.createdBy || author,
  145. norm(c.question),
  146. norm(c.deadline)
  147. ].join('|');
  148. }
  149. if (t === 'market') {
  150. return [
  151. 'market',
  152. c.seller || author,
  153. norm(c.title),
  154. norm(c.deadline),
  155. norm(c.item_type),
  156. norm(c.image)
  157. ].join('|');
  158. }
  159. if (t === 'transfer') {
  160. const txid = c.txid || c.transactionId || c.id;
  161. if (txid) return `transfer:${txid}`;
  162. return [
  163. 'transfer',
  164. norm(c.from),
  165. norm(c.to),
  166. norm(c.amount),
  167. norm(c.concept),
  168. norm(c.deadline)
  169. ].join('|');
  170. }
  171. if (t === 'feed') {
  172. return [
  173. 'feed',
  174. c.author || author,
  175. norm(c.text)
  176. ].join('|');
  177. }
  178. if (t === 'project') {
  179. return [
  180. 'project',
  181. c.activityActor || author,
  182. norm(c.title),
  183. norm(c.deadline),
  184. norm(c.goal)
  185. ].join('|');
  186. }
  187. if (t === 'job') {
  188. return [
  189. 'job',
  190. author,
  191. norm(c.title),
  192. norm(c.location),
  193. norm(c.salary),
  194. norm(c.job_type)
  195. ].join('|');
  196. }
  197. if (t === 'forum') {
  198. return `forum:${c.key || c.root || `${author}|${norm(c.title)}` || msg.key}`;
  199. }
  200. if (t === 'map') {
  201. return ['map', author, norm(c.title), norm(c.description), norm(c.lat), norm(c.lng)].join('|');
  202. }
  203. if (t === 'shop') {
  204. return ['shop', author, norm(c.title), norm(c.location)].join('|');
  205. }
  206. if (t === 'shopProduct') {
  207. return ['shopProduct', author, norm(c.title), norm(c.shopId)].join('|');
  208. }
  209. if (t === 'pad') {
  210. return ['pad', author, norm(c.title), norm(c.deadline)].join('|');
  211. }
  212. return `${t}:${msg.key}`;
  213. };
  214. const dedupeKeepLatest = (msgs) => {
  215. const map = new Map();
  216. for (const msg of msgs) {
  217. const k = getDedupeKey(msg);
  218. const prev = map.get(k);
  219. const ts = msg?.value?.timestamp || 0;
  220. const pts = prev?.value?.timestamp || 0;
  221. if (!prev || ts > pts) map.set(k, msg);
  222. }
  223. return Array.from(map.values());
  224. };
  225. const search = async ({ query, types = [], resultsPerPage = "10" }) => {
  226. const ssbClient = await openSsb();
  227. const queryLower = String(query || '').toLowerCase();
  228. const messages = await new Promise((res, rej) => {
  229. pull(
  230. ssbClient.createLogStream({ limit: logLimit }),
  231. pull.collect((err, msgs) => err ? rej(err) : res(msgs))
  232. );
  233. });
  234. const tombstoned = new Set(messages.filter(m => m.value?.content?.type === 'tombstone').map(m => m.value.content.target));
  235. const replacesMap = new Map();
  236. const latestByKey = new Map();
  237. for (const msg of messages) {
  238. const k = msg.key;
  239. const c = msg?.value?.content;
  240. const t = c?.type;
  241. if (!t || !searchableTypes.includes(t)) continue;
  242. if (tombstoned.has(k)) continue;
  243. if (c.replaces) replacesMap.set(c.replaces, k);
  244. latestByKey.set(k, msg);
  245. }
  246. for (const oldId of replacesMap.keys()) {
  247. latestByKey.delete(oldId);
  248. }
  249. if (padsModel) {
  250. for (const msg of latestByKey.values()) {
  251. const c = msg?.value?.content;
  252. if (c?.type === 'pad') {
  253. const rootId = c.replaces ? msg.key : msg.key;
  254. const decrypted = padsModel.decryptContent(c, rootId);
  255. c.title = decrypted.title || c.title;
  256. c.deadline = decrypted.deadline || c.deadline;
  257. c.tags = decrypted.tags.length ? decrypted.tags : c.tags;
  258. }
  259. }
  260. }
  261. let filtered = Array.from(latestByKey.values()).filter(msg => {
  262. const c = msg?.value?.content;
  263. const t = c?.type;
  264. if (!t || (types.length > 0 && !types.includes(t))) return false;
  265. if (t === 'market') {
  266. if (c.stock === 0 && c.status !== 'SOLD') return false;
  267. }
  268. if (!queryLower) return true;
  269. if (queryLower.startsWith('@') && queryLower.length > 1) return (t === 'about' && c?.about === query);
  270. const fields = getRelevantFields(t, c);
  271. if (queryLower.startsWith('#') && queryLower.length > 1) {
  272. const tag = queryLower.substring(1);
  273. const tagArr = Array.isArray(c?.tags) ? c.tags : (typeof c?.tags === 'string' ? c.tags.split(',').map(s => s.trim()).filter(Boolean) : []);
  274. return tagArr.some(x => String(x).toLowerCase() === tag);
  275. }
  276. return fields.filter(Boolean).map(String).some(field => field.toLowerCase().includes(queryLower));
  277. });
  278. filtered = dedupeKeepLatest(filtered);
  279. filtered.sort((a, b) => (b?.value?.timestamp || 0) - (a?.value?.timestamp || 0));
  280. const grouped = filtered.reduce((acc, msg) => {
  281. const t = msg?.value?.content?.type || 'unknown';
  282. if (!acc[t]) acc[t] = [];
  283. acc[t].push(msg);
  284. return acc;
  285. }, {});
  286. if (resultsPerPage !== "all") {
  287. const limit = parseInt(resultsPerPage, 10);
  288. for (const key in grouped) grouped[key] = grouped[key].slice(0, limit);
  289. }
  290. return grouped;
  291. };
  292. return { search };
  293. };