diff --git a/backend/src/Titulares.Api/Controllers/TitularesController.cs b/backend/src/Titulares.Api/Controllers/TitularesController.cs index 7f36d0e..0e023b7 100644 --- a/backend/src/Titulares.Api/Controllers/TitularesController.cs +++ b/backend/src/Titulares.Api/Controllers/TitularesController.cs @@ -47,9 +47,13 @@ public class TitularesController : ControllerBase [HttpPut("{id}")] public async Task Actualizar(int id, [FromBody] ActualizarTitularDto titularDto) { - var resultado = await _repositorio.ActualizarTextoAsync(id, titularDto); + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + var resultado = await _repositorio.ActualizarTitularAsync(id, titularDto); if (!resultado) return NotFound(); - await NotificarCambios(); // Notificamos después de actualizar + await NotificarCambios(); return NoContent(); } diff --git a/backend/src/Titulares.Api/Data/TitularRepositorio.cs b/backend/src/Titulares.Api/Data/TitularRepositorio.cs index 710c9bf..f33e2aa 100644 --- a/backend/src/Titulares.Api/Data/TitularRepositorio.cs +++ b/backend/src/Titulares.Api/Data/TitularRepositorio.cs @@ -39,15 +39,25 @@ public class TitularRepositorio return await connection.ExecuteScalarAsync(sql, new { titularDto.Texto, OrdenVisual = nuevoOrden }); } - public async Task ActualizarTextoAsync(int id, ActualizarTitularDto titularDto) + public async Task ActualizarTitularAsync(int id, ActualizarTitularDto titularDto) { using var connection = CreateConnection(); + // La consulta SQL actualiza Texto, Viñeta y las banderas de estado var sql = @" UPDATE Titulares - SET Texto = @Texto, ModificadoPorUsuario = 1, Tipo = 'Edited' + SET + Texto = @Texto, + Viñeta = @Viñeta, + ModificadoPorUsuario = 1, + Tipo = 'Edited' WHERE Id = @Id; "; - var affectedRows = await connection.ExecuteAsync(sql, new { titularDto.Texto, Id = id }); + var affectedRows = await connection.ExecuteAsync(sql, new + { + titularDto.Texto, + titularDto.Viñeta, + Id = id + }); return affectedRows > 0; } @@ -83,7 +93,7 @@ public class TitularRepositorio } } - public async Task SincronizarDesdeScraping(List articulosScrapeados, int limiteTotal) + public async Task SincronizarDesdeScraping(List articulosScrapeados, int cantidadALimitar) { using var connection = CreateConnection(); await connection.OpenAsync(); @@ -91,12 +101,11 @@ public class TitularRepositorio try { - // 1. Obtener las URLs que ya tenemos en la base de datos + // --- PARTE 1: AÑADIR NUEVOS TITULARES --- var urlsEnDb = (await connection.QueryAsync( "SELECT UrlFuente FROM Titulares WHERE UrlFuente IS NOT NULL", transaction: transaction)) .ToHashSet(); - // 2. Filtrar para quedarnos solo con los artículos que son realmente nuevos var articulosNuevos = articulosScrapeados .Where(a => !urlsEnDb.Contains(a.UrlFuente)) .ToList(); @@ -104,48 +113,45 @@ public class TitularRepositorio if (articulosNuevos.Any()) { var cantidadNuevos = articulosNuevos.Count; - - // 3. Hacer espacio para los nuevos: empujamos todos los existentes hacia abajo await connection.ExecuteAsync( "UPDATE Titulares SET OrdenVisual = OrdenVisual + @cantidadNuevos", new { cantidadNuevos }, transaction: transaction); - // 4. Insertar los nuevos artículos al principio (OrdenVisual 0, 1, 2...) for (int i = 0; i < cantidadNuevos; i++) { var articulo = articulosNuevos[i]; var sqlInsert = @" - INSERT INTO Titulares (Texto, UrlFuente, ModificadoPorUsuario, EsEntradaManual, OrdenVisual, Tipo, Fuente) - VALUES (@Texto, @UrlFuente, 0, 0, @OrdenVisual, 'Scraped', 'eldia.com'); + INSERT INTO Titulares (Texto, UrlFuente, OrdenVisual, Tipo, Fuente) + VALUES (@Texto, @UrlFuente, @OrdenVisual, 'Scraped', 'eldia.com'); "; - await connection.ExecuteAsync(sqlInsert, new - { - articulo.Texto, - articulo.UrlFuente, - OrdenVisual = i - }, transaction: transaction); + await connection.ExecuteAsync(sqlInsert, new { articulo.Texto, articulo.UrlFuente, OrdenVisual = i }, transaction: transaction); } - - // 5. Purgar los más antiguos si superamos el límite, ignorando los manuales - var sqlDelete = @" - DELETE FROM Titulares - WHERE Id IN ( - SELECT Id FROM Titulares - WHERE EsEntradaManual = 0 - ORDER BY OrdenVisual DESC - OFFSET @limiteTotal ROWS - ); - "; - await connection.ExecuteAsync(sqlDelete, new { limiteTotal }, transaction: transaction); } + // --- PARTE 2: PURGAR LOS SOBRANTES --- + var sqlDelete = @" + DELETE FROM Titulares + WHERE Id IN ( + -- Seleccionamos los IDs de los titulares que queremos borrar. + -- Son aquellos que no son manuales, ordenados por antigüedad (los más nuevos primero), + -- y nos saltamos los primeros 'N' que queremos conservar. + SELECT Id + FROM Titulares + WHERE EsEntradaManual = 0 + ORDER BY OrdenVisual ASC + OFFSET @Limite ROWS + ); + "; + // Usamos cantidadALimitar como el límite de cuántos conservar. + await connection.ExecuteAsync(sqlDelete, new { Limite = cantidadALimitar }, transaction: transaction); + transaction.Commit(); } catch { transaction.Rollback(); - throw; // Relanzamos la excepción para que el worker sepa que algo falló + throw; } } } \ No newline at end of file diff --git a/backend/src/Titulares.Api/Models/ConfiguracionApp.cs b/backend/src/Titulares.Api/Models/ConfiguracionApp.cs index 89f644b..1cf79ed 100644 --- a/backend/src/Titulares.Api/Models/ConfiguracionApp.cs +++ b/backend/src/Titulares.Api/Models/ConfiguracionApp.cs @@ -7,5 +7,5 @@ public class ConfiguracionApp public string RutaCsv { get; set; } = "C:\\temp\\titulares.csv"; public int IntervaloMinutos { get; set; } = 5; public int CantidadTitularesAScrapear { get; set; } = 20; - public int LimiteTotalEnDb { get; set; } = 50; + //public int LimiteTotalEnDb { get; set; } = 50; } \ No newline at end of file diff --git a/backend/src/Titulares.Api/Models/Titular.cs b/backend/src/Titulares.Api/Models/Titular.cs index b1ee2c6..56bf763 100644 --- a/backend/src/Titulares.Api/Models/Titular.cs +++ b/backend/src/Titulares.Api/Models/Titular.cs @@ -27,6 +27,7 @@ public class CrearTitularDto public class ActualizarTitularDto { public required string Texto { get; set; } + public string? Viñeta { get; set; } } // DTO para el reordenamiento diff --git a/backend/src/Titulares.Api/Services/CsvService.cs b/backend/src/Titulares.Api/Services/CsvService.cs index b9e9115..773883e 100644 --- a/backend/src/Titulares.Api/Services/CsvService.cs +++ b/backend/src/Titulares.Api/Services/CsvService.cs @@ -26,53 +26,35 @@ public class CsvService try { - // La cláusula Where asegura que los Encabezados no sean nulos o vacíos. - var grupos = titulares - .Where(t => !string.IsNullOrEmpty(t.Encabezado)) - .GroupBy(t => t.Encabezado); + var directorio = Path.GetDirectoryName(rutaArchivo); + if (directorio != null) + { + Directory.CreateDirectory(directorio); + } var config = new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";", + HasHeaderRecord = false, + // Esta linea es para que nunca se ponga comillas en ningún campo. + ShouldQuote = args => false, }; await using var writer = new StreamWriter(rutaArchivo); await using var csv = new CsvWriter(writer, config); - // Escribimos la cabecera principal csv.WriteField("Text"); - csv.WriteField("Heading"); - csv.WriteField("Value"); - csv.WriteField("Up"); - csv.WriteField("Down"); - csv.WriteField("Change"); csv.WriteField("Bullet"); await csv.NextRecordAsync(); - foreach (var grupo in grupos) + foreach (var titular in titulares) { - csv.WriteField(""); - csv.WriteField($" {grupo.Key!.ToUpper()} "); - csv.WriteField(""); - await csv.NextRecordAsync(); - - await csv.NextRecordAsync(); - - foreach (var titular in grupo) - { - csv.WriteField(titular.Texto); - csv.WriteField(""); // Heading - csv.WriteField(""); // Value - csv.WriteField(""); // Up - csv.WriteField(""); // Down - csv.WriteField(""); // Change - csv.WriteField(titular.Viñeta ?? "•"); - await csv.NextRecordAsync(); - } - + csv.WriteField(titular.Texto); + csv.WriteField(titular.Viñeta ?? "•"); await csv.NextRecordAsync(); } - _logger.LogInformation("CSV generado exitosamente."); + + _logger.LogInformation("CSV generado exitosamente con formato simplificado."); } catch (Exception ex) { diff --git a/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs b/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs index d803a3d..77fa872 100644 --- a/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs +++ b/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs @@ -9,17 +9,27 @@ using Titulares.Api.Models; namespace Titulares.Api.Workers; -public class ProcesoScrapingWorker : BackgroundService +public class ProcesoScrapingWorker : BackgroundService, IDisposable { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly IOptionsMonitor _configuracion; + private readonly IDisposable? _optionsReloadToken; + + private CancellationTokenSource? _delayCts; public ProcesoScrapingWorker(ILogger logger, IServiceProvider serviceProvider, IOptionsMonitor configuracion) { _logger = logger; _serviceProvider = serviceProvider; _configuracion = configuracion; + _optionsReloadToken = _configuracion.OnChange(OnConfigurationChanged); + } + + private void OnConfigurationChanged(ConfiguracionApp newConfig) + { + _logger.LogInformation("La configuración ha cambiado. Interrumpiendo la espera actual para aplicar los nuevos ajustes."); + _delayCts?.Cancel(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -27,42 +37,26 @@ public class ProcesoScrapingWorker : BackgroundService while (!stoppingToken.IsCancellationRequested) { var configActual = _configuracion.CurrentValue; - _logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", configActual.CantidadTitularesAScrapear); try { - // Creamos un 'scope' para obtener instancias 'scoped' de nuestros servicios. - // Es la práctica correcta en servicios de larga duración. using (var scope = _serviceProvider.CreateScope()) { var repositorio = scope.ServiceProvider.GetRequiredService(); var scrapingService = scope.ServiceProvider.GetRequiredService(); var hubContext = scope.ServiceProvider.GetRequiredService>(); var csvService = scope.ServiceProvider.GetRequiredService(); + int cantidadTitulares = configActual.CantidadTitularesAScrapear; + var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadTitulares); - // Obtener estos valores desde la configuración - int cantidadAObtener = configActual.CantidadTitularesAScrapear; - int limiteTotalEnDb = configActual.LimiteTotalEnDb; + await repositorio.SincronizarDesdeScraping(articulosScrapeados, cantidadTitulares); + _logger.LogInformation("Sincronización con la base de datos completada."); - // 1. Obtener los últimos titulares de la web - var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadAObtener); - - if (articulosScrapeados.Any()) - { - await repositorio.SincronizarDesdeScraping(articulosScrapeados, limiteTotalEnDb); - _logger.LogInformation("Sincronización con la base de datos completada."); - - var titularesActualizados = await repositorio.ObtenerTodosAsync(); - await hubContext.Clients.All.SendAsync("TitularesActualizados", titularesActualizados, stoppingToken); - _logger.LogInformation("Notificación enviada a los clientes."); - - await csvService.GenerarCsvAsync(titularesActualizados); - } - else - { - _logger.LogWarning("No se encontraron artículos en el scraping."); - } + var titularesActualizados = await repositorio.ObtenerTodosAsync(); + await hubContext.Clients.All.SendAsync("TitularesActualizados", titularesActualizados, stoppingToken); + _logger.LogInformation("Notificación enviada a los clientes."); + await csvService.GenerarCsvAsync(titularesActualizados); } } catch (Exception ex) @@ -72,7 +66,29 @@ public class ProcesoScrapingWorker : BackgroundService var intervaloEnMinutos = configActual.IntervaloMinutos; _logger.LogInformation("Proceso en espera por {minutos} minutos.", intervaloEnMinutos); - await Task.Delay(TimeSpan.FromMinutes(intervaloEnMinutos), stoppingToken); + + try + { + _delayCts = new CancellationTokenSource(); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _delayCts.Token); + await Task.Delay(TimeSpan.FromMinutes(intervaloEnMinutos), linkedCts.Token); + } + catch (TaskCanceledException) + { + _logger.LogInformation("La espera fue interrumpida. Reiniciando el ciclo."); + } + finally + { + _delayCts?.Dispose(); + _delayCts = null; + } } } + + public override void Dispose() + { + _optionsReloadToken?.Dispose(); + _delayCts?.Dispose(); + base.Dispose(); + } } \ No newline at end of file diff --git a/backend/src/Titulares.Api/configuracion.json b/backend/src/Titulares.Api/configuracion.json index b2369f1..a04d7db 100644 --- a/backend/src/Titulares.Api/configuracion.json +++ b/backend/src/Titulares.Api/configuracion.json @@ -1,6 +1,5 @@ { "RutaCsv": "C:\\temp\\titulares.csv", "IntervaloMinutos": 5, - "CantidadTitularesAScrapear": 5, - "LimiteTotalEnDb": 50 + "CantidadTitularesAScrapear": 5 } \ No newline at end of file diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index a21203f..827d9b2 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -11,11 +11,13 @@ import { useSignalR } from '../hooks/useSignalR'; import FormularioConfiguracion from './FormularioConfiguracion'; import TablaTitulares from './TablaTitulares'; import AddTitularModal from './AddTitularModal'; +import EditarTitularModal from './EditarTitularModal'; const Dashboard = () => { const [titulares, setTitulares] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); + const [titularAEditar, setTitularAEditar] = useState(null); // Usamos useCallback para que la función de callback no se recree en cada render, // evitando que el useEffect del hook se ejecute innecesariamente. @@ -95,6 +97,15 @@ const Dashboard = () => { } }; + const handleSaveEdit = async (id: number, texto: string, viñeta: string) => { + try { + await api.actualizarTitular(id, { texto, viñeta: viñeta || null }); + // SignalR se encargará de actualizar la UI + } catch (err) { + console.error("Error al guardar cambios:", err); + } + }; + return ( <> @@ -120,8 +131,25 @@ const Dashboard = () => { - - setModalOpen(false)} onAdd={handleAdd} /> + setTitularAEditar(titular)} + /> + + setModalOpen(false)} + onAdd={handleAdd} + /> + + setTitularAEditar(null)} + onSave={handleSaveEdit} + titular={titularAEditar} + /> ); }; diff --git a/frontend/src/components/EditarTitularModal.tsx b/frontend/src/components/EditarTitularModal.tsx new file mode 100644 index 0000000..9f9c4bd --- /dev/null +++ b/frontend/src/components/EditarTitularModal.tsx @@ -0,0 +1,66 @@ +// frontend/src/components/EditarTitularModal.tsx + +import { useEffect, useState } from 'react'; +import { Modal, Box, Typography, TextField, Button } from '@mui/material'; +import type { Titular } from '../types'; + +const style = { + position: 'absolute' as 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 400, + bgcolor: 'background.paper', + boxShadow: 24, + p: 4, +}; + +interface Props { + open: boolean; + onClose: () => void; + onSave: (id: number, texto: string, viñeta: string) => void; + titular: Titular | null; +} + +const EditarTitularModal = ({ open, onClose, onSave, titular }: Props) => { + const [texto, setTexto] = useState(''); + const [viñeta, setViñeta] = useState(''); + + // Este efecto actualiza el estado del formulario cuando se selecciona un titular para editar + useEffect(() => { + if (titular) { + setTexto(titular.texto); + setViñeta(titular.viñeta ?? '•'); // Default a '•' si es nulo + } + }, [titular]); + + const handleSave = () => { + if (titular && texto.trim()) { + onSave(titular.id, texto.trim(), viñeta.trim()); + onClose(); + } + }; + + return ( + + + Editar Titular + setTexto(e.target.value)} + /> + setViñeta(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + /> + + + + + + + ); +}; + +export default EditarTitularModal; \ No newline at end of file diff --git a/frontend/src/components/TablaTitulares.tsx b/frontend/src/components/TablaTitulares.tsx index 6ef7619..489cb0b 100644 --- a/frontend/src/components/TablaTitulares.tsx +++ b/frontend/src/components/TablaTitulares.tsx @@ -9,14 +9,16 @@ import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type D import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { Titular } from '../types'; +import EditIcon from '@mui/icons-material/Edit'; // La prop `onDelete` se añade para comunicar el evento al componente padre interface SortableRowProps { titular: Titular; onDelete: (id: number) => void; + onEdit: (titular: Titular) => void; } -const SortableRow = ({ titular, onDelete }: SortableRowProps) => { +const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: titular.id }); const style = { @@ -42,7 +44,9 @@ const SortableRow = ({ titular, onDelete }: SortableRowProps) => { {titular.fuente} - {/* Usamos un stopPropagation para que el clic no active el arrastre */} + { e.stopPropagation(); onEdit(titular); }}> + + { e.stopPropagation(); onDelete(titular.id); }}> @@ -55,9 +59,10 @@ interface TablaTitularesProps { titulares: Titular[]; onReorder: (titulares: Titular[]) => void; onDelete: (id: number) => void; + onEdit: (titular: Titular) => void; } -const TablaTitulares = ({ titulares, onReorder, onDelete }: TablaTitularesProps) => { +const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitularesProps) => { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); // Evita activar el drag con un simple clic const handleDragEnd = (event: DragEndEvent) => { @@ -95,7 +100,7 @@ const TablaTitulares = ({ titulares, onReorder, onDelete }: TablaTitularesProps) {titulares.map((titular) => ( - + ))} diff --git a/frontend/src/services/apiService.ts b/frontend/src/services/apiService.ts index 75e1d37..c1c2033 100644 --- a/frontend/src/services/apiService.ts +++ b/frontend/src/services/apiService.ts @@ -10,6 +10,11 @@ const apiClient = axios.create({ headers: { 'Content-Type': 'application/json' }, }); +export interface ActualizarTitularPayload { + texto: string; + viñeta: string | null; +} + export const obtenerTitulares = async (): Promise => { const response = await apiClient.get('/titulares'); return response.data; @@ -43,4 +48,8 @@ export const guardarConfiguracion = async (config: Configuracion): Promise export const generarCsvManual = async (): Promise => { await apiClient.post('/acciones/generar-csv'); +}; + +export const actualizarTitular = async (id: number, payload: ActualizarTitularPayload): Promise => { + await apiClient.put(`/titulares/${id}`, payload); }; \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d4a36f6..53fb9af 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -10,11 +10,12 @@ export interface Titular { fechaCreacion: string; tipo: 'Scraped' | 'Edited' | 'Manual' | null; fuente: string | null; + viñeta: string | null; } export interface Configuracion { rutaCsv: string; intervaloMinutos: number; cantidadTitularesAScrapear: number; - limiteTotalEnDb: number; + //limiteTotalEnDb: number; } \ No newline at end of file