You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/scripts/db/volcar.mjs

644 lines
19 KiB

/**
* Vuelca el contenido de `src/content/**` a PostgreSQL.
*
* npm run db:volcar # vuelca y comprueba
* npm run db:volcar -- --seco # solo dice qué haría
*
* ## Por qué reutiliza los analizadores del sitio
*
* En vez de volver a leer el frontmatter a mi manera, este script pasa cada
* archivo por `src/lib/content/schema.ts`, que es exactamente lo que hace la
* web hoy. Así lo volcado no puede diferir de lo que se ve: si un valor por
* defecto o una normalización cambian, cambian para los dos. Escribir un
* segundo analizador «parecido» es la forma más fácil de que la base y la web
* cuenten cosas distintas y nadie lo note.
*
* ## Es idempotente
*
* Se puede lanzar las veces que haga falta. Borra lo volcado antes de volver a
* escribirlo, y no toca nada de lo que generan las personas —cuentas, pedidos,
* compras—: esas tablas ni se miran.
*
* ## Todo en una transacción
*
* Si algo falla a mitad, no queda medio catálogo dentro. Y al final comprueba
* que lo escrito coincide con lo leído, porque «no dio error» no es lo mismo
* que «está bien».
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
// Con nombre y no por defecto: js-yaml 4 es CommonJS y su interoperabilidad
// con modulos no expone un `default`.
import { load as leerYaml } from 'js-yaml';
import pg from 'pg';
import {
parsearAlbum,
parsearCancion,
parsearCapitulo,
parsearEntrada,
parsearEstilo,
parsearLegal,
parsearPagina
} from '../../src/lib/content/schema.ts';
import { extraerLineas } from '../../src/lib/content/extracto.ts';
const SECO = process.argv.includes('--seco');
const RAIZ = 'src/content';
/* -------------------------------------------------------------------------
* Leer los archivos
* ---------------------------------------------------------------------- */
/** Separa el frontmatter del cuerpo. */
function partir(texto) {
const lineas = texto.split(/\r?\n/);
if (lineas[0]?.trim() !== '---') return { datos: {}, cuerpo: texto.trim() };
const cierre = lineas.indexOf('---', 1);
if (cierre === -1) return { datos: {}, cuerpo: texto.trim() };
return {
datos: leerYaml(lineas.slice(1, cierre).join('\n')) ?? {},
cuerpo: lineas
.slice(cierre + 1)
.join('\n')
.trim()
};
}
function leerCarpeta(carpeta, parser) {
const dir = path.join(RAIZ, carpeta);
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((n) => n.endsWith('.md'))
.sort()
.map((nombre) => {
const ruta = path.join(dir, nombre);
const { datos, cuerpo } = partir(fs.readFileSync(ruta, 'utf8'));
const slug = nombre.replace(/\.md$/, '');
return { slug, cuerpo, ruta, datos: parser(datos, slug, ruta) };
});
}
/* -------------------------------------------------------------------------
* Escribir
* ---------------------------------------------------------------------- */
const nuevoId = () => crypto.randomUUID();
/** Normaliza una etiqueta a slug, como hace el sitio. */
const slugificar = (nombre) =>
nombre
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
async function volcar(c, contenido, informe) {
const { estilos, albumes, canciones, capitulos, entradas, legales, paginas } = contenido;
/* --- Se borra lo volcado antes de reescribirlo -----------------------
*
* En orden inverso a las dependencias. Las tablas de cuentas, pedidos y
* compras no aparecen aquí ni por asomo: esas las llenan las personas.
*/
const aVaciar = [
'capitulo_acepcion',
'genero_tema',
'cancion_genero',
'genero_seccion',
'genero_alias',
'genero_pais',
'genero_origen',
'genero_metro',
'genero_compas',
'genero_instrumento',
'genero_persona',
'obra_referencia_persona',
'obra_referencia',
'capitulo',
'genero',
'entrada_cancion',
'entrada_etiqueta',
'entrada',
'version_legal',
'documento_legal',
'pagina',
'autoria',
'registro_obra',
'cancion_persona',
'cancion_enlace',
'cancion_etiqueta',
'traduccion',
'video',
'cancion',
'album_enlace',
'credito_album',
'album',
'etiqueta',
'estilo',
'persona'
];
for (const tabla of aVaciar) await c.query(`DELETE FROM "${tabla}"`);
/* --- Personas ---------------------------------------------------------
*
* Salen de los créditos, que hoy son cadenas sueltas repetidas en cada
* archivo. Aquí se unifican por nombre: es justo lo que la tabla existe
* para arreglar.
*/
const personas = new Map();
const persona = async (nombre) => {
const limpio = nombre.trim();
if (personas.has(limpio)) return personas.get(limpio);
const id = nuevoId();
await c.query('INSERT INTO persona (id, slug, nombre, estado) VALUES ($1, $2, $3, $4)', [
id,
slugificar(limpio),
limpio,
'publicado'
]);
personas.set(limpio, id);
return id;
};
/* --- Etiquetas ------------------------------------------------------- */
const etiquetas = new Map();
const etiqueta = async (nombre) => {
const slug = slugificar(nombre);
if (etiquetas.has(slug)) return etiquetas.get(slug);
const id = nuevoId();
await c.query('INSERT INTO etiqueta (id, slug, nombre) VALUES ($1, $2, $3)', [
id,
slug,
nombre.trim()
]);
etiquetas.set(slug, id);
return id;
};
/* --- Estilos ---------------------------------------------------------- */
const idEstilo = new Map();
for (const { datos } of estilos) {
const id = nuevoId();
await c.query(
`INSERT INTO estilo (id, slug, nombre, resumen, orden, principal, estado)
VALUES ($1, $2, $3, $4, $5, $6, 'publicado')`,
[id, datos.slug, datos.nombre, datos.resumen, datos.orden, datos.principal]
);
idEstilo.set(datos.slug, id);
}
informe.estilo = estilos.length;
/* --- Álbumes ----------------------------------------------------------- */
const idAlbum = new Map();
for (const { datos } of albumes) {
const id = nuevoId();
await c.query(
`INSERT INTO album (id, slug, titulo, estilo_id, tipo, fecha, sello, resumen,
destacado, numero_catalogo, formatos, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'publicado')`,
[
id,
datos.slug,
datos.titulo,
idEstilo.get(datos.estilo),
datos.tipo,
datos.fecha,
datos.sello ?? null,
datos.resumen,
datos.destacado,
datos.edicion?.numeroCatalogo ?? null,
datos.edicion?.formatos?.length ? datos.edicion.formatos : null
]
);
idAlbum.set(datos.slug, id);
for (const [plataforma, url] of Object.entries(datos.enlaces ?? {})) {
await c.query('INSERT INTO album_enlace (album_id, plataforma, url) VALUES ($1,$2,$3)', [
id,
plataforma,
url
]);
}
for (const [i, credito] of (datos.edicion?.creditos ?? []).entries()) {
await c.query(
`INSERT INTO credito_album (id, album_id, persona_id, nombre, rol, orden)
VALUES ($1,$2,$3,$4,$5,$6)`,
[nuevoId(), id, await persona(credito.nombre), credito.nombre, credito.rol, i]
);
}
}
informe.album = albumes.length;
/* --- Canciones, y sus versiones como filas hermanas -------------------- */
let versiones = 0;
for (const { datos, cuerpo } of canciones) {
const id = nuevoId();
await c.query(
`INSERT INTO cancion (id, slug, titulo, estilo_id, album_id, numero, duracion_s, ano,
letra, idioma, inspiracion, instrumental, destacada, ala_venta, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'publicado')`,
[
id,
datos.slug,
datos.titulo,
idEstilo.get(datos.estilo),
datos.album ? (idAlbum.get(datos.album) ?? null) : null,
datos.numero,
datos.duracion ?? null,
datos.ano ?? null,
cuerpo || null,
datos.idioma,
datos.inspiracion?.length ? datos.inspiracion.join('\n\n') : null,
datos.instrumental,
datos.destacada,
datos.alaVenta
]
);
/* Créditos: letra, música e intérpretes. */
for (const [rol, nombres] of [
['letra', datos.letra],
['musica', datos.musica],
['interprete', datos.interpretes]
]) {
for (const [i, nombre] of (nombres ?? []).entries()) {
await c.query(
`INSERT INTO cancion_persona (cancion_id, persona_id, rol, orden)
VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`,
[id, await persona(nombre), rol, i]
);
}
}
for (const nombre of datos.etiquetas ?? []) {
await c.query(
`INSERT INTO cancion_etiqueta (cancion_id, etiqueta_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[id, await etiqueta(nombre)]
);
}
for (const [plataforma, url] of Object.entries(datos.enlaces ?? {})) {
await c.query('INSERT INTO cancion_enlace (cancion_id, plataforma, url) VALUES ($1,$2,$3)', [
id,
plataforma,
url
]);
}
for (const [i, v] of (datos.videos ?? []).entries()) {
await c.query(
'INSERT INTO video (id, cancion_id, youtube_id, titulo, orden) VALUES ($1,$2,$3,$4,$5)',
[nuevoId(), id, v.youtube, v.titulo, i]
);
}
if (datos.traduccion) {
await c.query('INSERT INTO traduccion (cancion_id, idioma, texto) VALUES ($1,$2,$3)', [
id,
'es',
datos.traduccion
]);
}
const r = datos.registro ?? {};
if (r.iswc || r.isrc || r.entidad || r.numero || r.editorial) {
await c.query(
`INSERT INTO registro_obra (cancion_id, iswc, isrc, entidad, numero, editorial)
VALUES ($1,$2,$3,$4,$5,$6)`,
[
id,
r.iswc ?? null,
r.isrc ?? null,
r.entidad ?? null,
r.numero ?? null,
r.editorial ?? null
]
);
}
for (const parte of r.reparto ?? []) {
await c.query(
`INSERT INTO autoria (cancion_id, persona_id, rol, porcentaje) VALUES ($1,$2,$3,$4)
ON CONFLICT DO NOTHING`,
[id, await persona(parte.nombre), parte.rol, parte.porcentaje]
);
}
/*
* Las versiones pasan a ser filas hermanas, con los campos COPIADOS de la
* principal. No se heredan al leer: un nulo heredado nunca se sabe si es
* «no lo sé» o «lo mismo de arriba».
*/
for (const [i, v] of (datos.versiones ?? []).entries()) {
await c.query(
`INSERT INTO cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre,
version_orden, duracion_s, ano, letra, idioma, instrumental, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'publicado')`,
[
nuevoId(),
`${datos.slug}-${v.id}`,
datos.titulo,
idEstilo.get(datos.estilo),
id,
v.nombre,
i,
v.duracion ?? null,
datos.ano ?? null,
cuerpo || null,
datos.idioma,
datos.instrumental
]
);
versiones++;
}
}
informe.cancion = canciones.length;
informe.version = versiones;
/* --- Taller: géneros y capítulos ---------------------------------------
*
* Cada ficha del taller da un `genero` y un `capitulo` que lo describe. Los
* campos comparables se quedan de momento en las notas en prosa: pasarlos a
* `metro` y `compas` exige decidir a mano a qué fila corresponde cada frase,
* y eso no lo puede adivinar un volcado.
*/
const idGenero = new Map();
for (const { datos } of capitulos.filter((c) => c.datos.tipo === 'ficha')) {
const id = nuevoId();
const f = datos.ficha;
await c.query(
`INSERT INTO genero (id, slug, nombre, resumen, origen_lugar, tempo, rima,
metrica_nota, compas_nota, rasgos, estado, origen)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'publicado','manual')`,
[
id,
datos.slug,
datos.titulo,
datos.resumen,
f.origen ?? null,
f.tempo ?? null,
f.rima ?? null,
f.metrica ?? null,
f.compas ?? null,
f.rasgos?.length ? f.rasgos : null
]
);
idGenero.set(datos.slug, id);
if (f.estructura) {
await c.query(
`INSERT INTO genero_seccion (id, genero_id, clave, titulo, cuerpo, orden)
VALUES ($1,$2,'estructura','Estructura de las canciones',$3,0)`,
[nuevoId(), id, f.estructura]
);
}
for (const [i, tema] of (f.temas ?? []).entries()) {
await c.query(
`INSERT INTO genero_tema (genero_id, etiqueta_id, orden) VALUES ($1,$2,$3)
ON CONFLICT DO NOTHING`,
[id, await etiqueta(tema), i]
);
}
}
informe.genero = idGenero.size;
void personas;
for (const { datos, cuerpo } of capitulos) {
await c.query(
`INSERT INTO capitulo (id, slug, titulo, tipo, genero_id, orden, resumen, cuerpo, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'publicado')`,
[
nuevoId(),
datos.slug,
datos.titulo,
datos.tipo,
idGenero.get(datos.slug) ?? null,
datos.orden,
datos.resumen,
cuerpo
]
);
}
informe.capitulo = capitulos.length;
/* --- Blog --------------------------------------------------------------- */
for (const { datos, cuerpo } of entradas) {
const id = nuevoId();
await c.query(
`INSERT INTO entrada (id, slug, titulo, fecha, resumen, cuerpo, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[
id,
datos.slug,
datos.titulo,
datos.fecha,
datos.resumen,
cuerpo,
datos.borrador ? 'borrador' : 'publicado'
]
);
for (const nombre of datos.etiquetas ?? []) {
await c.query(
`INSERT INTO entrada_etiqueta (entrada_id, etiqueta_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[id, await etiqueta(nombre)]
);
}
for (const slug of datos.canciones ?? []) {
const { rows } = await c.query('SELECT id FROM cancion WHERE slug = $1', [slug]);
if (rows[0]) {
await c.query(
`INSERT INTO entrada_cancion (entrada_id, cancion_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[id, rows[0].id]
);
} else {
informe.avisos.push(`la entrada «${datos.slug}» cita un tema que no existe: ${slug}`);
}
}
}
informe.entrada = entradas.length;
/* --- Legales, con su primera versión ------------------------------------ */
for (const { datos, cuerpo } of legales) {
const id = nuevoId();
await c.query(
`INSERT INTO documento_legal (id, slug, titulo, resumen, orden) VALUES ($1,$2,$3,$4,$5)`,
[id, datos.slug, datos.titulo, datos.resumen, datos.orden]
);
const version = nuevoId();
await c.query(
`INSERT INTO version_legal (id, documento_id, cuerpo, vigente_desde, resumen_cambios)
VALUES ($1,$2,$3,$4,$5)`,
[version, id, cuerpo, new Date(datos.actualizada), 'Primera versión, volcada del repositorio']
);
await c.query('UPDATE documento_legal SET version_vigente_id = $1 WHERE id = $2', [
version,
id
]);
}
informe.legal = legales.length;
/* --- Páginas sueltas ------------------------------------------------------ */
for (const { datos, cuerpo } of paginas) {
await c.query(
`INSERT INTO pagina (id, slug, titulo, descripcion, cuerpo, estado)
VALUES ($1,$2,$3,$4,$5,'publicado')`,
[nuevoId(), datos.slug, datos.titulo, datos.descripcion, cuerpo]
);
}
informe.pagina = paginas.length;
/*
* Personas y etiquetas se cuentan AL FINAL y desde la base. Apuntar
* `etiquetas.size` justo después de las canciones daba 18 cuando acaban
* siendo 53: después se crean más con los temas de los géneros y con las del
* blog. Un informe con un número que no es el de la tabla es peor que sin
* número, porque parece comprobado.
*/
for (const tabla of ['persona', 'etiqueta']) {
const { rows } = await c.query(`SELECT count(*)::int n FROM "${tabla}"`);
informe[tabla] = rows[0].n;
}
}
/* -------------------------------------------------------------------------
* Comprobar
* ---------------------------------------------------------------------- */
/**
* Comprueba que lo escrito coincide con lo leído.
*
* «No dio error» no es lo mismo que «está bien»: un `INSERT` puede meter un
* nulo donde había un texto sin quejarse.
*/
async function comprobar(c, contenido, informe) {
const fallos = [];
const cuenta = async (tabla, esperado, condicion = '') => {
const { rows } = await c.query(`SELECT count(*)::int n FROM "${tabla}" ${condicion}`);
if (rows[0].n !== esperado) {
fallos.push(`${tabla}: hay ${rows[0].n} y se esperaban ${esperado}`);
}
return rows[0].n;
};
await cuenta('estilo', contenido.estilos.length);
await cuenta('album', contenido.albumes.length);
await cuenta('cancion', contenido.canciones.length, 'WHERE version_de_id IS NULL');
await cuenta('cancion_principal', contenido.canciones.length);
await cuenta('capitulo', contenido.capitulos.length);
await cuenta('entrada', contenido.entradas.length);
await cuenta('documento_legal', contenido.legales.length);
await cuenta('pagina', contenido.paginas.length);
/* La letra de cada tema, carácter a carácter. */
for (const { datos, cuerpo } of contenido.canciones) {
const { rows } = await c.query(
'SELECT titulo, letra, duracion_s, ano FROM cancion WHERE slug = $1',
[datos.slug]
);
const fila = rows[0];
if (!fila) {
fallos.push(`falta la canción ${datos.slug}`);
continue;
}
if (fila.titulo !== datos.titulo) fallos.push(`${datos.slug}: el título no coincide`);
if ((fila.letra ?? '') !== (cuerpo || '')) {
fallos.push(`${datos.slug}: la letra no coincide`);
}
if ((fila.duracion_s ?? null) !== (datos.duracion ?? null)) {
fallos.push(`${datos.slug}: la duración no coincide`);
}
if ((fila.ano ?? null) !== (datos.ano ?? null)) {
fallos.push(`${datos.slug}: el año no coincide`);
}
}
/* Que el extracto siga saliendo igual de la letra guardada. */
const conLetra = contenido.canciones.find((c) => c.cuerpo);
if (conLetra) {
const { rows } = await c.query('SELECT letra FROM cancion WHERE slug = $1', [conLetra.slug]);
const deLaBase = extraerLineas(rows[0].letra ?? '');
const delArchivo = extraerLineas(conLetra.cuerpo);
if (deLaBase.join('|') !== delArchivo.join('|')) {
fallos.push('el extracto de la letra no sale igual desde la base');
}
}
informe.fallos = fallos;
return fallos.length === 0;
}
/* -------------------------------------------------------------------------
* Adelante
* ---------------------------------------------------------------------- */
const contenido = {
estilos: leerCarpeta('estilos', parsearEstilo),
albumes: leerCarpeta('albumes', parsearAlbum),
canciones: leerCarpeta('canciones', parsearCancion),
capitulos: leerCarpeta('taller', parsearCapitulo),
entradas: leerCarpeta('blog', parsearEntrada),
legales: leerCarpeta('legal', parsearLegal),
paginas: leerCarpeta('paginas', parsearPagina)
};
const total = Object.values(contenido).reduce((n, l) => n + l.length, 0);
console.log(`Leídos ${total} archivos de ${RAIZ}\n`);
if (SECO) {
for (const [nombre, lista] of Object.entries(contenido)) {
console.log(` ${nombre.padEnd(12)} ${lista.length}`);
}
console.log('\n(en seco: no se ha tocado la base)');
process.exit(0);
}
const cliente = new pg.Client({
connectionString: process.env.DATABASE_URL,
query_timeout: 60_000
});
await cliente.connect();
await cliente.query('BEGIN');
const informe = { avisos: [], fallos: [] };
let bien = false;
try {
await volcar(cliente, contenido, informe);
bien = await comprobar(cliente, contenido, informe);
await cliente.query(bien ? 'COMMIT' : 'ROLLBACK');
} catch (fallo) {
await cliente.query('ROLLBACK').catch(() => {});
console.error('\nFalló el volcado, no se ha escrito nada:\n');
console.error(' ' + fallo.message);
await cliente.end();
process.exit(1);
}
await cliente.end();
for (const [tabla, n] of Object.entries(informe)) {
if (typeof n === 'number') console.log(` ${tabla.padEnd(12)} ${n}`);
}
for (const aviso of informe.avisos) console.log(` aviso: ${aviso}`);
if (bien) {
console.log('\nVolcado y comprobado.');
} else {
console.log('\nNo cuadra, así que se ha deshecho:');
for (const f of informe.fallos) console.log(` ✗ ${f}`);
process.exit(1);
}
void createHash;

Powered by TurnKey Linux.