docs: Plan P2603.01 Automatización Zoom y actualizaciones P2601.09

This commit is contained in:
Ricardo Monla
2026-03-31 18:03:21 -03:00
parent 160da47f96
commit 1fd213173e
72 changed files with 19694 additions and 460 deletions
@@ -4,24 +4,21 @@ const path = require('path');
module.exports = () => {
const router = require('express').Router();
const DOCS_BASE = path.resolve('/app/docs/proy');
const DOCS_BASE = path.resolve('/app/docs');
// GET /api/docs/:proyecto/:archivo — Servir .md crudo
// Ejemplo: /api/docs/P2601_Dasuten/P2601.09_DASUTEN-sin-DC.md
router.get('/:proyecto/:archivo', (req, res) => {
const { proyecto, archivo } = req.params;
// GET /api/docs/* — Servir .md crudo
// Ejemplo: /api/docs/proy/P2601_Dasuten/P2601.09_DASUTEN-sin-DC.md
router.get('/*', (req, res) => {
if (req.path === '/') return; // Se maneja abajo
const fileRelPath = req.params[0];
// Sanitizar: solo permitir caracteres seguros
if (!/^[A-Za-z0-9._-]+$/.test(proyecto) || !/^[A-Za-z0-9._-]+$/.test(archivo)) {
return res.status(400).json({ error: 'Nombre de archivo inválido' });
// Sanitizar y verificar extensión
if (!fileRelPath || !fileRelPath.endsWith('.md') || fileRelPath.includes('..')) {
return res.status(400).json({ error: 'Ruta de archivo inválida' });
}
// Solo permitir .md
if (!archivo.endsWith('.md')) {
return res.status(400).json({ error: 'Solo se permiten archivos .md' });
}
const filePath = path.join(DOCS_BASE, proyecto, archivo);
const filePath = path.join(DOCS_BASE, fileRelPath);
// Verificar que no escapa del directorio base
if (!filePath.startsWith(DOCS_BASE)) {
@@ -38,8 +35,9 @@ module.exports = () => {
}
res.json({
proyecto,
archivo,
doc_path: fileRelPath,
proyecto: path.basename(path.dirname(fileRelPath)),
archivo: path.basename(fileRelPath),
content,
lastModified: fs.statSync(filePath).mtime.toISOString()
});
@@ -165,10 +165,11 @@ function App() {
// Doc viewer modal
const [docModal, setDocModal] = useState<{ show: boolean; title: string; content: string; loading: boolean }>({ show: false, title: '', content: '', loading: false });
const openDocModal = React.useCallback(async (proyecto: string, archivo: string) => {
const openDocModal = React.useCallback(async (fileRelPath: string) => {
const archivo = fileRelPath.split('/').pop() || '';
setDocModal({ show: true, title: archivo.replace('.md', ''), content: '', loading: true });
try {
const res = await fetch(`${API_URL}/docs/${proyecto}/${archivo}`);
const res = await fetch(`${API_URL}/docs/${fileRelPath}`);
const data = await res.json();
if (data.content) {
setDocModal({ show: true, title: archivo.replace('.md', ''), content: data.content, loading: false });
@@ -185,11 +186,12 @@ function App() {
a: ({ node, ...props }: any) => {
const url = props.href || '';
// Si es un link a un .md en ../proy/, abrir modal de documento
if (url.includes('../proy/') && url.endsWith('.md')) {
const parts = url.split('/');
const archivo = parts.pop() || '';
const proyecto = parts.pop() || '';
// Si es un link a un .md en ../proy/ o ../ambito/, abrir modal de documento
if ((url.includes('../proy/') || url.includes('../ambito/')) && url.endsWith('.md')) {
// Obtener todo lo que viene después de "docs/" basándonos en si fue proy/ o ambito/
const linkType = url.includes('../proy/') ? '../proy/' : '../ambito/';
const docsIndex = url.indexOf(linkType) + 3; // +3 skips "../" so we start at "proy/" or "ambito/"
const fileRelPath = url.substring(docsIndex); // ex: proy/P2601_Dasuten/P26...
return (
<a
@@ -198,10 +200,10 @@ function App() {
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
openDocModal(proyecto, archivo);
openDocModal(fileRelPath);
}}
className="desc-link"
title={`Ver ${archivo} en la web`}
title={`Ver ${url.split('/').pop()} en la web`}
style={{ cursor: 'pointer', borderBottom: '1px dashed var(--primary)' }}
>
{props.children}
@@ -879,23 +881,29 @@ function App() {
{/* Agrupar entradas por ÁMBITO en lugar de por nodo */}
{(() => {
// Agrupar por ámbito
const porAmbito = {};
// Agrupar por ámbito a nivel de cada entrada
const porAmbito: Record<string, any> = {};
bitacora?.nodos?.forEach(nodoGroup => {
const ambito = nodoGroup.ambito_nombre || 'Sin Ámbito';
if (!porAmbito[ambito]) {
porAmbito[ambito] = {
ambito: ambito,
nodos: new Set(),
entradas: [],
resumenes: []
};
}
porAmbito[ambito].entradas.push(...nodoGroup.entradas);
porAmbito[ambito].nodos.add(nodoGroup.nodo);
if (nodoGroup.resumen) {
porAmbito[ambito].resumenes.push({ nodo: nodoGroup.nodo, resumen: nodoGroup.resumen });
// Si el nodo tiene resumen genérico, lo guardamos en su primer ámbito disponible o "Global"
const defaultAmbito = nodoGroup.entradas.length > 0 ? (nodoGroup.entradas[0].ambito_nombre || nodoGroup.ambito_nombre || 'Sin Ámbito') : 'Sin Ámbito';
if (!porAmbito[defaultAmbito]) porAmbito[defaultAmbito] = { ambito: defaultAmbito, nodos: new Set(), entradas: [], resumenes: [] };
porAmbito[defaultAmbito].resumenes.push({ nodo: nodoGroup.nodo, resumen: nodoGroup.resumen });
}
nodoGroup.entradas.forEach(e => {
const ambito = e.ambito_nombre || nodoGroup.ambito_nombre || 'Sin Ámbito';
if (!porAmbito[ambito]) {
porAmbito[ambito] = {
ambito: ambito,
nodos: new Set(),
entradas: [],
resumenes: []
};
}
porAmbito[ambito].entradas.push(e);
porAmbito[ambito].nodos.add(nodoGroup.nodo || e.nodo_nombre);
});
});
return Object.values(porAmbito).map((ambitoGroup, idx) => (