upstream: integrar Oasis 1.3.1

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.
This commit is contained in:
oasis-bot 2026-10-18 02:52:17 +02:00
parent 8d838dd8b1
commit c332afd6ea
265 changed files with 80970 additions and 29167 deletions

View file

@ -3,9 +3,11 @@ const path = require('path');
const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const agendaConfigPath = path.join(__dirname, '../configs/agenda-config.json');
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() {
@ -19,137 +21,117 @@ function writeAgendaConfig(cfg) {
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
}
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel }) => {
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 = (targetType) =>
new Promise((resolve, reject) => {
openSsb().then((ssbClient) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => {
if (err) return reject(err);
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 parent = new Map();
const child = new Map();
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 });
if (c.replaces) parent.set(k, c.replaces);
}
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));
}
for (const [k, p] of Array.from(parent.entries())) {
const cn = nodes.get(k);
const pn = nodes.get(p);
if (!pn) { parent.delete(k); continue; }
if (!cn || String(cn.author) !== String(pn.author)) { parent.delete(k); nodes.delete(k); }
}
for (const [k, p] of parent.entries()) child.set(p, k);
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 rootOf = (id) => { let cur = id; while (parent.has(cur) && nodes.has(parent.get(cur))) cur = parent.get(cur); return cur; };
const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
const groups = new Map();
for (const id of nodes.keys()) {
const r = rootOf(id);
if (!groups.has(r)) groups.set(r, new Set());
groups.get(r).add(id);
}
const out = [];
const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD'];
const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase());
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 out = [];
const tip = nodes.get(vg.tipOf(root));
if (tomb.has(tip.key)) continue;
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;
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;
let tipId = Array.from(ids).find(id => !child.has(id));
let tip = tipId ? nodes.get(tipId) : items.reduce((a, b) => a.ts > b.ts ? a : b);
out.push({
...c,
status,
author: chosen.author,
id: chosen.key,
tipId: chosen.key,
createdAt: c.createdAt || chosen.ts
});
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;
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;
out.push({
...c,
status,
author: chosen.author,
id: chosen.key,
tipId: chosen.key,
createdAt: c.createdAt || chosen.ts
});
continue;
}
const c = { ...latest.content, status: resolvedStatus, subscribers };
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;
out.push({
...c,
author: latest.author,
id: latest.key,
tipId: latest.key,
createdAt: c.createdAt || latest.ts
});
continue;
}
const c = { ...latest.content, status: resolvedStatus, subscribers };
out.push({
...tip.content,
author: tip.author,
id: tip.key,
tipId: tip.key,
createdAt: tip.content.createdAt || tip.ts
});
}
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
});
}
resolve(out);
})
);
}).catch(reject);
});
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;
@ -192,7 +174,19 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
? housingModel.listHousing('ALL', userId).then(normalize).catch(() => [])
: [];
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll, housingAll] = await Promise.all([
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'),
@ -203,7 +197,10 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
projectsViaModel,
calendarsViaModel,
industryViaModel,
housingViaModel
housingViaModel,
schoolViaModel,
campaignsViaModel,
logisticsViaModel
]);
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
@ -215,26 +212,36 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
).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.map(p => ({ ...p, type: 'project' }));
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, type: 'calendar' }));
const calendars = myCalendars.map(c => ({ ...c, id: c.rootId || c.id || c.key, type: 'calendar' }));
const calendarDates = [];
if (calendarsModel && typeof calendarsModel.getDatesForCalendar === 'function') {
for (const cal of myCalendars) {
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 = await calendarsModel.getDatesForCalendar(cal.id || cal.key);
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.id || cal.key,
calendarId: cal.rootId || cal.id || cal.key,
date: d.date || d.createdAt,
label: d.label || '',
author: cal.author,
@ -257,9 +264,12 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
...projects,
...industryBuilds,
...housingPlaces,
...schoolCourses,
...campaigns,
...routes,
...calendars,
...calendarDates
];
].filter(i => !removedItems.includes(i.id));
let filtered;
if (filter === 'discarded') {
@ -278,6 +288,9 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
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);
@ -333,6 +346,9 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
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,
@ -354,6 +370,14 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
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);
}
};
};