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.
485 lines
20 KiB
JavaScript
485 lines
20 KiB
JavaScript
const pull = require('../server/node_modules/pull-stream');
|
|
const { readTyped, memoIndex } = require('./typed_log');
|
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
const { getConfig } = require('../configs/config-manager.js');
|
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
|
|
const WIKILINK_RE = /\[\[([^\]|\n]{1,120})(?:\|([^\]\n]{1,120}))?\]\]/g;
|
|
|
|
const slugify = (value) => String(value == null ? '' : value)
|
|
.normalize('NFD')
|
|
.replace(/[̀-ͯ]/g, '')
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80);
|
|
|
|
const linkTarget = (raw) => {
|
|
const s = String(raw || '').trim();
|
|
const isUrl = /^[a-z]+:\/\//i.test(s);
|
|
const wikiPath = (isUrl || s.startsWith('/')) ? s.match(/\/wiki\/([^?#]+)/) : null;
|
|
if (wikiPath) { try { return decodeURIComponent(wikiPath[1]).trim(); } catch (_) { return wikiPath[1].trim(); } }
|
|
if (isUrl) {
|
|
const seg = s.replace(/[?#].*$/, '').split('/').filter(Boolean).pop() || '';
|
|
try { return decodeURIComponent(seg).trim(); } catch (_) { return seg.trim(); }
|
|
}
|
|
const namespaced = s.match(/^[a-z]+:(?=\S)(.+)$/i);
|
|
return namespaced ? namespaced[1].trim() : s;
|
|
};
|
|
|
|
const extractWikiLinks = (text) => {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const m of String(text || '').matchAll(WIKILINK_RE)) {
|
|
const slug = slugify(linkTarget(m[1]));
|
|
if (!slug || seen.has(slug)) continue;
|
|
seen.add(slug);
|
|
out.push(slug);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const WIKI_TYPE = 'wikiPage';
|
|
const ENVELOPE_TYPE = 'tribe-msg';
|
|
const EDIT_POLICIES = ['open', 'author', 'tribe'];
|
|
const RECENT_MS = 24 * 60 * 60 * 1000;
|
|
const MAX_CHANGES = 60;
|
|
const MAX_TITLE = 100;
|
|
const IMAGE_RE = /!\[image:[^\]\[\n]{0,160}\]\((&[^)\s]{1,120}\.sha256)\)/;
|
|
const BODY_INLINE_MAX = 6000;
|
|
const bodyCache = new Map();
|
|
const addBlob = (ssbClient, buf) => new Promise((resolve, reject) => pull(pull.values([buf]), ssbClient.blobs.add((err, ref) => err ? reject(err) : resolve(ref))));
|
|
const getBlob = (ssbClient, ref) => new Promise((resolve) => {
|
|
try {
|
|
pull(ssbClient.blobs.get(ref), pull.collect((err, chunks) => resolve(err || !chunks || !chunks.length ? null : Buffer.concat(chunks.map(c => Buffer.isBuffer(c) ? c : Buffer.from(c))))));
|
|
} catch (_) { resolve(null); }
|
|
});
|
|
|
|
const safeText = (v) => String(v == null ? '' : v).trim();
|
|
const normalizeList = (raw) => {
|
|
if (!raw) return [];
|
|
const arr = Array.isArray(raw) ? raw : String(raw).split(',');
|
|
return Array.from(new Set(arr.map(t => String(t || '').trim()).filter(Boolean)));
|
|
};
|
|
const normalizePolicy = (v) => {
|
|
const s = String(v || '').toLowerCase();
|
|
if (s === 'closed') return 'author';
|
|
return EDIT_POLICIES.includes(s) ? s : 'open';
|
|
};
|
|
|
|
module.exports = ({ cooler, tribeCrypto = null, tribesModel = null }) => {
|
|
let ssb;
|
|
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
|
|
const tribeRootId = async (tribeId) => {
|
|
if (!tribesModel || !tribeId) return tribeId;
|
|
try { return await tribesModel.getRootId(tribeId); } catch (_) { return tribeId; }
|
|
};
|
|
|
|
const tribeKeyFor = async (tribeId) => {
|
|
if (!tribeCrypto || !tribeId) return null;
|
|
const rootId = await tribeRootId(tribeId);
|
|
return tribeCrypto.getKey(rootId) || null;
|
|
};
|
|
|
|
const storeBody = async (ssbClient, body, tribeId) => {
|
|
const text = safeText(body);
|
|
if (Buffer.byteLength(text, 'utf8') <= BODY_INLINE_MAX) return { body: text };
|
|
let payload = text;
|
|
if (tribeId) {
|
|
const key = await tribeKeyFor(tribeId);
|
|
if (!key) throw new Error('Missing tribe key — cannot publish encrypted wiki page');
|
|
payload = JSON.stringify(tribeCrypto.encryptContent({ body: text }, [key], true));
|
|
}
|
|
const ref = await addBlob(ssbClient, Buffer.from(payload, 'utf8'));
|
|
bodyCache.set(ref, text);
|
|
return { body: '', bodyBlob: ref };
|
|
};
|
|
|
|
const resolveBody = async (ssbClient, c) => {
|
|
if (!c || typeof c.bodyBlob !== 'string' || !c.bodyBlob) return c;
|
|
if (bodyCache.has(c.bodyBlob)) return { ...c, body: bodyCache.get(c.bodyBlob) };
|
|
const buf = await getBlob(ssbClient, c.bodyBlob);
|
|
if (!buf) return { ...c, body: safeText(c.body) };
|
|
let text = buf.toString('utf8');
|
|
if (c.tribeId) {
|
|
try {
|
|
const key = await tribeKeyFor(c.tribeId);
|
|
const r = tribeCrypto.decryptContent(JSON.parse(text), key ? [[key]] : []);
|
|
text = r && !r._undecryptable && typeof r.body === 'string' ? r.body : '';
|
|
} catch (_) { text = ''; }
|
|
}
|
|
bodyCache.set(c.bodyBlob, text);
|
|
return { ...c, body: text };
|
|
};
|
|
|
|
const readEntries = async (ssbClient) => {
|
|
const msgs = await readTyped(ssbClient, [WIKI_TYPE, 'tombstone', ENVELOPE_TYPE], { limit: logLimit });
|
|
const fpIdx = tribeCrypto && typeof tribeCrypto.buildFingerprintIndex === 'function' ? tribeCrypto.buildFingerprintIndex() : null;
|
|
const out = [];
|
|
for (const m of msgs) {
|
|
const v = m && m.value;
|
|
const c = v && v.content;
|
|
if (!c || typeof c !== 'object') continue;
|
|
if (c.type === WIKI_TYPE || c.type === 'tombstone') { out.push(m); continue; }
|
|
if (!fpIdx || !tribeCrypto.isTribeMsg(c)) continue;
|
|
const r = tribeCrypto.unwrapMsg(c, fpIdx);
|
|
if (!r || !r.body || typeof r.body !== 'object') continue;
|
|
const body = r.body;
|
|
if (body.k === WIKI_TYPE) {
|
|
const flat = { ...body, type: WIKI_TYPE };
|
|
delete flat.k;
|
|
out.push({ ...m, value: { ...v, content: flat } });
|
|
} else if (body.k === 'tombstone' && typeof body.target === 'string') {
|
|
out.push({ ...m, value: { ...v, content: { type: 'tombstone', target: body.target, deletedAt: body.deletedAt, author: body.author || v.author } } });
|
|
}
|
|
}
|
|
for (let i = 0; i < out.length; i++) {
|
|
const c = out[i].value && out[i].value.content;
|
|
if (c && c.type === WIKI_TYPE && c.bodyBlob) out[i] = { ...out[i], value: { ...out[i].value, content: await resolveBody(ssbClient, c) } };
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const tribeMembersFor = async (entries) => {
|
|
const out = new Map();
|
|
if (!tribesModel) return out;
|
|
for (const m of entries) {
|
|
const c = m.value.content;
|
|
if (c.type !== WIKI_TYPE || !c.tribeId || out.has(String(c.tribeId))) continue;
|
|
let members = [];
|
|
try { const t = await tribesModel.getTribeById(c.tribeId); members = Array.isArray(t && t.members) ? t.members : []; } catch (_) {}
|
|
out.set(String(c.tribeId), members);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const buildIndex = (entries, membersOf = new Map()) => {
|
|
const tomb = buildValidatedTombstoneSet(entries);
|
|
const nodes = new Map();
|
|
for (const m of entries) {
|
|
const c = m.value.content;
|
|
if (c.type !== WIKI_TYPE) continue;
|
|
nodes.set(m.key, { key: m.key, author: m.value.author, ts: m.value.timestamp || m.timestamp || 0, c });
|
|
}
|
|
const mayEdit = (state, author) => {
|
|
if (author === state.owner) return true;
|
|
if (state.policy === 'author') return false;
|
|
if (state.tribeId) return (membersOf.get(state.tribeId) || []).includes(author);
|
|
return state.policy !== 'tribe';
|
|
};
|
|
const states = new Map();
|
|
const stateOf = (key) => {
|
|
const path = [];
|
|
const seen = new Set();
|
|
let cur = key;
|
|
while (!states.has(cur)) {
|
|
const node = nodes.get(cur);
|
|
if (!node) { states.set(cur, null); break; }
|
|
if (typeof node.c.replaces !== 'string') {
|
|
states.set(cur, { root: cur, owner: node.author, tribeId: String(node.c.tribeId || ''), policy: normalizePolicy(node.c.editPolicy), license: safeText(node.c.license) });
|
|
break;
|
|
}
|
|
if (seen.has(cur)) { states.set(cur, null); break; }
|
|
seen.add(cur);
|
|
path.push(cur);
|
|
cur = node.c.replaces;
|
|
}
|
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
const k = path[i];
|
|
if (states.has(k)) continue;
|
|
const node = nodes.get(k);
|
|
const parent = states.get(node.c.replaces);
|
|
if (!parent || String(node.c.tribeId || '') !== parent.tribeId || !mayEdit(parent, node.author)) { states.set(k, null); continue; }
|
|
states.set(k, node.author === parent.owner ? { ...parent, policy: normalizePolicy(node.c.editPolicy), license: safeText(node.c.license) } : parent);
|
|
}
|
|
return states.get(key);
|
|
};
|
|
const rootOf = (key) => { const s = stateOf(key); return s ? s.root : key; };
|
|
const versionsByRoot = new Map();
|
|
for (const node of nodes.values()) {
|
|
if (!stateOf(node.key)) continue;
|
|
const root = rootOf(node.key);
|
|
if (tomb.has(root)) continue;
|
|
if (!versionsByRoot.has(root)) versionsByRoot.set(root, []);
|
|
versionsByRoot.get(root).push(node);
|
|
}
|
|
const pages = [];
|
|
for (const [root, versions] of versionsByRoot) {
|
|
versions.sort((a, b) => a.ts - b.ts);
|
|
const first = versions[0];
|
|
const tip = versions[versions.length - 1];
|
|
const state = stateOf(tip.key);
|
|
const c = tip.c;
|
|
const body = safeText(c.body);
|
|
pages.push({
|
|
id: root,
|
|
rootId: root,
|
|
tipId: tip.key,
|
|
slug: slugify(c.title) || safeText(c.slug),
|
|
title: safeText(c.title).slice(0, MAX_TITLE),
|
|
body,
|
|
image: (body.match(IMAGE_RE) || [])[1] || null,
|
|
tags: normalizeList(c.tags),
|
|
aliases: [...new Set([...normalizeList(c.aliases).map(slugify), safeText(c.slug)].filter(a => a && a !== slugify(c.title)))],
|
|
editPolicy: state.policy,
|
|
license: state.license,
|
|
tribeId: c.tribeId || null,
|
|
encrypted: !!c.tribeId,
|
|
author: state.owner,
|
|
lastAuthor: tip.author,
|
|
createdAt: first.c.createdAt || new Date(first.ts).toISOString(),
|
|
updatedAt: c.updatedAt || new Date(tip.ts).toISOString(),
|
|
ts: tip.ts,
|
|
versionCount: versions.length,
|
|
links: extractWikiLinks(body),
|
|
versions: versions.map(vn => ({
|
|
key: vn.key,
|
|
author: vn.author,
|
|
ts: vn.ts,
|
|
createdAt: vn.c.updatedAt || vn.c.createdAt || new Date(vn.ts).toISOString(),
|
|
title: safeText(vn.c.title),
|
|
body: safeText(vn.c.body),
|
|
tags: normalizeList(vn.c.tags),
|
|
summary: safeText(vn.c.summary)
|
|
}))
|
|
});
|
|
}
|
|
pages.sort((a, b) => a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0);
|
|
return { pages, rootOf, nodes };
|
|
};
|
|
|
|
const sameScope = (page, tribeId) => String(page.tribeId || '') === String(tribeId || '');
|
|
|
|
const scopePages = async (pages, tribeId) => {
|
|
if (tribeId === 'any') return pages;
|
|
if (!tribeId) return pages.filter(p => !p.tribeId);
|
|
const chain = tribesModel && typeof tribesModel.getChainIds === 'function'
|
|
? await tribesModel.getChainIds(tribeId).catch(() => [tribeId])
|
|
: [tribeId];
|
|
const set = new Set(chain.map(String));
|
|
return pages.filter(p => p.tribeId && set.has(String(p.tribeId)));
|
|
};
|
|
|
|
const decorate = (pages, scoped) => {
|
|
const bySlug = new Map();
|
|
for (const p of scoped) {
|
|
const keys = [p.slug, ...p.aliases];
|
|
for (const s of keys) if (s && !bySlug.has(s)) bySlug.set(s, p);
|
|
}
|
|
const inbound = new Map();
|
|
for (const p of scoped) {
|
|
for (const s of p.links) {
|
|
const target = bySlug.get(s);
|
|
if (!target || target.id === p.id) continue;
|
|
if (!inbound.has(target.id)) inbound.set(target.id, new Set());
|
|
inbound.get(target.id).add(p.id);
|
|
}
|
|
}
|
|
return scoped.map(p => ({
|
|
...p,
|
|
backlinkCount: (inbound.get(p.id) || new Set()).size,
|
|
missingLinks: p.links.filter(s => !bySlug.has(s)),
|
|
isLinked: (inbound.get(p.id) || new Set()).size > 0
|
|
}));
|
|
};
|
|
|
|
const findInScope = (scoped, idOrSlug) => {
|
|
const needle = String(idOrSlug || '').trim();
|
|
if (!needle) return null;
|
|
if (needle.startsWith('%')) return scoped.find(p => p.id === needle || p.tipId === needle || p.versions.some(v => v.key === needle)) || null;
|
|
const slug = slugify(needle);
|
|
return scoped.find(p => p.slug === slug) || scoped.find(p => p.aliases.includes(slug)) || null;
|
|
};
|
|
|
|
const publishContent = async (ssbClient, content) => {
|
|
let msg = content;
|
|
if (content.tribeId) {
|
|
const key = await tribeKeyFor(content.tribeId);
|
|
if (!key) throw new Error('Missing tribe key — cannot publish encrypted wiki page');
|
|
msg = tribeCrypto.wrapMsg({ k: WIKI_TYPE, ...content }, key);
|
|
}
|
|
return new Promise((resolve, reject) => ssbClient.publish(msg, (err, res) => err ? reject(err) : resolve(res)));
|
|
};
|
|
|
|
const load = async () => {
|
|
const ssbClient = await openSsb();
|
|
const entries = await readEntries(ssbClient);
|
|
const members = await tribeMembersFor(entries);
|
|
const idx = memoIndex('wiki', entries, (e) => buildIndex(e, members));
|
|
return { ssbClient, idx };
|
|
};
|
|
|
|
const canEdit = async (page, viewer) => {
|
|
if (!page) return false;
|
|
const owner = String(page.author) === String(viewer);
|
|
if (page.editPolicy === 'author') return owner;
|
|
if (page.tribeId) {
|
|
if (!tribesModel) return owner;
|
|
try {
|
|
const t = await tribesModel.getTribeById(page.tribeId);
|
|
return owner || (Array.isArray(t.members) && t.members.includes(viewer));
|
|
} catch (_) { return owner; }
|
|
}
|
|
if (page.editPolicy === 'tribe') return owner;
|
|
return true;
|
|
};
|
|
|
|
return {
|
|
WIKI_TYPE,
|
|
EDIT_POLICIES,
|
|
slugify,
|
|
|
|
async createPage({ title, body, tags, aliases, editPolicy, license, tribeId = null }) {
|
|
const { ssbClient, idx } = await load();
|
|
const cleanTitle = safeText(title).slice(0, MAX_TITLE);
|
|
if (!cleanTitle) throw new Error('Title required');
|
|
const slug = slugify(cleanTitle);
|
|
if (!slug) throw new Error('Title required');
|
|
const scoped = await scopePages(idx.pages, tribeId);
|
|
const existing = scoped.find(p => p.slug === slug);
|
|
if (existing) return { key: existing.id, existing: true };
|
|
const now = new Date().toISOString();
|
|
const content = {
|
|
type: WIKI_TYPE,
|
|
title: cleanTitle,
|
|
slug,
|
|
...(await storeBody(ssbClient, body, tribeId)),
|
|
tags: normalizeList(tags),
|
|
aliases: normalizeList(aliases).map(slugify).filter(Boolean),
|
|
editPolicy: normalizePolicy(editPolicy),
|
|
...(license ? { license } : {}),
|
|
author: ssbClient.id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
...(tribeId ? { tribeId } : {})
|
|
};
|
|
const res = await publishContent(ssbClient, content);
|
|
return { key: res.key, existing: false };
|
|
},
|
|
|
|
async updatePage(id, data = {}) {
|
|
const { ssbClient, idx } = await load();
|
|
const page = findInScope(idx.pages, id);
|
|
if (!page) throw new Error('Page not found');
|
|
if (!(await canEdit(page, ssbClient.id))) throw new Error('Not allowed to edit');
|
|
const now = new Date().toISOString();
|
|
const nextTitle = data.title !== undefined ? safeText(data.title).slice(0, MAX_TITLE) : page.title;
|
|
const nextLicense = data.license !== undefined && String(page.author) === String(ssbClient.id) ? safeText(data.license) : page.license;
|
|
const content = {
|
|
type: WIKI_TYPE,
|
|
title: nextTitle || page.title,
|
|
slug: page.slug,
|
|
...(await storeBody(ssbClient, data.body !== undefined ? data.body : page.body, page.tribeId || null)),
|
|
tags: data.tags !== undefined ? normalizeList(data.tags) : page.tags,
|
|
aliases: data.aliases !== undefined ? normalizeList(data.aliases).map(slugify).filter(Boolean) : page.aliases,
|
|
editPolicy: data.editPolicy !== undefined ? normalizePolicy(data.editPolicy) : page.editPolicy,
|
|
...(nextLicense ? { license: nextLicense } : {}),
|
|
summary: safeText(data.summary).slice(0, 200),
|
|
author: ssbClient.id,
|
|
createdAt: page.createdAt,
|
|
updatedAt: now,
|
|
replaces: page.tipId,
|
|
...(page.tribeId ? { tribeId: page.tribeId } : {})
|
|
};
|
|
const res = await publishContent(ssbClient, content);
|
|
return { key: res.key, rootId: page.id, author: page.author };
|
|
},
|
|
|
|
async restoreVersion(id, versionKey) {
|
|
const { idx } = await load();
|
|
const page = findInScope(idx.pages, id);
|
|
if (!page) throw new Error('Page not found');
|
|
const version = page.versions.find(v => v.key === versionKey);
|
|
if (!version) throw new Error('Version not found');
|
|
return this.updatePage(page.id, { title: version.title, body: version.body, tags: version.tags, summary: `restore:${versionKey}` });
|
|
},
|
|
|
|
async deletePage(id) {
|
|
const { ssbClient, idx } = await load();
|
|
const page = findInScope(idx.pages, id);
|
|
if (!page) throw new Error('Page not found');
|
|
if (String(page.author) !== String(ssbClient.id)) throw new Error('Only the creator can delete');
|
|
const deletedAt = new Date().toISOString();
|
|
const tomb = { type: 'tombstone', target: page.id, deletedAt, author: ssbClient.id };
|
|
let msg = tomb;
|
|
if (page.tribeId) {
|
|
const key = await tribeKeyFor(page.tribeId);
|
|
if (!key) throw new Error('Missing tribe key — cannot delete encrypted wiki page');
|
|
msg = tribeCrypto.wrapMsg({ k: 'tombstone', target: page.id, deletedAt, author: ssbClient.id }, key);
|
|
}
|
|
return new Promise((resolve, reject) => ssbClient.publish(msg, (err, res) => err ? reject(err) : resolve(res)));
|
|
},
|
|
|
|
async getPage(idOrSlug, { tribeId = null } = {}) {
|
|
const { ssbClient, idx } = await load();
|
|
let scoped = await scopePages(idx.pages, tribeId);
|
|
let page = findInScope(scoped, idOrSlug);
|
|
if (!page && String(idOrSlug || '').startsWith('%')) {
|
|
page = findInScope(idx.pages, idOrSlug);
|
|
if (page) scoped = await scopePages(idx.pages, page.tribeId || null);
|
|
}
|
|
if (!page) return null;
|
|
const decorated = decorate(idx.pages, scoped);
|
|
const full = decorated.find(p => p.id === page.id) || page;
|
|
const bySlug = new Map();
|
|
for (const p of scoped) for (const s of [p.slug, ...p.aliases]) if (s && !bySlug.has(s)) bySlug.set(s, p);
|
|
const backlinks = decorated.filter(p => p.id !== full.id && p.links.some(s => bySlug.get(s) && bySlug.get(s).id === full.id));
|
|
const outbound = [];
|
|
const seen = new Set([full.id]);
|
|
for (const slug of full.links) {
|
|
const target = bySlug.get(slug);
|
|
if (!target || seen.has(target.id)) continue;
|
|
seen.add(target.id);
|
|
outbound.push(decorated.find(p => p.id === target.id) || target);
|
|
}
|
|
const linkedPages = [...backlinks];
|
|
const inList = new Set(backlinks.map(p => p.id));
|
|
for (const p of outbound) if (!inList.has(p.id)) linkedPages.push(p);
|
|
return { ...full, backlinks, outbound, linkedPages, canEdit: await canEdit(full, ssbClient.id), isOwner: String(full.author) === String(ssbClient.id) };
|
|
},
|
|
|
|
async listPages({ tribeId = null, filter = 'all', q = '', viewerId } = {}) {
|
|
const { ssbClient, idx } = await load();
|
|
const me = viewerId || ssbClient.id;
|
|
const scoped = decorate(idx.pages, await scopePages(idx.pages, tribeId));
|
|
let list = scoped;
|
|
const f = String(filter || 'all').toLowerCase();
|
|
if (f === 'mine') list = list.filter(p => String(p.author) === String(me) || p.versions.some(v => String(v.author) === String(me)));
|
|
else if (f === 'recent') list = list.filter(p => Date.now() - p.ts <= RECENT_MS);
|
|
else if (f === 'linked') list = list.filter(p => p.isLinked);
|
|
const needle = safeText(q).toLowerCase();
|
|
if (needle) list = list.filter(p => p.title.toLowerCase().includes(needle) || p.body.toLowerCase().includes(needle) || p.tags.some(t => t.toLowerCase().includes(needle)));
|
|
return list.slice().sort((a, b) => b.ts - a.ts);
|
|
},
|
|
|
|
async recentChanges({ tribeId = null } = {}) {
|
|
const { idx } = await load();
|
|
const scoped = await scopePages(idx.pages, tribeId);
|
|
const changes = [];
|
|
for (const p of scoped) {
|
|
p.versions.forEach((v, i) => changes.push({
|
|
pageId: p.id, slug: p.slug, title: v.title || p.title, tribeId: p.tribeId || null,
|
|
versionKey: v.key, author: v.author, ts: v.ts, createdAt: v.createdAt, summary: v.summary, index: i + 1, isCreation: i === 0
|
|
}));
|
|
}
|
|
return changes.sort((a, b) => b.ts - a.ts || b.index - a.index).slice(0, MAX_CHANGES);
|
|
},
|
|
|
|
async resolveRootId(id) {
|
|
const { idx } = await load();
|
|
const page = findInScope(idx.pages, id);
|
|
if (!page) throw new Error('Not found');
|
|
return page.id;
|
|
},
|
|
|
|
async canEditPage(id, viewer) {
|
|
const { ssbClient, idx } = await load();
|
|
const page = findInScope(idx.pages, id);
|
|
return canEdit(page, viewer || ssbClient.id);
|
|
}
|
|
};
|
|
};
|
|
|
|
Object.assign(module.exports, { WIKILINK_RE, slugify, linkTarget, extractWikiLinks });
|