/** * 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; const { rows: actividad } = await c.query(` select (select count(*) from compra)::int as compras, (select count(*) from pedido_item)::int as lineas_pedido `); if (actividad[0].compras > 0 || actividad[0].lineas_pedido > 0) { throw new Error( 'El volcado total es solo para una base vacía: ya hay compras o pedidos. Usa el panel y migraciones aditivas.' ); } /* --- 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_audio', 'cancion_interprete', 'cancion_persona', 'cancion_enlace', 'cancion_etiqueta', 'traduccion', 'video', 'cancion', 'album_enlace', 'credito_album', 'album', 'etiqueta', 'estilo', 'artista_identificador', 'artista_persona', 'artista', '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; }; /* Identidad pública: separada de la persona física que firma la obra. */ const artistas = new Map(); const artista = async (nombre, personaId) => { const limpio = nombre.trim(); if (artistas.has(limpio)) return artistas.get(limpio); const id = nuevoId(); const tipo = limpio === 'Los del Puerto' ? 'grupo' : limpio === 'Senza Paura' ? 'proyecto' : 'persona'; await c.query( `INSERT INTO artista (id, slug, tipo, nombre, estado) VALUES ($1,$2,$3,$4,'publicado')`, [id, slugificar(limpio), tipo, limpio] ); if (tipo === 'persona') { await c.query( `INSERT INTO artista_persona (artista_id, persona_id, relacion) VALUES ($1,$2,'identidad')`, [id, personaId] ); } artistas.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, tiene_letra, formacion_vocal, acceso, destacada, ala_venta, estado) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,'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.tieneLetra, datos.formacionVocal ?? null, datos.acceso, 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.map((interprete) => interprete.nombre)] ]) { 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 [i, interprete] of datos.interpretes.entries()) { const personaId = await persona(interprete.nombre); const artistaId = await artista(interprete.nombre, personaId); await c.query( `INSERT INTO cancion_interprete (cancion_id, artista_id, rol, acreditado_como, orden) VALUES ($1,$2,$3,$4,$5)`, [id, artistaId, interprete.rol, interprete.nombre, 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()) { const versionId = nuevoId(); await c.query( `INSERT INTO cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre, version_codigo, version_orden, duracion_s, ano, letra, idioma, tiene_letra, formacion_vocal, acceso, estado) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'publicado')`, [ versionId, `${datos.slug}-${v.id}`, datos.titulo, idEstilo.get(datos.estilo), id, v.nombre, v.id, i, v.duracion ?? null, datos.ano ?? null, cuerpo || null, v.idioma, v.tieneLetra, v.formacionVocal ?? null, v.acceso ] ); for (const [orden, interprete] of datos.interpretes.entries()) { const personaId = await persona(interprete.nombre); const artistaId = await artista(interprete.nombre, personaId); await c.query( `INSERT INTO cancion_interprete (cancion_id, artista_id, rol, acreditado_como, orden) VALUES ($1,$2,$3,$4,$5)`, [versionId, artistaId, interprete.rol, interprete.nombre, orden] ); } 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;