"""
Herramientas para la manipulación y validación de metadatos en archivos Markdown.
Funciones principales:
- validar_metadatos(ruta_archivo): Valida los metadatos de un archivo contra las reglas del proyecto.
- obtener_metadatos(ruta_archivo): Extrae y devuelve los metadatos como un diccionario (valida primero).
Otras herramientas:
- validar_archivo(ruta_archivo): Comprobaciones básicas de existencia y lectura de archivo.
- extraer_bloque_metadatos(contenido): Extrae el bloque de texto YAML crudo del archivo.
"""
import sys
import json
import re
from pathlib import Path
from pydantic import BaseModel
class ModeloSalida(BaseModel):
status: bool
message: str
def validar_archivo(ruta_archivo: str) -> tuple[bool, str, str]:
ruta = Path(ruta_archivo)
if not ruta.exists():
return False, f"Error: El archivo no existe: {ruta_archivo}", ""
try:
contenido = ruta.read_text(encoding='utf-8')
except Exception as e:
return False, f"Error al leer el archivo: {str(e)}", ""
if not contenido.strip():
return False, "Error: El archivo está vacío.", ""
return True, "", contenido
def extraer_bloque_metadatos(contenido: str) -> tuple[bool, str, list[str]]:
coincidencia = re.match(r'^---\s*\n(.*?)\n---\s*(?:\n|$)', contenido, re.DOTALL)
if not coincidencia:
return False, "Error: No se encontró un bloque de metadatos válido (YAML frontmatter) al inicio del archivo.", []
contenido_metadatos = coincidencia.group(1)
lineas_metadatos = contenido_metadatos.splitlines()
if not lineas_metadatos or not any(linea.strip() for linea in lineas_metadatos):
return False, "Error: Bloque de metadatos vacío.", []
return True, "", lineas_metadatos
def _es_elemento_lista(linea: str) -> bool:
return linea.startswith(' -')
def _validar_elemento_lista(linea: str, num_linea: int, padre_lista: str) -> ModeloSalida | None:
if not linea.startswith(' - '):
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Indentación incorrecta en '{padre_lista}'. Usar 2 espacios y un espacio tras el guión."
)
if not linea.strip()[2:]:
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Elemento de lista vacío."
)
return None
def _validar_formato_clave(clave: str, valor: str, num_linea: int, linea: str) -> ModeloSalida | None:
if not clave.islower():
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Clave '{clave}' debe ser minúscula."
)
if not valor.startswith(' ') and valor != '':
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Falta espacio tras ':' en '{linea}'."
)
if clave in ['layout', 'title', 'date']:
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Campo prohibido '{clave}'. Use español."
)
return None
def _validar_orden_campos(clave: str, indice_actual: int, orden_esperado: list[str], num_linea: int) -> tuple[ModeloSalida | None, int]:
try:
nuevo_indice = orden_esperado.index(clave)
except ValueError:
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Campo desconocido '{clave}'."
), indice_actual
if nuevo_indice <= indice_actual:
anterior = orden_esperado[indice_actual] if indice_actual != -1 else "Inicio"
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Campo '{clave}' fuera de orden. Se esperaba después de '{anterior}' (o posterior)."
), indice_actual
return None, nuevo_indice
def _verificar_campos_requeridos(campos_encontrados: set) -> ModeloSalida | None:
requeridos = ['titulo', 'carpeta', 'descripcion']
for campo in requeridos:
if campo not in campos_encontrados:
return ModeloSalida(
status=False,
message=f"Error: Falta campo obligatorio '{campo}'."
)
return None
def _verificar_requisitos_condicionales(campos_encontrados: set) -> ModeloSalida | None:
es_personaje = 'nombre' in campos_encontrados or 'facciones' in campos_encontrados
if es_personaje:
if 'nombre' not in campos_encontrados:
return ModeloSalida(
status=False,
message="Error: Personaje detectado pero falta 'nombre'."
)
if 'facciones' not in campos_encontrados:
return ModeloSalida(
status=False,
message="Error: Personaje detectado pero falta 'facciones'."
)
return None
def validar_contenido_metadatos(lineas_metadatos: list[str]) -> ModeloSalida:
orden_esperado = [
'titulo', 'slug', 'carpeta', 'descripcion',
'tags', 'region', 'fecha', 'nombre', 'facciones', 'spoilers'
]
indice_campo_actual = -1
en_bloque_lista = False
padre_lista = None
campos_encontrados = set()
for i, linea in enumerate(lineas_metadatos):
num_linea = i + 2
if not linea.strip():
continue
if _es_elemento_lista(linea):
if not en_bloque_lista:
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Elemento de lista sin campo padre declarado o indentación incorrecta."
)
error = _validar_elemento_lista(linea, num_linea, padre_lista)
if error:
return error
continue
elif linea.startswith(' '):
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Indentación inválida o línea mal formada."
)
en_bloque_lista = False
padre_lista = None
if ':' not in linea:
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: Línea sin formato clave:valor."
)
clave, valor = linea.split(':', 1)
clave = clave.strip()
error = _validar_formato_clave(clave, valor, num_linea, linea)
if error:
return error
error, indice_campo_actual = _validar_orden_campos(clave, indice_campo_actual, orden_esperado, num_linea)
if error:
return error
campos_encontrados.add(clave)
if clave in ['tags', 'facciones', 'spoilers']:
if valor.strip() != '':
return ModeloSalida(
status=False,
message=f"Error línea {num_linea}: '{clave}' debe ser una lista vertical (dejar vacío tras ':')."
)
en_bloque_lista = True
padre_lista = clave
error = _verificar_campos_requeridos(campos_encontrados)
if error:
return error
error = _verificar_requisitos_condicionales(campos_encontrados)
if error:
return error
return ModeloSalida(status=True, message="metadatos correctos")
def validar_metadatos(ruta_archivo: str) -> ModeloSalida:
ok, msg, contenido = validar_archivo(ruta_archivo)
if not ok:
return ModeloSalida(status=False, message=msg)
ok, msg, lineas_metadatos = extraer_bloque_metadatos(contenido)
if not ok:
return ModeloSalida(status=False, message=msg)
return validar_contenido_metadatos(lineas_metadatos)
def _parsear_elemento_lista(linea: str) -> str:
return linea.strip()[2:]
def _parsear_clave_valor(linea: str) -> tuple[str, str]:
clave, valor = linea.split(':', 1)
return clave.strip(), valor.strip()
def obtener_metadatos(ruta_archivo: str) -> dict:
validacion = validar_metadatos(ruta_archivo)
if not validacion.status:
raise ValueError(validacion.message)
_, _, contenido = validar_archivo(ruta_archivo)
_, _, lineas = extraer_bloque_metadatos(contenido)
metadatos = {}
clave_lista_actual = None
for linea in lineas:
if not linea.strip():
continue
if _es_elemento_lista(linea):
if clave_lista_actual:
metadatos[clave_lista_actual].append(_parsear_elemento_lista(linea))
continue
if ':' in linea:
clave, valor = _parsear_clave_valor(linea)
if clave in ['tags', 'facciones', 'spoilers']:
metadatos[clave] = []
clave_lista_actual = clave
else:
metadatos[clave] = valor
clave_lista_actual = None
return metadatos
if __name__ == "__main__":
if len(sys.argv) < 2:
print(ModeloSalida(status=False, message="Error: Se requiere ruta de archivo.").model_dump_json())
sys.exit(1)
ruta_archivo = sys.argv[1]
resultado = validar_metadatos(ruta_archivo)
print(resultado.model_dump_json())
if not resultado.status:
sys.exit(1)
try:
datos = obtener_metadatos(ruta_archivo)
print(json.dumps(datos, ensure_ascii=False))
except Exception as e:
print(json.dumps({"error": str(e)}, ensure_ascii=False))
sys.exit(1)