/** * Ata los cabos que el volcado dejó sueltos. * * npm run db:completar * * El volcado del catálogo y el de medios se hicieron por separado: el primero * creó las canciones y el segundo las filas de `medio` con su sha256 y su * duración, pero **nadie enlazó unas con otras**. Las veintitrés canciones no * tenían su representación `master` ni `portada_id`, así que la web habría * dejado de poder reproducir nada en cuanto leyera de la base. * * No se vio al volcar porque el volcado comprobaba las letras carácter a * carácter —que era lo delicado— y no que las relaciones estuvieran puestas. * Se vio al comparar el catálogo entero contra los archivos, que es lo que * hace `npm run db:comparar`. * * Rellena, además, lo que no tenía dónde ir hasta la migración 0009: la nota de * cada versión y el orden en que se escribieron las etiquetas. * * Es idempotente: son `UPDATE`. Se puede volver a pasar cuando haga falta. */ import fs from 'node:fs'; import path from 'node:path'; import { load as leerYaml } from 'js-yaml'; import pg from 'pg'; const RAIZ = 'src/content'; const SALTO = String.fromCharCode(10); /** Separa el frontmatter del cuerpo, igual que el volcado. */ function partir(texto) { const lineas = texto.split(new RegExp(String.raw`\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(SALTO)) ?? {}, cuerpo: lineas .slice(cierre + 1) .join(SALTO) .trim() }; } function leerCarpeta(carpeta) { const dir = path.join(RAIZ, carpeta); if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir) .filter((n) => n.endsWith('.md')) .sort() .map((nombre) => { const { datos, cuerpo } = partir(fs.readFileSync(path.join(dir, nombre), 'utf8')); return { slug: nombre.replace(new RegExp(String.raw`\.md$`), ''), datos, cuerpo }; }); } const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 60_000 }); await c.connect(); await c.query('BEGIN'); const contado = { audio: 0, portadaCancion: 0, portadaAlbum: 0, paisAlbum: 0, textoEstilo: 0, textoAlbum: 0, nota: 0, orden: 0 }; const avisos = []; try { /* --- Índices de lo que ya hay ----------------------------------------- */ const { rows: medios } = await c.query('SELECT id, clave FROM medio'); const idDeMedio = new Map(medios.map((m) => [m.clave, m.id])); const { rows: canciones } = await c.query( 'SELECT id, slug, version_de_id, version_nombre FROM cancion' ); const idDeCancion = new Map(canciones.filter((f) => !f.version_de_id).map((f) => [f.slug, f.id])); /* * Las versiones se buscan por su nombre y no por el slug: el volcado lo * compuso a partir del nombre —«Versión orquestal» → * `como-si-nada-version-orquestal`— y rehacer esa cuenta aquí sería tener * dos formas de calcular lo mismo, que es como acaban discrepando. */ const versionesDe = new Map(); for (const fila of canciones.filter((f) => f.version_de_id)) { const suyas = versionesDe.get(fila.version_de_id) ?? new Map(); suyas.set(fila.version_nombre, fila.id); versionesDe.set(fila.version_de_id, suyas); } const { rows: albumes } = await c.query('SELECT id, slug FROM album'); const idDeAlbum = new Map(albumes.map((f) => [f.slug, f.id])); const { rows: etiquetas } = await c.query('SELECT id, nombre FROM etiqueta'); const idDeEtiqueta = new Map(etiquetas.map((f) => [f.nombre, f.id])); /* * El país de edición se escribe con su nombre —«España»— y la base guarda * el código ISO. El volcado se lo saltó y los cuatro discos quedaron sin * país; la ficha de edición lo enseña. */ const { rows: paises } = await c.query('SELECT codigo, nombre FROM pais'); const codigoDePais = new Map(paises.map((f) => [f.nombre, f.codigo])); /** * La clave de un medio a partir de lo que dice el archivo. * * En el frontmatter el audio es una ruta relativa dentro del almacén * privado; en `medio` la clave lleva delante el almacén, porque el mismo * nombre puede existir en varios almacenes —máster, escucha e imágenes— y * sin el prefijo chocaría. */ const enAlmacen = (almacen, ruta) => { if (!ruta) return null; // Las portadas se escriben como ruta pública —`/images/…`— y el audio // como ruta dentro del almacén. La clave lleva el almacén delante, una // sola vez. const limpia = ruta.replace(/^\//, '').replace(new RegExp(`^${almacen}/`), ''); return `${almacen}/${limpia}`; }; /* --- Canciones ---------------------------------------------------------- */ for (const { slug, datos } of leerCarpeta('canciones')) { const cancionId = idDeCancion.get(slug); if (!cancionId) { avisos.push(`la canción «${slug}» no está en la base`); continue; } const audio = idDeMedio.get(enAlmacen('audio', datos.audio)); const portada = idDeMedio.get(enAlmacen('images', datos.portada)); if (datos.audio && !audio) avisos.push(`${slug}: no encuentro el audio ${datos.audio}`); if (datos.portada && !portada) avisos.push(`${slug}: no encuentro la portada ${datos.portada}`); await c.query('UPDATE cancion SET portada_id = COALESCE($2, portada_id) WHERE id = $1', [ cancionId, portada ?? null ]); if (audio) { const formato = path.extname(datos.audio).slice(1).toLowerCase() || 'audio'; await c.query( `INSERT INTO cancion_audio (cancion_id, medio_id, uso, formato) VALUES ($1, $2, 'master', $3) ON CONFLICT (cancion_id, uso, formato) DO UPDATE SET medio_id = excluded.medio_id`, [cancionId, audio, formato] ); contado.audio++; } if (portada) contado.portadaCancion++; /* Las versiones: su audio, su nota y su nombre. */ for (const version of datos.versiones ?? []) { const versionId = versionesDe.get(cancionId)?.get(version.nombre); if (!versionId) { avisos.push(`${slug}: no encuentro la versión «${version.nombre}»`); continue; } const audioVersion = idDeMedio.get(enAlmacen('audio', version.audio)); await c.query('UPDATE cancion SET version_nota = $2 WHERE id = $1', [ versionId, version.nota ?? null ]); if (audioVersion) { const formato = path.extname(version.audio).slice(1).toLowerCase() || 'audio'; await c.query( `INSERT INTO cancion_audio (cancion_id, medio_id, uso, formato) VALUES ($1, $2, 'master', $3) ON CONFLICT (cancion_id, uso, formato) DO UPDATE SET medio_id = excluded.medio_id`, [versionId, audioVersion, formato] ); contado.audio++; } if (version.nota) contado.nota++; } /* El orden de las etiquetas, que es el del archivo. */ for (const [i, nombre] of (datos.etiquetas ?? []).entries()) { const etiquetaId = idDeEtiqueta.get(nombre); if (!etiquetaId) { avisos.push(`${slug}: no conozco la etiqueta «${nombre}»`); continue; } await c.query( 'UPDATE cancion_etiqueta SET orden = $3 WHERE cancion_id = $1 AND etiqueta_id = $2', [cancionId, etiquetaId, i] ); contado.orden++; } } /* --- Estilos: su introducción ------------------------------------------ */ const { rows: estilos } = await c.query('SELECT id, slug FROM estilo'); const idDeEstilo = new Map(estilos.map((f) => [f.slug, f.id])); for (const { slug, cuerpo } of leerCarpeta('estilos')) { const estiloId = idDeEstilo.get(slug); if (!estiloId) { avisos.push(`el estilo «${slug}» no está en la base`); continue; } if (!cuerpo) continue; await c.query('UPDATE estilo SET cuerpo = $2 WHERE id = $1', [estiloId, cuerpo]); contado.textoEstilo++; } /* --- Álbumes ------------------------------------------------------------ */ for (const { slug, datos, cuerpo } of leerCarpeta('albumes')) { const albumId = idDeAlbum.get(slug); if (!albumId) { avisos.push(`el álbum «${slug}» no está en la base`); continue; } const portada = idDeMedio.get(enAlmacen('images', datos.portada)); if (datos.portada && !portada) { avisos.push(`${slug}: no encuentro la portada ${datos.portada}`); } const nombrePais = datos.edicion?.pais ?? datos.pais; const codigoPais = nombrePais ? codigoDePais.get(nombrePais) : null; if (nombrePais && !codigoPais) avisos.push(`${slug}: no conozco el país «${nombrePais}»`); await c.query( `UPDATE album SET portada_id = COALESCE($2, portada_id), pais_edicion_codigo = COALESCE($3, pais_edicion_codigo), cuerpo = COALESCE($4, cuerpo) WHERE id = $1`, [albumId, portada ?? null, codigoPais ?? null, cuerpo || null] ); if (cuerpo) contado.textoAlbum++; if (portada) contado.portadaAlbum++; if (codigoPais) contado.paisAlbum++; } await c.query('COMMIT'); } catch (fallo) { await c.query('ROLLBACK').catch(() => {}); console.error('Falló, no se ha escrito nada:'); console.error(' ' + fallo.message); await c.end(); process.exit(1); } await c.end(); for (const [que, n] of Object.entries(contado)) console.log(` ${que.padEnd(16)} ${n}`); for (const aviso of avisos) console.log(` aviso: ${aviso}`); console.log(''); console.log('Completado. Comprueba con: npm run db:comparar');