OASIS_MOBILE/src/models/dev_model.js
SITO f964e61a1c upstream: integrar 0.9.5 en lo que no diverge (57 ficheros) + dependencias nuevas
Primera tanda de la integracion 0.9.1 -> 0.9.5. Se actualizan los 57 ficheros que
upstream cambio y la rama no tocaba, entre ellos los 11 de traduccion: eso ya solo
es posible gracias al overlay de i18n, antes eran 11 conflictos garantizados.

Se portan ademas las dependencias que 0.9.5 introduce y de las que dependen los
ficheros actualizados: pdfDocument, content_favorites, recurrence, media_gallery,
comments_view, gallery_view y polls (modelo, limites y vista).

Quedan 48 ficheros con divergencia real por resolver a mano (backend.js,
main_views.js, OasisMobile.css y 45 vistas y modelos).

Verificado: 0 errores de sintaxis en src/, 0 requires relativos rotos, la app
arranca declarando 0.9.5 y las 47 rutas principales responden 200/302.
2026-08-18 21:42:59 +02:00

340 lines
14 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const { getConfig } = require('../configs/config-manager.js');
const ROOT = path.resolve(__dirname, '..', '..');
const ALLOWED_ROOTS = ['src', 'docs', 'scripts', 'test'];
const ALLOWED_ROOT_FILES = ['README.md', 'LICENSE', 'install.sh', 'oasis.sh'];
const DENIED_SEGMENTS = new Set(['node_modules', '.git', '.ssb', 'packages', 'configs', 'embeddings', 'tiles', 'cache', 'results']);
const DENIED_NAMES = new Set(['secret', '.env', '.npmrc', 'package-lock.json']);
const DENIED_EXTENSIONS = new Set(['.gguf', '.jks', '.keystore', '.enc', '.pem', '.key', '.p12', '.crt']);
const VIEWABLE_EXTENSIONS = new Set(['.js', '.mjs', '.json', '.css', '.md', '.sh', '.html', '.txt', '.yml', '.yaml']);
const STATS_TTL = 60 * 1000;
let statsCache = null;
let statsCacheAt = 0;
const MAX_VIEW_BYTES = 2 * 1024 * 1024;
const PAGE_LINES = 500;
const SEARCH_MAX = 200;
const SEARCH_SNIPPET = 200;
const extOf = (name) => {
const i = String(name || '').lastIndexOf('.');
return i > 0 ? String(name).slice(i).toLowerCase() : '';
};
const isDeniedEntry = (name) => {
const n = String(name || '');
if (!n || n === '.' || n === '..') return true;
if (n.toLowerCase().startsWith('.git')) return true;
if (DENIED_SEGMENTS.has(n)) return true;
if (DENIED_NAMES.has(n)) return true;
if (DENIED_EXTENSIONS.has(extOf(n))) return true;
return false;
};
const PLAIN_TEXT_NAME = /^[A-Z][A-Z0-9_-]*$/;
const isViewable = (name) => (VIEWABLE_EXTENSIONS.has(extOf(name)) || PLAIN_TEXT_NAME.test(String(name || ''))) && !isDeniedEntry(name);
function realRoot() {
try { return fs.realpathSync(ROOT); } catch (_) { return ROOT; }
}
function splitPath(rel) {
const raw = String(rel || '');
for (let i = 0; i < raw.length; i++) { if (raw.charCodeAt(i) < 32) throw new Error('Invalid path'); }
return raw.replace(/^[/\\]+/, '').split(/[/\\]+/).filter(s => s && s !== '.');
}
function resolveSafe(rel) {
const segments = splitPath(rel);
if (segments.some(s => s === '..')) throw new Error('Invalid path');
if (segments.some(isDeniedEntry)) throw new Error('Path not available');
if (segments.length) {
const head = segments[0];
const isRootFile = segments.length === 1 && ALLOWED_ROOT_FILES.includes(head);
if (!ALLOWED_ROOTS.includes(head) && !isRootFile) throw new Error('Path not available');
}
const target = path.resolve(ROOT, segments.join(path.sep));
let real;
try { real = fs.realpathSync(target); } catch (_) { throw new Error('Not found'); }
const base = realRoot();
if (real !== base && !real.startsWith(base + path.sep)) throw new Error('Path not available');
return { abs: real, rel: segments.join('/'), segments };
}
function parentsOf(segments) {
const out = [];
let acc = '';
for (const s of segments) {
acc = acc ? `${acc}/${s}` : s;
out.push({ name: s, path: acc });
}
return out;
}
function rootListing() {
const dirs = [];
const files = [];
for (const name of ALLOWED_ROOTS) {
const abs = path.join(ROOT, name);
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) dirs.push({ name, path: name });
}
for (const name of ALLOWED_ROOT_FILES) {
const abs = path.join(ROOT, name);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
files.push({ name, path: name, size: fs.statSync(abs).size, ext: extOf(name), viewable: isViewable(name) });
}
return { path: '', parents: [], dirs, files, hidden: 0 };
}
function walkFiles(relDir, onFile) {
let entries;
try { entries = fs.readdirSync(path.resolve(ROOT, relDir), { withFileTypes: true }); } catch (_) { return true; }
for (const e of entries) {
if (isDeniedEntry(e.name)) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
if (e.isSymbolicLink()) continue;
if (e.isDirectory()) { if (walkFiles(rel, onFile) === false) return false; continue; }
if (!e.isFile()) continue;
if (onFile(rel, e.name) === false) return false;
}
return true;
}
const MODULE_ALIASES = {
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
docs: { paths: ['/documents'] },
blogs: { model: 'src/models/blog_model.js', view: 'src/views/blog_view.js', paths: ['/blogs'] },
polls: { model: 'src/models/polls_model.js', view: 'src/views/polls_view.js', paths: ['/polls'] },
data: { model: 'src/models/data_model.js', view: 'src/views/data_view.js', paths: ['/data'] },
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' }
};
const GROUPS = {
translations: ['src/client/assets/translations'],
styles: ['src/client/assets/styles', 'src/client/assets/themes'],
tests: ['test']
};
module.exports = {
ROOT,
PAGE_LINES,
GROUPS,
listGroup(key) {
const roots = GROUPS[String(key || '')];
if (!roots) throw new Error('Not found');
const dirs = [];
const files = [];
for (const rel of roots) {
let listing;
try { listing = module.exports.listTree(rel); } catch (_) { continue; }
dirs.push(...listing.dirs);
files.push(...listing.files);
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));
return { group: String(key), path: '', parents: [], dirs, files, hidden: 0 };
},
listTree(relPath) {
const segments = splitPath(relPath);
if (!segments.length) return rootListing();
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isDirectory()) throw new Error('Not a directory');
const entries = fs.readdirSync(target.abs, { withFileTypes: true });
const dirs = [];
const files = [];
let hidden = 0;
for (const e of entries) {
if (isDeniedEntry(e.name) || e.isSymbolicLink()) { hidden++; continue; }
const rel = `${target.rel}/${e.name}`;
if (e.isDirectory()) { dirs.push({ name: e.name, path: rel }); continue; }
if (!e.isFile()) { hidden++; continue; }
let size = 0;
try { size = fs.statSync(path.join(target.abs, e.name)).size; } catch (_) { size = 0; }
files.push({ name: e.name, path: rel, size, ext: extOf(e.name), viewable: isViewable(e.name) });
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));
return { path: target.rel, parents: parentsOf(target.segments), dirs, files, hidden };
},
readFile(relPath, opts = {}) {
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isFile()) throw new Error('Not a file');
if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed');
if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed');
const all = fs.readFileSync(target.abs, 'utf8').split('\n');
const totalLines = all.length;
const requested = parseInt(opts.from, 10);
const from = Math.min(Math.max(Number.isFinite(requested) ? requested : 1, 1), Math.max(1, totalLines));
const to = Math.min(from + PAGE_LINES - 1, totalLines);
const lines = [];
for (let n = from; n <= to; n++) lines.push({ n, text: all[n - 1] });
return {
path: target.rel,
parents: parentsOf(target.segments),
name: path.basename(target.abs),
size: stat.size,
totalLines,
from,
to,
pageLines: PAGE_LINES,
hasPrev: from > 1,
hasNext: to < totalLines,
lines
};
},
rawFile(relPath) {
const target = resolveSafe(relPath);
const stat = fs.statSync(target.abs);
if (!stat.isFile()) throw new Error('Not a file');
if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed');
if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed');
return { name: path.basename(target.abs), content: fs.readFileSync(target.abs, 'utf8') };
},
searchCode(query, opts = {}) {
const q = String(query || '').trim();
if (q.length < 2) return { query: q, results: [], truncated: false, scanned: 0 };
const wanted = String(opts.ext || '').trim().toLowerCase();
const max = Math.min(Math.max(parseInt(opts.max, 10) || SEARCH_MAX, 1), SEARCH_MAX);
const needle = q.toLowerCase();
const results = [];
let scanned = 0;
let truncated = false;
for (const root of ALLOWED_ROOTS) {
if (truncated) break;
walkFiles(root, (rel, name) => {
if (!isViewable(name)) return true;
if (wanted && extOf(name) !== wanted) return true;
let stat;
try { stat = fs.statSync(path.resolve(ROOT, rel)); } catch (_) { return true; }
if (stat.size > MAX_VIEW_BYTES) return true;
scanned++;
let content;
try { content = fs.readFileSync(path.resolve(ROOT, rel), 'utf8'); } catch (_) { return true; }
if (content.toLowerCase().indexOf(needle) < 0) return true;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().indexOf(needle) < 0) continue;
results.push({ path: rel, line: i + 1, text: lines[i].trim().slice(0, SEARCH_SNIPPET) });
if (results.length >= max) { truncated = true; return false; }
}
return true;
});
}
return { query: q, results, truncated, scanned };
},
moduleMap() {
const cfg = getConfig();
const mods = Object.keys((cfg && cfg.modules) || {}).map(k => k.replace(/Mod$/, '')).sort();
let backend = '';
try { backend = fs.readFileSync(path.join(ROOT, 'src', 'backend', 'backend.js'), 'utf8'); } catch (_) { backend = ''; }
const modelFiles = fs.existsSync(path.join(ROOT, 'src', 'models')) ? fs.readdirSync(path.join(ROOT, 'src', 'models')) : [];
const viewFiles = fs.existsSync(path.join(ROOT, 'src', 'views')) ? fs.readdirSync(path.join(ROOT, 'src', 'views')) : [];
const stem = (s) => String(s || '').toLowerCase().replace(/s$/, '');
const pick = (files, name, suffixes) => {
for (const suf of suffixes) {
const exact = files.find(f => f.toLowerCase() === `${name.toLowerCase()}${suf}`);
if (exact) return exact;
}
const singular = name.replace(/s$/, '');
for (const suf of suffixes) {
const short = files.find(f => f.toLowerCase() === `${singular.toLowerCase()}${suf}`);
if (short) return short;
}
const target = stem(name);
return files.find(f => {
const suf = suffixes.find(x => f.toLowerCase().endsWith(x));
if (!suf) return false;
const base = stem(f.slice(0, f.length - suf.length));
return base.startsWith(target) || target.startsWith(base);
}) || null;
};
const testsIndex = new Map();
const modsDir = path.join(ROOT, 'test', 'mods');
const indexTests = (dir, prefix) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
for (const e of entries) {
if (!e.isDirectory()) continue;
const rel = `${prefix}/${e.name}`;
if (!testsIndex.has(e.name)) testsIndex.set(e.name, rel);
indexTests(path.join(dir, e.name), rel);
}
};
indexTests(modsDir, 'test/mods');
return mods.map(name => {
const alias = MODULE_ALIASES[name] || {};
const routePaths = Array.isArray(alias.paths) && alias.paths.length ? alias.paths : [`/${name}`];
let routes = 0;
for (const routePath of routePaths) {
const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\.(get|post)\\(\\s*["'\`]${escaped}(?=["'/?])`, 'g');
routes += backend ? (backend.match(re) || []).length : 0;
}
const model = alias.model !== undefined ? alias.model : (pick(modelFiles, name, ['_model.js', '_models.js']) ? `src/models/${pick(modelFiles, name, ['_model.js', '_models.js'])}` : null);
const view = alias.view !== undefined ? alias.view : (pick(viewFiles, name, ['_view.js', '_views.js']) ? `src/views/${pick(viewFiles, name, ['_view.js', '_views.js'])}` : null);
return {
name,
enabled: (cfg.modules || {})[`${name}Mod`] !== 'off',
model,
view,
routes,
tests: testsIndex.get(name) || testsIndex.get(name.replace(/s$/, '')) || (() => {
const target = stem(name);
for (const [dir, rel] of testsIndex) {
const base = stem(dir);
if (base.startsWith(target) || target.startsWith(base)) return rel;
}
return null;
})()
};
});
},
projectStats() {
const now = Date.now();
if (statsCache && now - statsCacheAt < STATS_TTL) return statsCache;
let files = 0;
let lines = 0;
let bytes = 0;
for (const root of ALLOWED_ROOTS) {
walkFiles(root, (rel, name) => {
if (!isViewable(name)) return true;
try {
const abs = path.resolve(ROOT, rel);
const stat = fs.statSync(abs);
if (stat.size > MAX_VIEW_BYTES) return true;
files++;
bytes += stat.size;
lines += fs.readFileSync(abs, 'utf8').split('\n').length;
} catch (_) {}
return true;
});
}
const cfg = getConfig();
const modules = Object.keys((cfg && cfg.modules) || {}).length;
const translations = path.join(ROOT, 'src', 'client', 'assets', 'translations');
const languages = fs.existsSync(translations) ? fs.readdirSync(translations).filter(f => /^oasis_[a-z]{2}\.js$/.test(f)).length : 0;
const testsDir = path.join(ROOT, 'test', 'mods');
const testMods = fs.existsSync(testsDir) ? fs.readdirSync(testsDir).length : 0;
let version = '';
try { version = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'src', 'server', 'package.json'), 'utf8')).version || ''); } catch (_) { version = ''; }
statsCache = { files, lines, bytes, modules, languages, testMods, version, node: process.version };
statsCacheAt = now;
return statsCache;
}
};