Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.
Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).
Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
384 lines
18 KiB
JavaScript
384 lines
18 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const pull = require('../server/node_modules/pull-stream');
|
|
const moment = require('../server/node_modules/moment');
|
|
|
|
const agendaConfigPath = require('../configs/state-manager').statePath('agenda-config.json');
|
|
const { getConfig } = require('../configs/config-manager.js');
|
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
const { readTyped } = require('./typed_log');
|
|
const { versionGroups, versionEntry } = require('../backend/dedupe');
|
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
|
|
function readAgendaConfig() {
|
|
if (!fs.existsSync(agendaConfigPath)) {
|
|
fs.writeFileSync(agendaConfigPath, JSON.stringify({ discardedItems: [] }));
|
|
}
|
|
return JSON.parse(fs.readFileSync(agendaConfigPath));
|
|
}
|
|
|
|
function writeAgendaConfig(cfg) {
|
|
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
|
|
}
|
|
|
|
module.exports = ({ cooler, campaignsModel = null, logisticsModel = null, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel, schoolModel }) => {
|
|
let ssb;
|
|
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
|
|
const STATUS_ORDER = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
|
|
const sIdx = s => STATUS_ORDER.indexOf(String(s || '').toUpperCase());
|
|
|
|
const fetchItems = async (targetType) => {
|
|
const ssbClient = await openSsb();
|
|
const msgs = await readTyped(ssbClient, [targetType, 'tombstone'], { limit: logLimit });
|
|
|
|
const tomb = buildValidatedTombstoneSet(msgs);
|
|
const nodes = new Map();
|
|
const versions = [];
|
|
|
|
for (const m of msgs) {
|
|
const k = m.key;
|
|
const v = m.value;
|
|
const c = v?.content;
|
|
if (!c) continue;
|
|
if (c.type === 'tombstone') continue;
|
|
if (c.type !== targetType) continue;
|
|
if (c.encryptedPayload) continue;
|
|
nodes.set(k, { key: k, ts: v.timestamp || 0, author: v.author, content: c });
|
|
versions.push(versionEntry(m));
|
|
}
|
|
|
|
const vg = versionGroups(versions);
|
|
const groups = new Map();
|
|
for (const r of vg.roots) groups.set(r, new Set(vg.groups.get(r).filter(id => !vg.isForeign(id))));
|
|
|
|
const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
|
|
const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
|
|
|
|
const out = [];
|
|
|
|
for (const [root, ids] of groups.entries()) {
|
|
const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key));
|
|
if (!items.length) continue;
|
|
|
|
const tip = nodes.get(vg.tipOf(root));
|
|
if (tomb.has(tip.key)) continue;
|
|
|
|
if (targetType === 'market') {
|
|
let chosen = items[0];
|
|
for (const n of items) {
|
|
const a = sIdx(n.content.status);
|
|
const b = sIdx(chosen.content.status);
|
|
if (a > b || (a === b && n.ts > chosen.ts)) chosen = n;
|
|
}
|
|
const c = chosen.content;
|
|
let status = c.status;
|
|
if (c.deadline) {
|
|
const dl = moment(c.deadline);
|
|
if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED';
|
|
}
|
|
if (status === 'FOR SALE' && (c.stock || 0) === 0) continue;
|
|
|
|
out.push({
|
|
...c,
|
|
status,
|
|
author: chosen.author,
|
|
id: chosen.key,
|
|
tipId: chosen.key,
|
|
createdAt: c.createdAt || chosen.ts
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (targetType === 'job') {
|
|
const latest = items.sort((a, b) => b.ts - a.ts)[0];
|
|
const withSubsNode = items
|
|
.filter(n => Array.isArray(n.content.subscribers))
|
|
.sort((a, b) => b.ts - a.ts)[0];
|
|
const subscribers = withSubsNode ? withSubsNode.content.subscribers : [];
|
|
const latestWithStatus = items
|
|
.filter(n => typeof n.content.status !== 'undefined')
|
|
.sort((a, b) => b.ts - a.ts)[0];
|
|
const resolvedStatus = latestWithStatus
|
|
? latestWithStatus.content.status
|
|
: latest.content.status;
|
|
|
|
const c = { ...latest.content, status: resolvedStatus, subscribers };
|
|
|
|
out.push({
|
|
...c,
|
|
author: latest.author,
|
|
id: latest.key,
|
|
tipId: latest.key,
|
|
createdAt: c.createdAt || latest.ts
|
|
});
|
|
continue;
|
|
}
|
|
|
|
out.push({
|
|
...tip.content,
|
|
author: tip.author,
|
|
id: tip.key,
|
|
tipId: tip.key,
|
|
createdAt: tip.content.createdAt || tip.ts
|
|
});
|
|
}
|
|
|
|
return out;
|
|
};
|
|
|
|
return {
|
|
async listAgenda(filter = 'all') {
|
|
const agendaConfig = readAgendaConfig();
|
|
const discardedItems = agendaConfig.discardedItems || [];
|
|
const removedItems = agendaConfig.removedItems || [];
|
|
const ssbClient = await openSsb();
|
|
const userId = ssbClient.id;
|
|
|
|
const normalize = (arr) => arr.map(it => ({
|
|
...it,
|
|
key: it.id || it.key,
|
|
createdAt: it.createdAt || new Date().toISOString(),
|
|
tipId: it.tipId || it.id || it.key
|
|
}));
|
|
|
|
const eventsViaModel = eventsModel && typeof eventsModel.listAll === 'function'
|
|
? eventsModel.listAll(null, 'all').then(normalize).catch(() => [])
|
|
: fetchItems('event');
|
|
|
|
const calendarsViaModel = calendarsModel && typeof calendarsModel.listAll === 'function'
|
|
? calendarsModel.listAll({ filter: 'all', viewerId: userId }).then(normalize).catch(() => [])
|
|
: fetchItems('calendar');
|
|
|
|
const tasksViaModel = tasksModel && typeof tasksModel.listAll === 'function'
|
|
? tasksModel.listAll().then(normalize).catch(() => [])
|
|
: fetchItems('task');
|
|
|
|
const marketViaModel = marketModel && typeof marketModel.listAllItems === 'function'
|
|
? marketModel.listAllItems('all').then(normalize).catch(() => [])
|
|
: fetchItems('market');
|
|
|
|
const jobsViaModel = jobsModel && typeof jobsModel.listJobs === 'function'
|
|
? jobsModel.listJobs('ALL', userId).then(normalize).catch(() => [])
|
|
: fetchItems('job');
|
|
|
|
const projectsViaModel = projectsModel && typeof projectsModel.listProjects === 'function'
|
|
? projectsModel.listProjects('all').then(normalize).catch(() => [])
|
|
: fetchItems('project');
|
|
|
|
const industryViaModel = industryModel && typeof industryModel.listMyBuilds === 'function'
|
|
? industryModel.listMyBuilds().then(normalize).catch(() => [])
|
|
: [];
|
|
|
|
const housingViaModel = housingModel && typeof housingModel.listHousing === 'function'
|
|
? housingModel.listHousing('ALL', userId).then(normalize).catch(() => [])
|
|
: [];
|
|
|
|
const schoolViaModel = schoolModel && typeof schoolModel.listCourses === 'function'
|
|
? schoolModel.listCourses('ALL', userId, {}).then(normalize).catch(() => [])
|
|
: [];
|
|
|
|
const campaignsViaModel = campaignsModel && typeof campaignsModel.listAll === 'function'
|
|
? campaignsModel.listAll({ filter: 'all' }).catch(() => [])
|
|
: [];
|
|
|
|
const logisticsViaModel = logisticsModel && typeof logisticsModel.listAll === 'function'
|
|
? logisticsModel.listAll({ filter: 'all' }).catch(() => [])
|
|
: [];
|
|
|
|
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll, housingAll, schoolAll, campaignsAll, logisticsAll] = await Promise.all([
|
|
tasksViaModel,
|
|
eventsViaModel,
|
|
fetchItems('transfer'),
|
|
fetchItems('tribe'),
|
|
marketViaModel,
|
|
fetchItems('report'),
|
|
jobsViaModel,
|
|
projectsViaModel,
|
|
calendarsViaModel,
|
|
industryViaModel,
|
|
housingViaModel,
|
|
schoolViaModel,
|
|
campaignsViaModel,
|
|
logisticsViaModel
|
|
]);
|
|
|
|
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
|
|
const events = eventsAll.filter(c => Array.isArray(c.attendees) && c.attendees.includes(userId)).map(e => ({ ...e, type: 'event' }));
|
|
const transfers = transfersAll.filter(c => c.from === userId || c.to === userId).map(tr => ({ ...tr, type: 'transfer' }));
|
|
const tribes = tribesAll.filter(c => Array.isArray(c.members) && c.members.includes(userId)).map(t => ({ ...t, type: 'tribe', title: t.title }));
|
|
const marketItems = marketAll.filter(c =>
|
|
c.seller === userId || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => String(b).split(':')[0] === userId))
|
|
).map(m => ({ ...m, type: 'market' }));
|
|
const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
|
|
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
|
|
const projects = projectsAll.filter(p => p.author === userId || (Array.isArray(p.followers) && p.followers.includes(userId)) || (Array.isArray(p.backers) && p.backers.some(b => b && b.userId === userId)) || (Array.isArray(p.bounties) && p.bounties.some(b => b && b.claimedBy === userId))).map(p => ({ ...p, type: 'project' }));
|
|
const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title }));
|
|
const schoolCourses = schoolAll
|
|
.filter(c => c.author === userId || (Array.isArray(c.students) && c.students.includes(userId)) || (Array.isArray(c.pending) && c.pending.some(pn => pn.author === userId)))
|
|
.map(c => ({ ...c, type: 'schoolCourse', title: c.title, date: c.startDate || c.createdAt, teaching: c.author === userId }));
|
|
const campaigns = (Array.isArray(campaignsAll) ? campaignsAll : [])
|
|
.filter(c => c && c.deadline && (c.author === userId || (Array.isArray(c.signers) && c.signers.includes(userId))))
|
|
.map(c => ({ ...c, type: 'campaign', date: c.deadline, status: c.closed ? 'CLOSED' : 'OPEN' }));
|
|
const routes = (Array.isArray(logisticsAll) ? logisticsAll : [])
|
|
.filter(r => r && r.date && (r.author === userId || !!r.myBooking))
|
|
.map(r => ({ ...r, type: 'logisticsRoute', status: r.status === 'CLOSED' ? 'CLOSED' : 'OPEN' }));
|
|
const housingPlaces = housingAll
|
|
.filter(h => h.author === userId || (Array.isArray(h.requests) && h.requests.includes(userId)))
|
|
.map(h => ({ ...h, type: 'housing', date: h.availableFrom || h.createdAt, requested: Array.isArray(h.requests) && h.requests.includes(userId) }));
|
|
const myCalendars = calendarsAll
|
|
.filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
|
|
const calendars = myCalendars.map(c => ({ ...c, id: c.rootId || c.id || c.key, type: 'calendar' }));
|
|
const calendarDates = [];
|
|
if (calendarsModel && typeof calendarsModel.getDatesForCalendar === 'function') {
|
|
const datesByCal = await Promise.all(myCalendars.map(cal => calendarsModel.getDatesForCalendar(cal.rootId || cal.id || cal.key).catch(() => [])));
|
|
for (const [ci, cal] of myCalendars.entries()) {
|
|
try {
|
|
const dates = datesByCal[ci];
|
|
for (const d of (dates || [])) {
|
|
calendarDates.push({
|
|
type: 'calendarDate',
|
|
id: d.key || d.id,
|
|
title: d.label || cal.title || 'Calendar date',
|
|
calendarTitle: cal.title || '',
|
|
calendarId: cal.rootId || cal.id || cal.key,
|
|
date: d.date || d.createdAt,
|
|
label: d.label || '',
|
|
author: cal.author,
|
|
createdAt: d.createdAt || cal.createdAt,
|
|
status: 'OPEN'
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
let combined = [
|
|
...tasks,
|
|
...events,
|
|
...transfers,
|
|
...tribes,
|
|
...marketItems,
|
|
...reports,
|
|
...jobs,
|
|
...projects,
|
|
...industryBuilds,
|
|
...housingPlaces,
|
|
...schoolCourses,
|
|
...campaigns,
|
|
...routes,
|
|
...calendars,
|
|
...calendarDates
|
|
].filter(i => !removedItems.includes(i.id));
|
|
|
|
let filtered;
|
|
if (filter === 'discarded') {
|
|
filtered = combined.filter(i => discardedItems.includes(i.id));
|
|
} else {
|
|
filtered = combined.filter(i => !discardedItems.includes(i.id));
|
|
if (filter === 'tasks') filtered = filtered.filter(i => i.type === 'task');
|
|
else if (filter === 'events') filtered = filtered.filter(i => i.type === 'event');
|
|
else if (filter === 'transfers') filtered = filtered.filter(i => i.type === 'transfer');
|
|
else if (filter === 'tribes') filtered = filtered.filter(i => i.type === 'tribe');
|
|
else if (filter === 'market') filtered = filtered.filter(i => i.type === 'market');
|
|
else if (filter === 'reports') filtered = filtered.filter(i => i.type === 'report');
|
|
else if (filter === 'open') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'OPEN');
|
|
else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
|
|
else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
|
|
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
|
|
else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry');
|
|
else if (filter === 'housing') filtered = filtered.filter(i => i.type === 'housing');
|
|
else if (filter === 'school') filtered = filtered.filter(i => i.type === 'schoolCourse');
|
|
else if (filter === 'campaigns') filtered = filtered.filter(i => i.type === 'campaign');
|
|
else if (filter === 'logistics') filtered = filtered.filter(i => i.type === 'logisticsRoute');
|
|
else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
|
|
else if (filter === 'today') {
|
|
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
|
|
const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999);
|
|
filtered = filtered.filter(i => {
|
|
const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
|
|
return d >= startOfDay.getTime() && d <= endOfDay.getTime();
|
|
});
|
|
}
|
|
else if (filter === 'upcoming') {
|
|
const now = Date.now();
|
|
filtered = filtered.filter(i => {
|
|
const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
|
|
return d > now;
|
|
});
|
|
}
|
|
else if (filter === 'overdue') {
|
|
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
|
|
filtered = filtered.filter(i => {
|
|
const d = new Date(i.date || i.startTime || i.deadline || 0).getTime();
|
|
const open = String(i.status || 'OPEN').toUpperCase() === 'OPEN';
|
|
return d > 0 && d < startOfDay.getTime() && open;
|
|
});
|
|
}
|
|
}
|
|
|
|
filtered.sort((a, b) => {
|
|
const dateA = a.date || a.startTime || a.deadline || a.createdAt || 0;
|
|
const dateB = b.date || b.startTime || b.deadline || b.createdAt || 0;
|
|
return new Date(dateA) - new Date(dateB);
|
|
});
|
|
|
|
const mainItems = combined.filter(i => !discardedItems.includes(i.id));
|
|
const discarded = combined.filter(i => discardedItems.includes(i.id));
|
|
|
|
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
|
|
const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999);
|
|
const now = Date.now();
|
|
const itemTs = (i) => new Date(i.date || i.startTime || i.deadline || 0).getTime();
|
|
return {
|
|
items: filtered,
|
|
counts: {
|
|
all: mainItems.length,
|
|
open: mainItems.filter(i => String(i.status).toUpperCase() === 'OPEN').length,
|
|
closed: mainItems.filter(i => String(i.status).toUpperCase() === 'CLOSED').length,
|
|
tasks: mainItems.filter(i => i.type === 'task').length,
|
|
events: mainItems.filter(i => i.type === 'event').length,
|
|
transfers: mainItems.filter(i => i.type === 'transfer').length,
|
|
tribes: mainItems.filter(i => i.type === 'tribe').length,
|
|
market: mainItems.filter(i => i.type === 'market').length,
|
|
reports: mainItems.filter(i => i.type === 'report').length,
|
|
jobs: mainItems.filter(i => i.type === 'job').length,
|
|
projects: mainItems.filter(i => i.type === 'project').length,
|
|
industry: mainItems.filter(i => i.type === 'industry').length,
|
|
housing: mainItems.filter(i => i.type === 'housing').length,
|
|
school: mainItems.filter(i => i.type === 'schoolCourse').length,
|
|
campaigns: mainItems.filter(i => i.type === 'campaign').length,
|
|
logistics: mainItems.filter(i => i.type === 'logisticsRoute').length,
|
|
calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
|
|
today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
|
|
upcoming: mainItems.filter(i => itemTs(i) > now).length,
|
|
overdue: mainItems.filter(i => { const d = itemTs(i); return d > 0 && d < startOfDay.getTime() && String(i.status || 'OPEN').toUpperCase() === 'OPEN'; }).length,
|
|
discarded: discarded.length
|
|
}
|
|
};
|
|
},
|
|
|
|
async discardItem(itemId) {
|
|
const agendaConfig = readAgendaConfig();
|
|
if (!agendaConfig.discardedItems.includes(itemId)) {
|
|
agendaConfig.discardedItems.push(itemId);
|
|
writeAgendaConfig(agendaConfig);
|
|
}
|
|
},
|
|
|
|
async restoreItem(itemId) {
|
|
const agendaConfig = readAgendaConfig();
|
|
agendaConfig.discardedItems = agendaConfig.discardedItems.filter(id => id !== itemId);
|
|
writeAgendaConfig(agendaConfig);
|
|
},
|
|
|
|
async removeItem(itemId) {
|
|
const agendaConfig = readAgendaConfig();
|
|
agendaConfig.discardedItems = (agendaConfig.discardedItems || []).filter(id => id !== itemId);
|
|
agendaConfig.removedItems = agendaConfig.removedItems || [];
|
|
if (!agendaConfig.removedItems.includes(itemId)) agendaConfig.removedItems.push(itemId);
|
|
writeAgendaConfig(agendaConfig);
|
|
}
|
|
};
|
|
};
|
|
|