Feat(ADN): Implementado y ejecutado archivar:md para mover Markdowns obsoletos a histórico DB-First

This commit is contained in:
Ricardo Monla
2026-03-10 16:02:03 -03:00
parent 8aa1532396
commit 8d27a8958e
74 changed files with 3195 additions and 2073 deletions
+72
View File
@@ -0,0 +1,72 @@
# frozen_string_literal: true
# tools/bitacoras-db/cli/exportar_todo.rb
# ==========================================
# Exporta TODAS las bitácoras de la DB a archivos .md
# Uso: ruby tools/bitacoras-db/cli/exportar_todo.rb [--dir DIRECTORIO]
# — o vía enjambre —
# ./tools/adn/run db exportar:md --todo
require 'date'
require 'optparse'
require_relative '../core/bitacora_db'
require_relative '../exporters/md_exporter'
module BitacorasDB
module CLI
class ExportarTodo
def initialize(bitacoras_dir, logger = nil)
@bitacoras_dir = bitacoras_dir
@logger = logger || default_logger
@resultados = { ok: 0, error: 0 }
end
def ejecutar
@logger.info("Exportando todas las bitácoras de DB a MD...")
BitacorasDB::BitacoraDB.with_connection do |db|
bitacoras = db.list_bitacoras(limit: 500)
if bitacoras.empty?
@logger.info("No hay bitácoras en la base de datos.")
return
end
exporter = BitacorasDB::Exporters::MDExporter.new(db, @logger)
bitacoras.each do |b|
fecha = b['fecha'].to_s
fecha = fecha.split('T').first if fecha.include?('T')
archivo = File.join(@bitacoras_dir, "#{fecha}.md")
if exporter.exportar_bitacora(fecha, archivo)
@resultados[:ok] += 1
else
@resultados[:error] += 1
end
end
end
@logger.info("📊 Exportación masiva completada:")
@logger.info(" ✅ Exitosas: #{@resultados[:ok]}")
@logger.info(" ❌ Errores: #{@resultados[:error]}") if @resultados[:error] > 0
end
private
def default_logger
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
def exito(msg); puts "[OK] #{msg}"; end
end.new
end
end
end
end
# Ejecución directa
if __FILE__ == $0
bitacoras_dir = File.expand_path('../../../docs/bitacoras', __dir__)
BitacorasDB::CLI::ExportarTodo.new(bitacoras_dir).ejecutar
end
@@ -0,0 +1,121 @@
# frozen_string_literal: true
# tools/bitacoras-db/cli/verificar_migracion.rb
# ================================================
# Verifica que todas las bitácoras MD existentes estén correctamente
# migradas en la base de datos. Reporta faltantes y discrepancias.
#
# Uso:
# ruby tools/bitacoras-db/cli/verificar_migracion.rb
# — o vía enjambre —
# ./tools/adn/run db verificar
require 'date'
require_relative '../core/bitacora_db'
module BitacorasDB
module CLI
class VerificarMigracion
COLORES = {
reset: "\e[0m", bold: "\e[1m", red: "\e[31m",
green: "\e[32m", yellow: "\e[33m", cyan: "\e[36m", dim: "\e[2m"
}.freeze
def initialize(bitacoras_dir)
@bitacoras_dir = bitacoras_dir
@resultados = { ok: [], sin_entradas: [], no_migrada: [], sin_md: [] }
end
def ejecutar
puts "#{COLORES[:cyan]}🔍 Verificación de migración MD ↔ DB#{COLORES[:reset]}\n\n"
archivos_md = Dir.glob(File.join(@bitacoras_dir, '*.md')).sort
puts "#{COLORES[:dim]}Archivos MD encontrados: #{archivos_md.size}#{COLORES[:reset]}"
BitacorasDB::BitacoraDB.with_connection do |db|
# 1. Validar cada MD contra la DB
archivos_md.each do |archivo|
fecha = File.basename(archivo, '.md')
bitacora = db.find_bitacora_by_fecha(fecha)
unless bitacora
@resultados[:no_migrada] << fecha
next
end
entradas = db.execute(
"SELECT COUNT(*) as total FROM bitacoras.entradas WHERE bitacora_id = $1",
[bitacora['id']]
)
total = entradas.first['total'].to_i
if total > 0
@resultados[:ok] << { fecha: fecha, entradas: total }
else
@resultados[:sin_entradas] << fecha
end
end
# 2. Bitácoras en DB que no tienen MD
todas_db = db.list_bitacoras(limit: 500)
fechas_md = archivos_md.map { |a| File.basename(a, '.md') }
todas_db.each do |b|
f = b['fecha'].to_s
f = f.split('T').first if f.include?('T') # por si viene con timestamp
@resultados[:sin_md] << f unless fechas_md.include?(f)
end
end
mostrar_resultados
end
private
def mostrar_resultados
puts "\n#{COLORES[:bold]}📊 Resultado de verificación:#{COLORES[:reset]}\n"
# OK
puts "#{COLORES[:green]}✅ Migradas correctamente: #{@resultados[:ok].size}#{COLORES[:reset]}"
@resultados[:ok].each do |r|
puts " #{COLORES[:dim]}#{r[:fecha]} (#{r[:entradas]} entradas)#{COLORES[:reset]}"
end
# Sin entradas
if @resultados[:sin_entradas].any?
puts "\n#{COLORES[:yellow]}⚠ En DB pero sin entradas: #{@resultados[:sin_entradas].size}#{COLORES[:reset]}"
@resultados[:sin_entradas].each { |f| puts " #{f}" }
end
# No migradas
if @resultados[:no_migrada].any?
puts "\n#{COLORES[:red]}❌ No migradas (MD existe, DB no): #{@resultados[:no_migrada].size}#{COLORES[:reset]}"
@resultados[:no_migrada].each { |f| puts " #{f}" }
puts " #{COLORES[:dim]}Ejecuta: ./tools/adn/run db migrar:md FECHA#{COLORES[:reset]}"
end
# Sin MD
if @resultados[:sin_md].any?
puts "\n#{COLORES[:cyan]} En DB sin archivo MD: #{@resultados[:sin_md].size}#{COLORES[:reset]}"
@resultados[:sin_md].each { |f| puts " #{f}" }
puts " #{COLORES[:dim]}Ejecuta: ./tools/adn/run db exportar:md FECHA#{COLORES[:reset]}"
end
total = @resultados[:ok].size + @resultados[:sin_entradas].size
total_md = @resultados[:ok].size + @resultados[:sin_entradas].size + @resultados[:no_migrada].size
pct = total_md > 0 ? ((@resultados[:ok].size.to_f / total_md) * 100).round(1) : 0
puts "\n#{COLORES[:bold]}📈 Cobertura: #{pct}% (#{@resultados[:ok].size}/#{total_md})#{COLORES[:reset]}"
if @resultados[:no_migrada].empty? && @resultados[:sin_entradas].empty?
puts "#{COLORES[:green]}#{COLORES[:bold]}🎉 ¡Migración completa! Todas las bitácoras están en la DB.#{COLORES[:reset]}"
end
end
end
end
end
# Ejecución directa
if __FILE__ == $0
bitacoras_dir = File.expand_path('../../../docs/bitacoras', __dir__)
BitacorasDB::CLI::VerificarMigracion.new(bitacoras_dir).ejecutar
end
+130
View File
@@ -0,0 +1,130 @@
# frozen_string_literal: true
require 'date'
module BitacorasDB
module Exporters
class MDExporter
def initialize(db, logger = nil)
@db = db
@logger = logger || default_logger
end
def exportar_bitacora(fecha, ruta_destino)
@logger.info("Exportando bitácora #{fecha} a #{ruta_destino}...")
bitacora = @db.find_bitacora_by_fecha(fecha)
unless bitacora
@logger.error("No se encontró la bitácora para la fecha: #{fecha}")
return false
end
bitacora_id = bitacora['id']
# Obtener datos estructurados (incluyendo lo nuevo)
entradas = @db.execute("
SELECT e.*, n.nombre as nodo_nombre
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE e.bitacora_id = $1
ORDER BY n.nombre, e.inicio ASC
", [bitacora_id])
gestion = @db.execute("
SELECT g.*, n.nombre as nodo_nombre
FROM bitacoras.gestion g
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
WHERE g.bitacora_id = $1 OR g.activo = true
ORDER BY g.tipo, g.orden
", [bitacora_id]) rescue [] # Por si la tabla no existe aún en otros entornos
resumenes = @db.execute("
SELECT r.*, n.nombre as nodo_nombre
FROM bitacoras.resumen_nodos r
JOIN bitacoras.nodos n ON r.nodo_id = n.id
WHERE r.bitacora_id = $1
", [bitacora_id]) rescue []
# Generar contenido Markdown
contenido = generar_markdown(fecha, bitacora['resumen'], gestion, resumenes, entradas)
File.write(ruta_destino, contenido)
@logger.info("✅ Exportación completada con éxito.")
true
rescue => e
@logger.error("Error exportando bitácora: #{e.message}")
false
end
private
def generar_markdown(fecha, resumen_gen, gestion, resumenes, entradas)
y, m, d = fecha.to_s.split('-')
md = String.new("<!-- GENERADO AUTOMÁTICAMENTE por adn db exportar:md — No editar manualmente -->\n")
md << "# Bitácora de Operaciones - #{d}/#{m}/#{y}\n\n"
if gestion && gestion.ntuples > 0
md << "## 📋 Control de Gestión\n\n"
pendientes = gestion.select { |g| g['tipo'] == 'pendiente' }
if pendientes.any?
md << "### 📝 Resumen de Actividades\n" # Formato ADN
md << "| NODO | PENDIENTES |\n| :--- | :--- |\n"
pendientes.each do |p|
md << "| #{p['nodo_nombre'] || '—'} | #{p['detalle']} |\n"
end
md << "\n"
end
en_proceso = gestion.select { |g| g['tipo'] == 'proceso' }
if en_proceso.any?
md << "### ⏳ En Proceso\n"
md << "| NODO | ACTIVIDAD |\n| :--- | :--- |\n"
en_proceso.each do |p|
md << "| #{p['nodo_nombre'] || '—'} | #{p['detalle']} |\n"
end
md << "\n"
end
end
if resumenes && resumenes.ntuples > 0
md << "### 📝 Resumen Integral\n"
md << "| NODO | RESUMEN INTEGRAL |\n| :--- | :--- |\n"
resumenes.each do |r|
md << "| #{r['nodo_nombre']} | #{r['resumen']} |\n"
end
md << "\n"
end
md << "## 📂 Actividades Detalladas\n\n"
por_nodo = entradas.group_by { |e| e['nodo_nombre'] || 'sin-nodo' }
por_nodo.each do |nodo, entries|
md << "### #{nodo}\n\n"
md << "| I | F | Descripción | E |\n"
md << "| :--- | :--- | :--- | :--- |\n"
entries.each do |e|
inicio = e['inicio'] ? e['inicio'][0..4] : '-'
fin = e['fin'] ? e['fin'][0..4] : '-'
ia = e['es_ia'] == 't' ? '(IA) ' : ''
estado = "[#{e['modo']}] #{e['estado']}"
md << "| #{inicio} | #{fin} | #{ia}#{e['descripcion']} | #{estado} |\n"
end
md << "\n"
end
md << "---\n"
md << "<!-- GENERADO AUTOMÁTICAMENTE desde PostgreSQL el #{Time.now.strftime('%Y-%m-%d %H:%M')} -->\n"
md << "<!-- Fuente de verdad: DB. Regenerar con: adn db exportar:md #{fecha} -->\n"
md
end
def default_logger
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
end.new
end
end
end
end
+100 -38
View File
@@ -31,6 +31,7 @@
require 'date'
require 'time'
require 'yaml'
require 'digest'
module BitacorasDB
module Importers
@@ -43,14 +44,16 @@ module BitacorasDB
'⏸️' => '⏸️',
'⏳' => '⏳',
'👁️' => '⏳', # Sincronización mapea a ⏳
'➡️' => '⏳' # Tarea continúa en otro día
'➡️' => '⏳', # Tarea continúa en otro día
'🎭' => '✅' # Evento/Servicio
}.freeze
# Modos posibles en archivos .md
MODOS_MD = {
'[P]' => 'P', # Presencial
'[R]' => 'R', # Remoto
'[P/R]' => 'P' # Presencial/Remoto -> Presencial por defecto
'[P/R]' => 'P', # Presencial/Remoto -> Presencial por defecto
'[S]' => 'S' # Servicio (No nodo técnico)
}.freeze
# Tipos de nodo inferidos del nombre
@@ -61,6 +64,7 @@ module BitacorasDB
/^pcv-/ => 'vm',
/^pc-/ => 'pc',
/^srvv-/ => 'vm',
/^dtic-/ => 'servicio',
/-pmox\d+$/ => 'hipervisor',
/-xen\d+$/ => 'hipervisor'
}.freeze
@@ -264,7 +268,7 @@ module BitacorasDB
end
# Detectar inicio de tabla de entradas
if linea.match?(/^\| I \| F \| Descripción \| E \|$/)
if linea.match?(/^\| I \| F \| Descripción \| (?:J \| )?E \|$/)
en_tabla_entradas = true
encabezados_tabla = linea.split('|').map(&:strip).reject(&:empty?)
entradas_tabla = []
@@ -343,20 +347,32 @@ module BitacorasDB
# Parsea línea de tema (ej: "✅ - SEC01 - Refactorización ns8-candados")
def parsear_tema(linea)
# Patrón: estado - id - título (id puede contener puntos, como ADN01.1)
match = linea.match(/^([✅❌⚠️⏸️⏳👁️➡️]+)\s*-\s*([A-Z0-9.]+)\s*-\s*(.+)$/)
return nil unless match
# Intentar formato estándar: estado - id - título (ej: ✅ - ADN01 - Título)
match_std = linea.match(/^([^\s-]+)\s*-\s*([A-Z0-9.]+)\s*-\s*(.+)$/)
if match_std
return {
id_tema: match_std[2],
titulo: match_std[3].strip,
estado: ESTADOS_MD[match_std[1]] || '⏳',
descripcion: match_std[3].strip
}
end
estado = match[1]
id_tema = match[2]
titulo = match[3].strip
# Intentar formato simplificado: estado - título (ej: 🎭 - Servicio Auditorio)
match_simple = linea.match(/^([^\s-]+)\s*-\s*(.+)$/)
if match_simple
titulo = match_simple[2].strip
# Generar un ID determinista basado en el título para estabilidad
id_gen = "GEN" + Digest::MD5.hexdigest(titulo)[0..5].upcase
return {
id_tema: id_gen,
titulo: titulo,
estado: ESTADOS_MD[match_simple[1]] || '⏳',
descripcion: titulo
}
end
{
id_tema: id_tema,
titulo: titulo,
estado: ESTADOS_MD[estado] || '⏳',
descripcion: titulo # Por defecto, usar título como descripción
}
nil
end
# Parsea líneas de tabla de entradas
@@ -370,6 +386,7 @@ module BitacorasDB
when 'i' then columnas[:inicio] = idx
when 'f' then columnas[:fin] = idx
when 'descripción' then columnas[:descripcion] = idx
when 'j' then columnas[:jornada] = idx
when 'e' then columnas[:estado] = idx
end
end
@@ -379,28 +396,39 @@ module BitacorasDB
next if linea.match?(/^[\|\s:-]+$/) # Solo pipes, espacios, guiones y dos puntos
next if linea.match?(/^\|?\s*:?-+:?\s*\|/) # Separadores con :---, ---, :-:, etc.
celdas = linea.split('|').map(&:strip).reject(&:empty?)
# Limpiar pipes iniciales y finales antes de split
linea_limpia = linea.strip.gsub(/^\||\|$/, '')
celdas = linea_limpia.split('|').map(&:strip)
next if celdas.length < 3 # Mínimo: inicio, descripción, estado
# Validar que la celda de inicio no sea un separador
# Validar que la celda de inicio tenga contenido
valor_inicio = celdas[columnas[:inicio]]
next if valor_inicio.nil? || valor_inicio.match?(/^:?-+:?$/)
next if valor_inicio.nil? || valor_inicio.empty? || valor_inicio.match?(/^:?-+:?$/)
fin_valor = nil
if columnas[:fin] && celdas[columnas[:fin]] != '-'
fin_valor = celdas[columnas[:fin]]
if columnas[:fin] && !celdas[columnas[:fin]].nil?
raw_fin = celdas[columnas[:fin]].strip
fin_valor = raw_fin if !raw_fin.empty? && raw_fin != '-'
end
entrada = {
inicio: celdas[columnas[:inicio]],
fin: fin_valor,
inicio: valor_inicio.gsub(/\s*\(.*?\)\s*/, '').strip,
fin: fin_valor ? fin_valor.gsub(/\s*\(.*?\)\s*/, '').strip : nil,
descripcion: celdas[columnas[:descripcion]],
estado_modo: celdas[columnas[:estado]]
}
# Parsear estado y modo (ej: "[P] ✅")
if entrada[:estado_modo]
estado_modo = parsear_estado_modo(entrada[:estado_modo])
# Parsear estado y modo
if columnas[:jornada] && columnas[:estado]
# Nuevo formato: J y E separados
# Unimos temporalmente para usar parsear_estado_modo existente
campo_combinado = "[#{celdas[columnas[:jornada]]}] #{celdas[columnas[:estado]]}"
estado_modo = parsear_estado_modo(campo_combinado)
entrada[:estado] = estado_modo[:estado]
entrada[:modo] = estado_modo[:modo]
elsif columnas[:estado]
# Formato viejo o solo E
estado_modo = parsear_estado_modo(celdas[columnas[:estado]])
entrada[:estado] = estado_modo[:estado]
entrada[:modo] = estado_modo[:modo]
end
@@ -456,6 +484,16 @@ module BitacorasDB
bitacora_id = bitacora['id']
# 1b. Si se solicita sobrescribir, limpiar entradas previas de esta bitácora
if opciones[:sobrescribir]
@logger.info("Limpiando datos previos de la bitácora #{estructura[:fecha]}...")
# Borrar entradas primero (FK)
@db.execute("DELETE FROM bitacoras.entradas WHERE bitacora_id = $1", [bitacora_id])
# Borrar temas (FK)
@db.execute("DELETE FROM bitacoras.temas WHERE bitacora_id = $1", [bitacora_id])
@estadisticas[:datos_limpiados] = true
end
# 2. Procesar cada nodo
estructura[:nodos].each do |nombre_nodo, datos_nodo|
# Buscar o crear nodo
@@ -505,19 +543,43 @@ module BitacorasDB
# Validar campos requeridos
next unless entrada_md[:inicio] && entrada_md[:descripcion]
# Crear entrada en BD
@db.create_entrada(
inicio: entrada_md[:inicio],
fin: entrada_md[:fin],
descripcion: entrada_md[:descripcion],
estado: entrada_md[:estado] || '⏳',
modo: entrada_md[:modo] || 'P',
es_ia: false, # Las importadas de .md no son IA
tema_id: tema_id,
bitacora_id: bitacora_id,
nodo_id: nodo_id
)
@estadisticas[:entradas_creadas] += 1
# Intentar buscar entrada existente para evitar duplicados (Upsert)
# Buscamos por: bitacora_id, nodo_id y hora de inicio (única para un nodo en un día)
entradas_nodo = @db.list_entradas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100)
# Normalizar hora para comparación
inicio_norm = @db.send(:parse_time, entrada_md[:inicio])
existente = entradas_nodo.find { |e| e['inicio'] == inicio_norm }
begin
if existente
# Actualizar si ya existe
@db.update_entrada(existente['id'], {
fin: entrada_md[:fin],
descripcion: entrada_md[:descripcion],
estado: entrada_md[:estado] || '⏳',
modo: entrada_md[:modo] || 'P'
})
@estadisticas[:entradas_actualizadas] ||= 0
@estadisticas[:entradas_actualizadas] += 1
else
# Crear si no existe
@db.create_entrada(
inicio: entrada_md[:inicio],
fin: entrada_md[:fin],
descripcion: entrada_md[:descripcion],
estado: entrada_md[:estado] || '⏳',
modo: entrada_md[:modo] || 'P',
es_ia: false,
tema_id: tema_id,
bitacora_id: bitacora_id,
nodo_id: nodo_id
)
@estadisticas[:entradas_creadas] += 1
end
rescue => e
@logger.error("Error procesando entrada '#{entrada_md[:inicio]} - #{entrada_md[:descripcion]}': #{e.message}")
end
end
end
end