diff --git a/almeira-web-deploy.zip b/almeira-web-deploy.zip index 192349d..381caa6 100644 Binary files a/almeira-web-deploy.zip and b/almeira-web-deploy.zip differ diff --git a/fix-scraped-images.js b/fix-scraped-images.js new file mode 100644 index 0000000..a2fcda5 --- /dev/null +++ b/fix-scraped-images.js @@ -0,0 +1,367 @@ +const axios = require('axios'); +const cheerio = require('cheerio'); +const fs = require('fs'); +const { Client, Databases, Storage, ID, Query } = require('node-appwrite'); +require('dotenv').config({ path: '.env.local' }); + +let InputFile; +try { InputFile = require('node-appwrite/file').InputFile; } catch (e) { InputFile = null; } + +const client = new Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY); + +const databases = new Databases(client); +const storage = new Storage(client); +const DB_ID = process.env.APPWRITE_DATABASE_ID; +const BUCKET_ID = process.env.NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID; + +const scrapedNews = [ + { + title: "Gobernación del Cauca lideró mesa técnica para fortalecer la respuesta ante la actividad del volcán Puracé", + dateStr: "jueves, 20 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-lideró-mesa-técnica-para-fortalecer-la-respuesta-ante-la-actividad-del-volcán-Puracé.aspx" + }, + { + title: "¡Gracias Cauca! Ayudas humanitarias recolectadas superan las 80 toneladas", + dateStr: "miércoles, 19 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/-¡Gracias-Cauca!-Ayudas-humanitarias-recolectadas-superan-las-80-toneladas.aspx" + }, + { + title: "Gobernación del Cauca fortalece la conectividad vial en la vereda El Túnel, Cajibío", + dateStr: "martes, 18 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-fortalece-la-conectividad-vial-en-la-vereda-El-Túnel,-Cajibío.aspx" + }, + { + title: "Gobierno Departamental mantiene seguimiento a afectaciones ocasionadas por el sismo del 10 de agosto", + dateStr: "martes, 18 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobierno-Departamental-mantiene-seguimiento-a-afectaciones-ocasionadas-por-el-sismo-del-10-de-agosto-.aspx" + }, + { + title: "Más de 40 toneladas de ayudas serán entregadas en las regiones afectadas del país", + dateStr: "viernes, 14 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Más-de-40-toneladas-de-ayudas-serán-entregadas-en-las-regiones-afectadas-del-país.aspx" + }, + { + title: "Gobierno departamental hace llamado vehemente al Gobierno Nacional frente a la situación de orden público que atenta contra...", + dateStr: "jueves, 13 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobierno-departamental-hace-llamado-vehemente-al-Gobierno-Nacional-frente-a-la-situación-de-orden-público-que-atenta-contra.aspx" + }, + { + title: "Gobernación del Cauca sigue consolidando afectaciones tras el sismo del 10 de agosto", + dateStr: "jueves, 13 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-sigue-consolidando-afectaciones-tras-el-sismo-del-10-de-agosto.aspx" + }, + { + title: "Gobernación del Cauca sustentó proyecto para reducir hasta el 80 % de intereses y sanciones en impuestos departamentales", + dateStr: "jueves, 13 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-sustentó-proyecto-para-reducir-hasta-el-80--de-intereses-y-sanciones-en-impuestos-departamentales.aspx" + }, + { + title: "Gobernación del Cauca activa Plan de Evaluación Integral tras sismo de 7.4 registrado en el país", + dateStr: "lunes, 10 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-activa-Plan-de-Evaluación-Integral-tras-sismo-de-7-4-registrado-en-el-país.aspx" + }, + { + title: "Gobernación del Cauca está en permanente monitoreo y atención a municipios afectados tras el sismo de 7.4", + dateStr: "lunes, 10 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-está-en-permanente-monitoreo-y-atención-a-municipios-afectados-tras-el-sismo-de-7-4.aspx" + }, + { + title: "Gobernación del Cauca desplegó acciones simultáneas frente a la alerta volcánica del Puracé, la protección de la vía Panamericana...", + dateStr: "domingo, 9 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-desplegó-acciones-simultáneas-frente-a-la-alerta-volcánica-del-Puracé,-la-protección-de-la-vía-Paname.aspx" + }, + { + title: "Gobernación del Cauca entregó ayudas humanitarias ante la emergencia generada por la actividad del volcán Puracé", + dateStr: "miércoles, 5 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-entregó-ayudas-humanitarias-ante-la-emergencia-generada-por-la-actividad-del-volcán-Puracé.aspx" + }, + { + title: "Gobernación del Cauca intensifica acciones para proteger la salud de las comunidades tras la alerta naranja del volcán Puracé", + dateStr: "sábado, 1 de agosto de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-intensifica-acciones-para-proteger-la-salud-de-las-comunidades-tras-la-alerta-naranja-del-volcán-Pura.aspx" + }, + { + title: "Gobernación del Cauca unifica sus trámites tributarios en el portal web institucional en cumplimiento de la Ley de Transparencia", + dateStr: "martes, 28 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-unifica-sus-trámites-tributarios-en-el-portal-web-institucional-en-cumplimiento-de-la-Ley-de-Transpar.aspx" + }, + { + title: "Invitación rueda de prensa sobre el estado actual de la Industria Licorera del Cauca", + dateStr: "martes, 28 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Invitación-rueda-de-prensa-sobre-el-estado-actual-de-la-Industria-Licorera-del-Cauca.aspx" + }, + { + title: "Gobierno Departamental fortalece la defensa de los Derechos Humanos con la Mesa Territorial de Garantías", + dateStr: "lunes, 27 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/-Gobierno-Departamental-fortalece-la-defensa-de-los-Derechos-Humanos-con-la-Mesa-Territorial-de-Garantías--En-el-marco-del-.aspx" + }, + { + title: "La Afrocolombianidad se vive y se fortalece en el Cauca con La Fuerza del Pueblo", + dateStr: "viernes, 24 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/La-Afrocolombianidad-se-vive-y-se-fortalece-en-el-Cauca-con-La-Fuerza-del-Pueblo.aspx" + }, + { + title: "Gobernación del Cauca hace un llamado urgente a entidades competentes para garantizar atención en salud a más de 400.000 afiliados de Asmet Salud", + dateStr: "jueves, 23 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-hace-un-llamado-urgente-a-entidades-competentes-para-garantizar-atención-en-salud-a-más-de-400-000-af.aspx" + }, + { + title: "Golpe al contrabando en el Norte del Cauca: decomisadas 586 unidades de licor", + dateStr: "martes, 21 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/-Golpe-al-contrabando-en-el-Norte-del-Cauca-decomisadas-586-unidades-de-licor.aspx" + }, + { + title: "Con Escuela y Café, la Gobernación del Cauca fortalece la formación de los jóvenes del departamento", + dateStr: "martes, 21 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Con-Escuela-y-Café,-la-Gobernación-del-Cauca-fortalece-la-formación-de-los-jóvenes-del-departamento.aspx" + }, + { + title: "Vías para la Vida: el sector rural de Popayán, una prioridad para el Gobierno Departamental", + dateStr: "sábado, 11 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Vías-para-la-Vida-el-sector-rural-de-Popayán,-una-prioridad-para-el-Gobierno-Departamental.aspx" + }, + { + title: "La paz se construye con acciones en los territorios: Gobernación del Cauca entrega ayudas integrales en El Carmelo, Cajibío", + dateStr: "miércoles, 8 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/La-paz-se-construye-con-acciones-en-los-territorios-Gobernación-del-Cauca-entrega-ayudas-integrales-en-El-Carmelo,-Cajibío.aspx" + }, + { + title: "El Cauca se destaca por su gestión administrativa a nivel nacional", + dateStr: "lunes, 6 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/El-Cauca-se-destaca-por-su-gestión-administrative-a-nivel-nacional.aspx" + }, + { + title: "Gobernación del Cauca alerta sobre enlace fraudulento para el pago del impuesto vehicular", + dateStr: "lunes, 6 de julio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-alerta-sobre-enlace-fraudulento-para-el-pago-del-impuesto-vehicular.aspx" + }, + { + title: "Cauca se consolida como referente mundial del café con la VI Feria \"Cauca Sabe a Café 2026\"", + dateStr: "viernes, 26 de junio de 2026", + link: "/Prensa/SaladePrensa/Paginas/-Cauca-se-consolida-como-referente-mundial-del-café-con-la-VI-Feria-Cauca-Sabe-a-Café-2026.aspx" + }, + { + title: "Este 26 de junio Indeportes Cauca rinde cuentas a la ciudadanía", + dateStr: "martes, 23 de junio de 2026", + link: "/Prensa/SaladePrensa/Paginas/-Este-26-de-junio-Indeportes-Cauca-rinde-cuentas-a-la-ciudadanía.aspx" + }, + { + title: "Participación histórica fortalece la democracia en el Cauca", + dateStr: "domingo, 21 de junio de 2026", + link: "/Prensa/SaladePrensa/Paginas/-Participación-histórica-fortalece-la-democracia-en-el-Cauca.aspx" + }, + { + title: "Con normalidad y garantías, se realiza la segunda vuelta presidencial en los 42 municipios del Cauca", + dateStr: "domingo, 21 de junio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Con-normalidad-y-garantías,-se-realiza-la-segunda-vuelta-presidencial-en-los-42-municipios-del-Cauca.aspx" + }, + { + title: "Cauca fortalece garantías para un proceso electoral seguro, transparente y participativo en la segunda vuelta presidencial", + dateStr: "miércoles, 10 de junio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Cauca-fortalece-garantías-para-un-proceso-electoral-seguro,-transparente-y-participativo-en-la-segunda-vuelta-presidencial.aspx" + }, + { + title: "La Industria Licorera del Cauca se vistió de oro en París", + dateStr: "martes, 9 de junio de 2026", + link: "/Prensa/SaladePrensa/Paginas/La-Industria-Licorera-del-Cauca-se-vistió-de-oro-en-París.aspx" + }, + { + title: "Gobernación del Cauca fortalece acciones de vigilancia sanitaria en establecimientos de estética", + dateStr: "sábado, 6 de junio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-fortalece-acciones-de-vigilancia-sanitaria-en-establecimientos-de-estética.aspx" + }, + { + title: "Gobernación del Cauca fortalece el bienestar animal, la movilidad rural and la productividad campesina en Timbío", + dateStr: "viernes, 5 de junio de 2026", + link: "/Prensa/SaladePrensa/Paginas/Gobernación-del-Cauca-fortalece-el-bienestar-animal,-la-movilidad-rural-y-la-productividad-campesina-en-Timbío.aspx" + } +]; + +function slugify(text) { + return text + .toString() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9 -]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); +} + +async function run() { + try { + const collections = await databases.listCollections(DB_ID); + const map = {}; + collections.collections.forEach(c => { map[c.name.toLowerCase()] = c.$id; }); + const postsCollectionId = map['posts'] || 'posts'; + const mediaCollectionId = map['media'] || 'media'; + + console.log(`Starting self-healing scan on ${scrapedNews.length} articles...`); + + for (let i = 0; i < scrapedNews.length; i++) { + const item = scrapedNews[i]; + const slug = `gobernacion-web-${slugify(item.title)}`; + console.log(`\nChecking [${i+1}/${scrapedNews.length}]: "${item.title}"`); + + const postsRes = await databases.listDocuments(DB_ID, postsCollectionId, [ + Query.equal('slug', slug), + Query.limit(1) + ]); + + if (postsRes.documents.length === 0) { + console.log(`-> Post not found in database. Skipping.`); + continue; + } + + const post = postsRes.documents[0]; + let needsHealing = false; + let oldMediaId = post.imagen_destacada_id; + let oldFileId = null; + + if (!oldMediaId) { + console.log("-> Post has no featured image ID. Needs healing!"); + needsHealing = true; + } else { + try { + const mediaDoc = await databases.getDocument(DB_ID, mediaCollectionId, oldMediaId); + oldFileId = mediaDoc.file_id; + const file = await storage.getFile(BUCKET_ID, oldFileId); + if (file.mimeType.includes('html') || file.mimeType.includes('text')) { + console.log(`-> Found corrupted HTML file payload (ID: ${oldFileId}, Mime: ${file.mimeType}). Needs healing!`); + needsHealing = true; + } else { + console.log(`-> Image is valid (Mime: ${file.mimeType}).`); + } + } catch (e) { + console.log(`-> Missing file or media document in Appwrite: ${e.message}. Needs healing!`); + needsHealing = true; + } + } + + if (!needsHealing) continue; + + const url = 'https://cauca.gov.co' + item.link; + console.log(`-> Healing: Fetching webpage to scrape image... ${url}`); + + try { + const res = await axios.get(encodeURI(decodeURI(url)), { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36' + }, + timeout: 10000 + }); + + const $ = cheerio.load(res.data); + let image = null; + + // 1. Check body content first + $('.ms-rtestate-field img').each((idx, el) => { + const src = $(el).attr('src'); + if (src && !src.includes('logo') && !src.includes('icon')) { + image = src.startsWith('http') ? src : 'https://cauca.gov.co' + src; + return false; + } + }); + + // 2. Try old path + if (!image) { + $('img').each((idx, el) => { + const src = $(el).attr('src'); + if (src && src.includes('/Prensa/PublishingImages/')) { + image = src.startsWith('http') ? src : 'https://cauca.gov.co' + src; + return false; + } + }); + } + + // 3. Try general image fallback + if (!image) { + $('img').each((idx, el) => { + const src = $(el).attr('src'); + if (src && !src.includes('logo') && !src.includes('icon') && !src.includes('menu') && src.length > 10) { + image = src.startsWith('http') ? src : 'https://cauca.gov.co' + src; + return false; + } + }); + } + + if (!image) { + console.log("-> Could not find any valid image on this article page."); + continue; + } + + console.log(`-> Found image URL: ${image}`); + console.log("-> Downloading binary image with correct User-Agent..."); + + const imgRes = await axios.get(encodeURI(decodeURI(image)), { + responseType: 'arraybuffer', + timeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36' + } + }); + + const buffer = Buffer.from(imgRes.data, 'binary'); + + // Verify that the response is actually binary image and not HTML + const isHtml = buffer.toString('utf-8', 0, 50).trim().toLowerCase().startsWith(' Error: Image request returned HTML instead of binary image data!"); + continue; + } + + const urlParts = image.split('/'); + const filename = urlParts[urlParts.length - 1] || 'image.jpg'; + const filePath = `/tmp/heal_${ID.unique()}_${filename}`; + fs.writeFileSync(filePath, buffer); + + let fileParam; + if (InputFile && InputFile.fromPath) fileParam = InputFile.fromPath(filePath, filename); + else fileParam = fs.createReadStream(filePath); + + console.log("-> Uploading new binary image file to Appwrite..."); + const newFile = await storage.createFile(BUCKET_ID, ID.unique(), fileParam); + + console.log("-> Creating new media document in database..."); + const newMediaDoc = await databases.createDocument(DB_ID, mediaCollectionId, ID.unique(), { + file_id: newFile.$id, + bucket_id: BUCKET_ID, + titulo: item.title, + tipo: 'imagen' + }); + + console.log(`-> Updating post document "${post.titulo}" to link new image...`); + await databases.updateDocument(DB_ID, postsCollectionId, post.$id, { + imagen_destacada_id: newMediaDoc.$id + }); + + // Cleanup temp file + try { fs.unlinkSync(filePath); } catch (e) {} + + // Delete old corrupted file and media document if they existed + if (oldFileId) { + console.log(`-> Cleaning up old corrupted file ID ${oldFileId} from storage...`); + try { await storage.deleteFile(BUCKET_ID, oldFileId); } catch (e) { console.log(` (Failed to delete file: ${e.message})`); } + } + if (oldMediaId) { + console.log(`-> Cleaning up old corrupted media document ID ${oldMediaId} from database...`); + try { await databases.deleteDocument(DB_ID, mediaCollectionId, oldMediaId); } catch (e) { console.log(` (Failed to delete media document: ${e.message})`); } + } + + console.log(`[HEALED SUCCESS] Post "${post.titulo}" now has its correct featured image!`); + + } catch (err) { + console.error(`-> Error healing post:`, err.message); + } + } + + console.log("\nSelf-healing scan complete!"); + } catch (e) { + console.error("Fatal Error:", e.message); + } +} +run(); diff --git a/import-images.js b/import-images.js new file mode 100644 index 0000000..2b16840 --- /dev/null +++ b/import-images.js @@ -0,0 +1,243 @@ +const fs = require('fs'); +const path = require('path'); +const { Client, Databases, Storage, ID, Query } = require('node-appwrite'); +const stringSimilarity = require('string-similarity'); +const sharp = require('sharp'); +require('dotenv').config({ path: '.env.local' }); + +let InputFile; +try { InputFile = require('node-appwrite/file').InputFile; } catch (e) { InputFile = null; } + +const client = new Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT) + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID) + .setKey(process.env.APPWRITE_API_KEY); + +const databases = new Databases(client); +const storage = new Storage(client); +const DB_ID = process.env.APPWRITE_DATABASE_ID; +const BUCKET_ID = process.env.NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID; + +const DIR_PENDIENTES = '/home/carlos/Documentos/Almeira Web/imagenes pendientes'; +const DIR_PROCESADOS = path.join(DIR_PENDIENTES, 'Procesados'); + +if (!fs.existsSync(DIR_PROCESADOS)) { + fs.mkdirSync(DIR_PROCESADOS); +} + +function normalizeText(text) { + if (!text) return ''; + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]/g, '') + .trim(); +} + +function cleanFilenameTitle(filename) { + const ext = path.extname(filename); + let base = path.basename(filename, ext); + return base.replace(/\(1\)/g, '').trim(); +} + +async function main() { + try { + console.log('=== INICIANDO PROCESAMIENTO DE IMÁGENES PENDIENTES ==='); + console.log(`Endpoint: ${process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT}`); + console.log(`Proyecto: ${process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID}`); + console.log(`Base de datos: ${DB_ID}`); + console.log(`Bucket de medios: ${BUCKET_ID}\n`); + + // 1. Obtener mapeo dinámico de colecciones + const collections = await databases.listCollections(DB_ID); + const colMap = {}; + collections.collections.forEach(c => { colMap[c.name.toLowerCase()] = c.$id; }); + const postsCollectionId = colMap['posts'] || 'posts'; + const mediaCollectionId = colMap['media'] || 'media'; + + // 2. Leer archivos en "imagenes pendientes" + const files = fs.readdirSync(DIR_PENDIENTES).filter(file => { + const ext = path.extname(file).toLowerCase(); + return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.jfif'].includes(ext); + }); + + if (files.length === 0) { + console.log('No se encontraron imágenes pendientes en el directorio.'); + return; + } + console.log(`Se encontraron ${files.length} imágenes para procesar.`); + + // 3. Cargar todos los posts de la base de datos + console.log('Cargando publicaciones de la base de datos...'); + const posts = []; + let cursor = null; + while (true) { + const queries = [Query.limit(100)]; + if (cursor) queries.push(Query.cursorAfter(cursor)); + const res = await databases.listDocuments(DB_ID, postsCollectionId, queries); + posts.push(...res.documents); + if (res.documents.length < 100) break; + cursor = res.documents[res.documents.length - 1].$id; + } + console.log(`Se cargaron ${posts.length} publicaciones de la base de datos.`); + + const postTitles = posts.map(p => p.titulo || ''); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const filePath = path.join(DIR_PENDIENTES, file); + const cleanTitle = cleanFilenameTitle(file); + const normCleanTitle = normalizeText(cleanTitle); + + console.log(`\n[${i+1}/${files.length}] Procesando archivo: "${file}"`); + + // Buscar coincidencia en posts + let bestPost = null; + let bestScore = 0; + + // Primero buscar coincidencia exacta normalizada + for (const post of posts) { + const normPostTitle = normalizeText(post.titulo); + if (normPostTitle === normCleanTitle) { + bestPost = post; + bestScore = 1.0; + break; + } + } + + // Si no hay exacta, buscar por similitud de cadena + if (bestScore < 1.0 && postTitles.length > 0) { + const matches = stringSimilarity.findBestMatch(cleanTitle, postTitles); + if (matches.bestMatch.rating >= 0.85) { // Umbral de confianza alto (>= 0.85) + bestScore = matches.bestMatch.rating; + bestPost = posts[matches.bestMatchIndex]; + } + } + + const matchedTitle = bestPost ? bestPost.titulo : cleanTitle; + if (bestPost) { + console.log(` -> Publicación coincidente encontrada: "${bestPost.titulo}" (Similitud: ${bestScore.toFixed(2)})`); + } else { + console.log(` -> No se encontró publicación coincidente en la base de datos. Se subirá solo a la biblioteca de medios.`); + } + + // Redimensionar imagen a 640x360 usando sharp + let uploadFilename = file; + let fileExt = path.extname(file).toLowerCase(); + if (fileExt === '.jfif') { + uploadFilename = path.basename(file, fileExt) + '.jpg'; + } + + const tempResizedPath = path.join('/tmp', `resized_${ID.unique()}_${path.basename(uploadFilename)}`); + console.log(` -> Redimensionando imagen a 640x360...`); + try { + await sharp(filePath) + .resize(640, 360, { + fit: 'cover', + position: 'center' + }) + .toFile(tempResizedPath); + console.log(` -> Imagen redimensionada guardada en ${tempResizedPath}`); + } catch (err) { + console.error(` ❌ Error redimensionando la imagen: ${err.message}`); + continue; // Saltar este archivo si falla el redimensionado + } + + // Subir a Appwrite Storage + let mediaDocId = null; + try { + console.log(` -> Subiendo imagen redimensionada a Appwrite Storage...`); + let fileParam; + if (InputFile && InputFile.fromPath) { + fileParam = InputFile.fromPath(tempResizedPath, uploadFilename); + } else { + fileParam = fs.createReadStream(tempResizedPath); + } + + const uploadedFile = await storage.createFile(BUCKET_ID, ID.unique(), fileParam); + console.log(` -> Archivo subido exitosamente con ID: ${uploadedFile.$id}`); + + // Crear documento en la colección de media + console.log(` -> Creando registro en la biblioteca de medios...`); + const mediaDoc = await databases.createDocument(DB_ID, mediaCollectionId, ID.unique(), { + file_id: uploadedFile.$id, + bucket_id: BUCKET_ID, + titulo: matchedTitle, + tipo: 'imagen', + alt_text: matchedTitle + }); + mediaDocId = mediaDoc.$id; + console.log(` -> Registro de biblioteca de medios creado con ID: ${mediaDocId}`); + + } catch (err) { + console.error(` ❌ Error subiendo a Appwrite / creando registro de media: ${err.message}`); + // Eliminar archivo temporal + try { fs.unlinkSync(tempResizedPath); } catch (_) {} + continue; + } + + // Eliminar archivo temporal + try { fs.unlinkSync(tempResizedPath); } catch (_) {} + + // Asociar imagen destacada si corresponde + if (bestPost) { + let needsUpdate = false; + let oldMediaId = bestPost.imagen_destacada_id; + + if (!oldMediaId) { + console.log(` -> La publicación no tiene imagen destacada. Se procederá a asociarla.`); + needsUpdate = true; + } else { + try { + console.log(` -> Verificando imagen destacada actual (${oldMediaId})...`); + const currentMediaDoc = await databases.getDocument(DB_ID, mediaCollectionId, oldMediaId); + const fileInfo = await storage.getFile(BUCKET_ID, currentMediaDoc.file_id); + if (fileInfo.mimeType.includes('html') || fileInfo.mimeType.includes('text')) { + console.log(` -> Imagen actual corrupta (HTML/Text). Se procederá a sanarla/reemplazarla.`); + needsUpdate = true; + } else { + console.log(` -> La publicación ya cuenta con una imagen destacada válida (${fileInfo.mimeType}). No se actualizará.`); + } + } catch (e) { + console.log(` -> Referencia de imagen actual rota o inexistente en Appwrite. Se procederá a asociarla.`); + needsUpdate = true; + } + } + + if (needsUpdate) { + try { + console.log(` -> Actualizando publicación para establecer la nueva imagen destacada...`); + await databases.updateDocument(DB_ID, postsCollectionId, bestPost.$id, { + imagen_destacada_id: mediaDocId + }); + console.log(` ✓ Publicación "${bestPost.titulo}" vinculada exitosamente.`); + } catch (err) { + console.error(` ❌ Error vinculando la imagen a la publicación: ${err.message}`); + } + } + } + + // Mover archivo original a Procesados + try { + let destPath = path.join(DIR_PROCESADOS, file); + if (fs.existsSync(destPath)) { + const ext = path.extname(file); + const base = path.basename(file, ext); + destPath = path.join(DIR_PROCESADOS, `${base}_${Date.now()}${ext}`); + } + fs.renameSync(filePath, destPath); + console.log(` -> Archivo original movido a Procesados.`); + } catch (err) { + console.error(` ❌ Error moviendo archivo original a Procesados: ${err.message}`); + } + } + + console.log('\n=== PROCESAMIENTO COMPLETADO EXITOSAMENTE ==='); + + } catch (e) { + console.error('Error fatal durante la ejecución:', e); + } +} + +main(); diff --git a/publish-cauca-web.js b/publish-cauca-web.js index b88545f..e7382fd 100644 --- a/publish-cauca-web.js +++ b/publish-cauca-web.js @@ -387,7 +387,13 @@ async function run() { if (image) { try { console.log(`-> Downloading image: ${image}`); - const imgRes = await axios.get(encodeURI(image), { responseType: 'arraybuffer', timeout: 10000 }); + const imgRes = await axios.get(encodeURI(image), { + responseType: 'arraybuffer', + timeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36' + } + }); const buffer = Buffer.from(imgRes.data, 'binary'); // Extract filename from URL diff --git a/src/components/publications/PublicationDrawer.tsx b/src/components/publications/PublicationDrawer.tsx deleted file mode 100644 index d633ef5..0000000 --- a/src/components/publications/PublicationDrawer.tsx +++ /dev/null @@ -1,169 +0,0 @@ -'use client'; - -/** - * @file PublicationDrawer.tsx - * @description Slide-in SideDrawer for inline post editing. - * Opens when a user clicks a PostCard (gated by RBAC). - * Renders entirely in a Portal, focus-trapped, Escape-closeable. - */ - -import React, { useEffect, useRef, useCallback, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { X, Edit2, Loader2 } from 'lucide-react'; -import type { Post, AuthUser, PublicationLayoutProps } from '@/types/publications'; - -export interface PublicationDrawerProps { - isOpen: boolean; - post: Post | null; - user: AuthUser; - relationData: Record>; - submitAction: (id: string, formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown } | void>; - appwriteConfig: { - endpoint: string; - projectId: string; - databaseId: string; - }; - onClose: () => void; - onSuccess?: () => void; -} - -export const PublicationDrawer: React.FC = ({ - isOpen, - post, - user, - relationData, - submitAction, - appwriteConfig, - onClose, - onSuccess, -}) => { - const closeButtonRef = useRef(null); - - // Focus management - useEffect(() => { - if (isOpen) { - const timer = setTimeout(() => closeButtonRef.current?.focus(), 150); - return () => clearTimeout(timer); - } - }, [isOpen]); - - // Keyboard handler - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }, [onClose]); - - useEffect(() => { - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - document.body.style.overflow = 'hidden'; - } - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.body.style.overflow = ''; - }; - }, [isOpen, handleKeyDown]); - - const wrappedSubmit = useCallback(async (formData: FormData) => { - if (!post?.$id) return { success: false, error: 'No post ID found' }; - const result = await submitAction(post.$id, formData); - if (!result || result.success) { - onSuccess?.(); - } - return result; - }, [submitAction, onSuccess, post]); - - if (typeof document === 'undefined') return null; - - return createPortal( -
- {/* Backdrop */} - , - document.body - ); -}; - -/** Lazy-loads PublicationLayout to code-split it from the list page */ -function LazyPublicationLayout(props: PublicationLayoutProps) { - const [Layout, setLayout] = useState | null>(null); - - useEffect(() => { - import('./PublicationLayout').then((mod) => { - setLayout(() => mod.default as React.ComponentType); - }); - }, []); - - if (!Layout) { - return ( -
- - Cargando formulario… -
- ); - } - - return ; -} - -export default PublicationDrawer; diff --git a/src/components/publications/PublicationLayout.tsx b/src/components/publications/PublicationLayout.tsx index dbc9fa2..c2b6578 100644 --- a/src/components/publications/PublicationLayout.tsx +++ b/src/components/publications/PublicationLayout.tsx @@ -178,7 +178,10 @@ export const PublicationLayout: React.FC = ({ } else { setOptimisticState('error', result.error || 'Error al guardar.'); } - } catch (err: unknown) { + } catch (err: any) { + if (err && (err.message === 'NEXT_REDIRECT' || err.digest?.startsWith('NEXT_REDIRECT'))) { + throw err; + } setOptimisticState('error', err instanceof Error ? err.message : 'Error desconocido.'); } }); diff --git a/src/components/publications/PublicationsList.tsx b/src/components/publications/PublicationsList.tsx index 29b81d1..92ae738 100644 --- a/src/components/publications/PublicationsList.tsx +++ b/src/components/publications/PublicationsList.tsx @@ -69,10 +69,6 @@ export const PublicationsList: React.FC = ({ const [statusFilter, setStatusFilter] = useState('all'); const [sortBy, setSortBy] = useState('fecha_publicacion'); - const [drawerPost, setDrawerPost] = useState(null); - - // Lazy-load Drawer to avoid shipping it unless needed - const [DrawerComponent, setDrawerComponent] = useState | null>(null); const canEdit = user?.$id === 'admin-emergency' || user?.labels?.includes('admin') || user?.labels?.includes('editor'); const userRole = canEdit ? 'admin' : 'viewer'; @@ -109,18 +105,9 @@ export const PublicationsList: React.FC = ({ // ── HANDLERS ──────────────────────────────── - const handleEdit = useCallback(async (post: Post) => { - // Lazy-load the drawer component - if (!DrawerComponent) { - const mod = await import('./PublicationDrawer'); - setDrawerComponent(() => mod.PublicationDrawer); - } - setDrawerPost(post); - }, [DrawerComponent]); - - const handleCloseDrawer = useCallback(() => { - setDrawerPost(null); - }, []); + const handleEdit = useCallback((post: Post) => { + router.push(`/dashboard/${entity}/${post.$id}`); + }, [entity, router]); const handleDelete = useCallback((postId: string) => { if (!confirm('¿Eliminar esta publicación permanentemente?')) return; @@ -143,11 +130,7 @@ export const PublicationsList: React.FC = ({ }); }, [toast]); - const handleEditSuccess = useCallback(() => { - setDrawerPost(null); - toast.success('Publicación actualizada correctamente.'); - router.refresh(); - }, [toast, router]); + // ── RENDER ────────────────────────────────── @@ -277,19 +260,7 @@ export const PublicationsList: React.FC = ({
)} - {/* SideDrawer — lazy loaded */} - {DrawerComponent && ( - - )} + ); };