commit 82a69e0d2020c405d6ce0a51a8cd599f6a5a0fca Author: Davide Grilli Date: Wed Sep 16 09:58:55 2026 +0200 Add CadSearchGui: WinForms app to index and search the CAD archive Indicizza le cartelle __Accessori, __B.end, __Corpi, __Fondi in un DB SQLite locale ed espone una GUI di ricerca (codice, tipo, cartella, solo ultima versione) con aggiornamento incrementale dell'indice e stima del tempo rimanente. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87c9f54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Build output +bin/ +obj/ +publish/ + +# Visual Studio / Rider +.vs/ +*.user +*.suo + +# Database generato dallo scan (rigenerabile con "Aggiorna indice") +*.db +*.db-journal +*.db-wal +*.db-shm + +# Log +*.log + +# Stato locale degli strumenti agentici +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1efdb3f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Cos'è questo progetto + +`CadSearchGui` è un'app Windows Forms (.NET 10, WinForms) che indicizza e permette di +cercare i file CAD Pro-ENGINEER/Creo contenuti in quattro cartelle di un disco/share +di rete (`__Accessori`, `__B.end`, `__Corpi`, `__Fondi` sotto una radice, di default +`X:\`), salvando i metadati in un DB SQLite locale (`cad_index.db`). + +## Comandi + +Tutti da eseguire dentro `CadSearchGui/`. + +```powershell +dotnet build -c Release +dotnet run -c Release +``` + +Pubblicazione come eseguibile standalone (single-file, self-contained, Windows x64): + +```powershell +dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish +``` + +Non ci sono test automatizzati nel repo. + +## Architettura + +Il programma è tutto in `CadSearchGui/` (nessun altro progetto nella solution — in +passato esisteva anche una CLI separata e uno script Python di indicizzazione, +entrambi rimossi: ora l'unico programma è questo, con GUI e indicizzazione integrate). + +- **Program.cs** — entry point WinForms standard, avvia `Form1`. +- **Form1.cs** / **Form1.Designer.cs** — finestra principale. La UI è costruita + interamente in codice in `Form1.BuildUi()` (nessun designer visuale usato); i + controlli sono campi privati (`_txtCerca`, `_cmbTipo`, `_cmbCartella`, `_chkUltima`, + `_grid`, ecc.). `Form1.Designer.cs` contiene solo lo scheletro minimo + (`InitializeComponent`/`Dispose`) generato dal template. +- **Indexer.cs** — logica di scansione del disco e scrittura/aggiornamento del DB + SQLite (`Indexer.ScanAsync`). Cammina le 4 cartelle radice (`Indexer.CartelleRadice`) + con un walk manuale a stack (`Walk`, più veloce di `EnumerateFiles(..., AllDirectories)` + su share di rete con centinaia di migliaia di file), estrae da ogni nome file il + pattern Pro-E `..` via regex, e fa un upsert incrementale + riga per riga (confronto per `percorso_completo` + dimensione + data modifica). + Riporta il progresso tramite `IProgress`, marshalato automaticamente + sul thread UI. Contiene anche lo schema SQL del DB (tabella `files` + vista + `files_ultima_versione`, che seleziona solo l'ultima versione per + `(cartella_radice, codice, tipo_file)` usando `ROW_NUMBER() OVER (...)` — non un + self-join con `GROUP BY`, che su ~200k righe degenera in tempi quasi quadratici). +- **ProgressForm.cs** — dialog modale mostrato durante l'aggiornamento indice: barra + di progresso, conteggi nuovi/aggiornati/invariati/rimossi, tempo trascorso e stima + del tempo rimanente (calcolata dal ritmo file/secondo rispetto al totale atteso, + preso dal conteggio della scansione precedente salvato nel DB). + +### Schema del DB (`cad_index.db`) + +Tabella `files`: un record per ogni file trovato, con `percorso_completo` (chiave +univoca), `cartella_radice`, `famiglia` (primo livello di sottocartella), +`codice`/`tipo_file`/`versione` estratti dal nome file, dimensione, data modifica e +timestamp dell'ultima scansione (`scansionato_il`, usato per rilevare ed eliminare i +file non più presenti su disco a fine scansione). + +Vista `files_ultima_versione`: stessa struttura, filtrata alla sola versione più +recente per codice+tipo. + +### Comportamento di ricerca di default + +La ricerca di default (nessun filtro esplicito dall'utente) mostra solo i tipi +`prt`/`asm` e solo l'ultima versione — vedi `OpzionePrtAsm`/`TipiPredefiniti` in +`Form1.cs`. L'utente può comunque scegliere "Tutti i tipi" o un tipo specifico dal +combo, e togliere la spunta "Solo ultima versione". + +### Percorso del DB + +All'avvio la app cerca `cad_index.db` accanto all'eseguibile o nella working +directory corrente; se non lo trova, l'utente può selezionarlo con "Apri DB...". +Il bottone "Aggiorna indice" rilancia `Indexer.ScanAsync` sullo stesso path (radice +disco di default `X:\`, hardcoded in `Form1.RadiceDiscoDefault`). diff --git a/CadSearchGui/CadSearchGui.csproj b/CadSearchGui/CadSearchGui.csproj new file mode 100644 index 0000000..4093c4e --- /dev/null +++ b/CadSearchGui/CadSearchGui.csproj @@ -0,0 +1,15 @@ + + + + WinExe + net10.0-windows + enable + true + enable + + + + + + + \ No newline at end of file diff --git a/CadSearchGui/Form1.Designer.cs b/CadSearchGui/Form1.Designer.cs new file mode 100644 index 0000000..f91b798 --- /dev/null +++ b/CadSearchGui/Form1.Designer.cs @@ -0,0 +1,30 @@ +#nullable enable + +namespace CadSearchGui; + +partial class Form1 +{ + private System.ComponentModel.IContainer? components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + SuspendLayout(); + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(1000, 650); + MinimumSize = new Size(700, 400); + Name = "Form1"; + Text = "CadSearch — ricerca archivio CAD"; + ResumeLayout(false); + } +} diff --git a/CadSearchGui/Form1.cs b/CadSearchGui/Form1.cs new file mode 100644 index 0000000..00638ad --- /dev/null +++ b/CadSearchGui/Form1.cs @@ -0,0 +1,407 @@ +using System.Diagnostics; +using Microsoft.Data.Sqlite; + +namespace CadSearchGui; + +public partial class Form1 : Form +{ + private readonly TextBox _txtCerca = new(); + private readonly ComboBox _cmbTipo = new(); + private readonly ComboBox _cmbCartella = new(); + private readonly CheckBox _chkUltima = new(); + private readonly Button _btnCerca = new(); + private readonly Button _btnApriDb = new(); + private readonly Button _btnAggiorna = new(); + private readonly DataGridView _grid = new(); + private readonly Label _lblStatus = new(); + private readonly Label _lblDb = new(); + + private const string OpzionePrtAsm = "Solo prt/asm (predefinito)"; + private const string OpzioneTuttiTipi = "Tutti i tipi"; + private static readonly string[] TipiPredefiniti = { "prt", "asm" }; + private const string RadiceDiscoDefault = @"X:\"; + + private string? _dbPath; + + public Form1() + { + InitializeComponent(); + BuildUi(); + Load += (_, _) => InizializzaDatabase(); + } + + private void BuildUi() + { + var topPanel = new TableLayoutPanel + { + Dock = DockStyle.Top, + Height = 76, + ColumnCount = 7, + Padding = new Padding(8), + RowCount = 2, + }; + topPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40)); + topPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 15)); + topPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 20)); + topPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 130)); + topPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 90)); + topPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110)); + topPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 130)); + + _txtCerca.Dock = DockStyle.Fill; + _txtCerca.Font = new Font(_txtCerca.Font!.FontFamily, 11f); + _txtCerca.PlaceholderText = "Cerca per codice o nome file (es. 9231000036)..."; + _txtCerca.KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Enter) + { + e.SuppressKeyPress = true; + EseguiRicerca(); + } + }; + + _cmbTipo.Dock = DockStyle.Fill; + _cmbTipo.DropDownStyle = ComboBoxStyle.DropDownList; + _cmbTipo.Items.Add(OpzionePrtAsm); + _cmbTipo.Items.Add(OpzioneTuttiTipi); + + _cmbCartella.Dock = DockStyle.Fill; + _cmbCartella.DropDownStyle = ComboBoxStyle.DropDownList; + _cmbCartella.Items.Add("Tutte le cartelle"); + _cmbCartella.Items.AddRange(new object[] { "__Accessori", "__B.end", "__Corpi", "__Fondi" }); + _cmbCartella.SelectedIndex = 0; + + _chkUltima.Text = "Solo ultima versione"; + _chkUltima.Dock = DockStyle.Fill; + _chkUltima.TextAlign = ContentAlignment.MiddleLeft; + _chkUltima.Checked = true; + + _btnCerca.Text = "Cerca"; + _btnCerca.Dock = DockStyle.Fill; + _btnCerca.Click += (_, _) => EseguiRicerca(); + + _btnApriDb.Text = "Apri DB..."; + _btnApriDb.Dock = DockStyle.Fill; + _btnApriDb.Click += (_, _) => ScegliDatabase(); + + _btnAggiorna.Text = "Aggiorna indice"; + _btnAggiorna.Dock = DockStyle.Fill; + _btnAggiorna.Click += (_, _) => AggiornaIndice(); + + topPanel.Controls.Add(_txtCerca, 0, 0); + topPanel.Controls.Add(_cmbTipo, 1, 0); + topPanel.Controls.Add(_cmbCartella, 2, 0); + topPanel.Controls.Add(_chkUltima, 3, 0); + topPanel.Controls.Add(_btnCerca, 4, 0); + topPanel.Controls.Add(_btnApriDb, 5, 0); + topPanel.Controls.Add(_btnAggiorna, 6, 0); + topPanel.SetColumnSpan(_lblDb, 7); + + _lblDb.Dock = DockStyle.Fill; + _lblDb.AutoSize = false; + _lblDb.ForeColor = SystemColors.GrayText; + _lblDb.TextAlign = ContentAlignment.MiddleLeft; + topPanel.Controls.Add(_lblDb, 0, 1); + + _grid.Dock = DockStyle.Fill; + _grid.ReadOnly = true; + _grid.AllowUserToAddRows = false; + _grid.AllowUserToDeleteRows = false; + _grid.AllowUserToResizeRows = false; + _grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + _grid.MultiSelect = false; + _grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; + _grid.CellDoubleClick += (_, e) => ApriCartellaFileSelezionato(); + + var statusPanel = new Panel { Dock = DockStyle.Bottom, Height = 28 }; + _lblStatus.Dock = DockStyle.Fill; + _lblStatus.TextAlign = ContentAlignment.MiddleLeft; + _lblStatus.Padding = new Padding(8, 0, 0, 0); + _lblStatus.Text = "Pronto."; + statusPanel.Controls.Add(_lblStatus); + + Controls.Add(_grid); + Controls.Add(statusPanel); + Controls.Add(topPanel); + } + + private void InizializzaDatabase() + { + var candidati = new[] + { + Path.Combine(AppContext.BaseDirectory, "cad_index.db"), + Path.Combine(Directory.GetCurrentDirectory(), "cad_index.db"), + }; + _dbPath = candidati.FirstOrDefault(File.Exists); + + if (_dbPath is null) + { + _lblStatus.Text = "Database non trovato. Usa 'Apri DB...' per selezionarlo."; + _lblDb.Text = "Nessun database caricato."; + return; + } + + CaricaDatabase(_dbPath); + } + + private void ScegliDatabase() + { + using var dialog = new OpenFileDialog + { + Filter = "Database SQLite (*.db)|*.db|Tutti i file (*.*)|*.*", + Title = "Seleziona cad_index.db", + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + { + CaricaDatabase(dialog.FileName); + } + } + + private void CaricaDatabase(string path) + { + try + { + using var connection = new SqliteConnection($"Data Source={path};Mode=ReadOnly"); + connection.Open(); + + _cmbTipo.Items.Clear(); + _cmbTipo.Items.Add(OpzionePrtAsm); + _cmbTipo.Items.Add(OpzioneTuttiTipi); + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "SELECT DISTINCT tipo_file FROM files ORDER BY tipo_file"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + _cmbTipo.Items.Add(reader.GetString(0)); + } + } + _cmbTipo.SelectedIndex = 0; + + long totale = (long)(new SqliteCommand("SELECT COUNT(*) FROM files", connection).ExecuteScalar() ?? 0L); + + _dbPath = path; + _lblDb.Text = $"Database: {path} ({totale:N0} file indicizzati)"; + _lblStatus.Text = "Pronto."; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Impossibile aprire il database:\n{ex.Message}", "Errore", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private async void AggiornaIndice() + { + string dbPath = _dbPath ?? Path.Combine(AppContext.BaseDirectory, "cad_index.db"); + + if (!Directory.Exists(RadiceDiscoDefault)) + { + MessageBox.Show(this, $"Disco non raggiungibile: {RadiceDiscoDefault}", "Errore", + MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + _btnAggiorna.Enabled = false; + _btnCerca.Enabled = false; + _lblStatus.Text = "Aggiornamento indice in corso..."; + + using var cts = new CancellationTokenSource(); + using var dialog = new ProgressForm(); + var progress = new Progress(dialog.Aggiorna); + dialog.Annullato += (_, _) => cts.Cancel(); + + var scanTask = Indexer.ScanAsync(RadiceDiscoDefault, dbPath, progress, cts.Token); + + // Chiude automaticamente il dialog quando lo scan termina (successo, errore o annullamento) + _ = scanTask.ContinueWith(_ => + { + if (dialog.Visible) + { + dialog.BeginInvoke(dialog.Close); + } + }, TaskScheduler.Default); + + dialog.ShowDialog(this); + + try + { + await scanTask; + _lblStatus.Text = "Indice aggiornato."; + CaricaDatabase(dbPath); + } + catch (OperationCanceledException) + { + _lblStatus.Text = "Aggiornamento annullato."; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Errore durante l'aggiornamento dell'indice:\n{ex.Message}", "Errore", + MessageBoxButtons.OK, MessageBoxIcon.Error); + _lblStatus.Text = "Aggiornamento fallito."; + } + finally + { + _btnAggiorna.Enabled = true; + _btnCerca.Enabled = true; + } + } + + private void EseguiRicerca() + { + if (_dbPath is null) + { + _lblStatus.Text = "Nessun database caricato. Usa 'Apri DB...'."; + return; + } + + string testo = _txtCerca.Text.Trim(); + string? tipoSelezionato = (string?)_cmbTipo.SelectedItem; + string[]? tipi = tipoSelezionato switch + { + OpzionePrtAsm => TipiPredefiniti, + OpzioneTuttiTipi => null, + null => TipiPredefiniti, + _ => new[] { tipoSelezionato }, + }; + string? cartella = _cmbCartella.SelectedIndex > 0 ? (string)_cmbCartella.SelectedItem! : null; + bool soloUltima = _chkUltima.Checked; + + if (testo.Length == 0 && tipi is null && cartella is null) + { + _lblStatus.Text = "Inserisci un termine di ricerca o imposta un filtro."; + return; + } + + Cursor = Cursors.WaitCursor; + _btnCerca.Enabled = false; + try + { + var (colonne, righe, troncato) = Cerca(_dbPath, testo, soloUltima, cartella, tipi); + PopolaGriglia(colonne, righe); + _lblStatus.Text = $"{righe.Count} risultat{(righe.Count == 1 ? "o" : "i")}" + + (troncato ? " (troncato a 500, affina la ricerca)" : ""); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Errore durante la ricerca:\n{ex.Message}", "Errore", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + Cursor = Cursors.Default; + _btnCerca.Enabled = true; + } + } + + private static (string[] colonne, List righe, bool troncato) Cerca( + string dbPath, string testo, bool soloUltimaVersione, string? cartella, string[]? tipi) + { + using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly"); + connection.Open(); + + string tabella = soloUltimaVersione ? "files_ultima_versione" : "files"; + string filtroTipo = "1=1"; + if (tipi is { Length: > 0 }) + { + var nomiParametri = tipi.Select((_, i) => $"@tipo{i}"); + filtroTipo = $"tipo_file IN ({string.Join(",", nomiParametri)})"; + } + + var sql = $""" + SELECT percorso_completo, cartella_radice, famiglia, codice, tipo_file, versione, + dimensione_byte, data_modifica + FROM {tabella} + WHERE (@testo = '' OR codice LIKE @like OR nome_file LIKE @like) + AND (@cartella IS NULL OR cartella_radice = @cartella) + AND ({filtroTipo}) + ORDER BY codice, tipo_file, versione + LIMIT 500 + """; + + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("@testo", testo); + command.Parameters.AddWithValue("@like", $"%{testo}%"); + command.Parameters.AddWithValue("@cartella", (object?)cartella ?? DBNull.Value); + if (tipi is { Length: > 0 }) + { + for (int i = 0; i < tipi.Length; i++) + { + command.Parameters.AddWithValue($"@tipo{i}", tipi[i]); + } + } + + using var reader = command.ExecuteReader(); + var righe = new List(); + while (reader.Read()) + { + righe.Add(new[] + { + reader.GetString(0), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? "" : reader.GetString(4), + reader.IsDBNull(5) ? "" : reader.GetInt64(5).ToString(), + FormatSize(reader.IsDBNull(6) ? 0 : reader.GetInt64(6)), + reader.IsDBNull(7) ? "" : reader.GetString(7), + }); + } + + var colonne = new[] { "percorso", "famiglia", "codice", "tipo", "ver", "dimensione", "data modifica" }; + return (colonne, righe, righe.Count == 500); + } + + private static string FormatSize(long bytes) + { + string[] unita = { "B", "KB", "MB", "GB" }; + double value = bytes; + int i = 0; + while (value >= 1024 && i < unita.Length - 1) + { + value /= 1024; + i++; + } + return $"{value:0.#} {unita[i]}"; + } + + private void PopolaGriglia(string[] colonne, List righe) + { + _grid.Rows.Clear(); + _grid.Columns.Clear(); + foreach (var nome in colonne) + { + _grid.Columns.Add(nome, nome); + } + foreach (var riga in righe) + { + _grid.Rows.Add(riga); + } + if (_grid.Columns.Count > 0) + { + _grid.Columns[0].FillWeight = 220; + } + } + + private void ApriCartellaFileSelezionato() + { + if (_grid.CurrentRow is null) + { + return; + } + string? percorso = _grid.CurrentRow.Cells[0].Value as string; + if (string.IsNullOrEmpty(percorso)) + { + return; + } + try + { + Process.Start("explorer.exe", $"/select,\"{percorso}\""); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Impossibile aprire Esplora risorse:\n{ex.Message}", "Errore", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } +} diff --git a/CadSearchGui/Indexer.cs b/CadSearchGui/Indexer.cs new file mode 100644 index 0000000..ccbc4e3 --- /dev/null +++ b/CadSearchGui/Indexer.cs @@ -0,0 +1,277 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using Microsoft.Data.Sqlite; + +namespace CadSearchGui; + +public sealed record ScanProgress( + string Cartella, + long FattiCartella, + long PrevistiCartella, + long FattiTotali, + long PrevistiTotali, + int Nuovi, + int Aggiornati, + int Invariati, + int Rimossi, + TimeSpan Trascorso); + +public static class Indexer +{ + public static readonly string[] CartelleRadice = { "__Accessori", "__B.end", "__Corpi", "__Fondi" }; + + private static readonly Regex FilenameRegex = + new(@"^(?.+)\.(?[A-Za-z0-9]+)\.(?\d+)$", RegexOptions.Compiled); + + private const string Schema = """ + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + percorso_completo TEXT UNIQUE NOT NULL, + percorso_relativo TEXT, + nome_file TEXT, + cartella_radice TEXT, + famiglia TEXT, + codice TEXT, + tipo_file TEXT, + versione INTEGER, + dimensione_byte INTEGER, + data_modifica TEXT, + scansionato_il TEXT + ); + CREATE INDEX IF NOT EXISTS idx_files_codice ON files(codice); + CREATE INDEX IF NOT EXISTS idx_files_tipo ON files(tipo_file); + CREATE INDEX IF NOT EXISTS idx_files_cartella ON files(cartella_radice); + + CREATE VIEW IF NOT EXISTS files_ultima_versione AS + SELECT percorso_completo, percorso_relativo, nome_file, cartella_radice, famiglia, + codice, tipo_file, versione, dimensione_byte, data_modifica, scansionato_il + FROM ( + SELECT f.*, ROW_NUMBER() OVER ( + PARTITION BY cartella_radice, codice, tipo_file + ORDER BY versione DESC + ) AS rn + FROM files f + ) WHERE rn = 1; + """; + + public static Task ScanAsync(string root, string dbPath, IProgress progress, CancellationToken ct) + => Task.Run(() => Scan(root, dbPath, progress, ct), ct); + + private static void Scan(string root, string dbPath, IProgress progress, CancellationToken ct) + { + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + using (var schemaCmd = connection.CreateCommand()) + { + schemaCmd.CommandText = Schema; + schemaCmd.ExecuteNonQuery(); + } + + var previsti = new Dictionary(); + foreach (var cartella in CartelleRadice) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM files WHERE cartella_radice = @c"; + cmd.Parameters.AddWithValue("@c", cartella); + previsti[cartella] = (long)(cmd.ExecuteScalar() ?? 0L); + } + long previstiTotali = previsti.Values.Sum(); + + var sw = Stopwatch.StartNew(); + long fattiTotali = 0; + + foreach (var cartella in CartelleRadice) + { + ct.ThrowIfCancellationRequested(); + + string rootPath = Path.Combine(root, cartella); + if (!Directory.Exists(rootPath)) + { + continue; + } + + string now = DateTime.UtcNow.ToString("o"); + int nuovi = 0, aggiornati = 0, invariati = 0; + long fattiCartella = 0; + + using var transaction = connection.BeginTransaction(); + + using var cmdSelect = connection.CreateCommand(); + cmdSelect.Transaction = transaction; + cmdSelect.CommandText = "SELECT dimensione_byte, data_modifica FROM files WHERE percorso_completo = @p"; + var pSelect = cmdSelect.Parameters.Add("@p", Microsoft.Data.Sqlite.SqliteType.Text); + + using var cmdInsert = connection.CreateCommand(); + cmdInsert.Transaction = transaction; + cmdInsert.CommandText = """ + INSERT INTO files + (percorso_completo, percorso_relativo, nome_file, cartella_radice, famiglia, + codice, tipo_file, versione, dimensione_byte, data_modifica, scansionato_il) + VALUES (@percorso, @relativo, @nome, @cartella, @famiglia, @codice, @tipo, @versione, + @dimensione, @modifica, @scansionato) + """; + AggiungiParametriRecord(cmdInsert); + cmdInsert.Parameters.AddWithValue("@scansionato", now); + + using var cmdUpdate = connection.CreateCommand(); + cmdUpdate.Transaction = transaction; + cmdUpdate.CommandText = """ + UPDATE files SET percorso_relativo=@relativo, nome_file=@nome, cartella_radice=@cartella, + famiglia=@famiglia, codice=@codice, tipo_file=@tipo, versione=@versione, + dimensione_byte=@dimensione, data_modifica=@modifica, scansionato_il=@scansionato + WHERE percorso_completo=@percorso + """; + AggiungiParametriRecord(cmdUpdate); + cmdUpdate.Parameters.AddWithValue("@scansionato", now); + + using var cmdTouch = connection.CreateCommand(); + cmdTouch.Transaction = transaction; + cmdTouch.CommandText = "UPDATE files SET scansionato_il=@scansionato WHERE percorso_completo=@percorso"; + cmdTouch.Parameters.AddWithValue("@scansionato", now); + var pTouchPercorso = cmdTouch.Parameters.Add("@percorso", Microsoft.Data.Sqlite.SqliteType.Text); + + foreach (var file in Walk(rootPath)) + { + ct.ThrowIfCancellationRequested(); + + string percorsoCompleto = file.FullName; + string percorsoRelativo = Path.GetRelativePath(rootPath, percorsoCompleto); + string nomeFile = file.Name; + string[] parti = percorsoRelativo.Split(Path.DirectorySeparatorChar); + string? famiglia = parti.Length > 1 ? parti[0] : null; + var (codice, tipo, versione) = ParseFilename(nomeFile); + long dimensione = file.Length; + string modifica = file.LastWriteTimeUtc.ToString("o"); + + pSelect.Value = percorsoCompleto; + object? esistenteDim = null; + string? esistenteMod = null; + bool esiste = false; + using (var reader = cmdSelect.ExecuteReader()) + { + if (reader.Read()) + { + esiste = true; + esistenteDim = reader.IsDBNull(0) ? null : reader.GetInt64(0); + esistenteMod = reader.IsDBNull(1) ? null : reader.GetString(1); + } + } + + if (!esiste) + { + ImpostaParametriRecord(cmdInsert, percorsoCompleto, percorsoRelativo, nomeFile, cartella, + famiglia, codice, tipo, versione, dimensione, modifica); + cmdInsert.ExecuteNonQuery(); + nuovi++; + } + else if (!(esistenteDim is long d && d == dimensione && esistenteMod == modifica)) + { + ImpostaParametriRecord(cmdUpdate, percorsoCompleto, percorsoRelativo, nomeFile, cartella, + famiglia, codice, tipo, versione, dimensione, modifica); + cmdUpdate.ExecuteNonQuery(); + aggiornati++; + } + else + { + pTouchPercorso.Value = percorsoCompleto; + cmdTouch.ExecuteNonQuery(); + invariati++; + } + + fattiCartella++; + fattiTotali++; + + if (fattiTotali % 200 == 0) + { + progress.Report(new ScanProgress(cartella, fattiCartella, previsti[cartella], + fattiTotali, previstiTotali, nuovi, aggiornati, invariati, 0, sw.Elapsed)); + } + } + + using (var cmdDelete = connection.CreateCommand()) + { + cmdDelete.Transaction = transaction; + cmdDelete.CommandText = "DELETE FROM files WHERE cartella_radice = @c AND scansionato_il != @now"; + cmdDelete.Parameters.AddWithValue("@c", cartella); + cmdDelete.Parameters.AddWithValue("@now", now); + int rimossi = cmdDelete.ExecuteNonQuery(); + + transaction.Commit(); + + progress.Report(new ScanProgress(cartella, fattiCartella, previsti[cartella], + fattiTotali, previstiTotali, nuovi, aggiornati, invariati, rimossi, sw.Elapsed)); + } + } + } + + private static void AggiungiParametriRecord(SqliteCommand cmd) + { + cmd.Parameters.Add("@percorso", Microsoft.Data.Sqlite.SqliteType.Text); + cmd.Parameters.Add("@relativo", Microsoft.Data.Sqlite.SqliteType.Text); + cmd.Parameters.Add("@nome", Microsoft.Data.Sqlite.SqliteType.Text); + cmd.Parameters.Add("@cartella", Microsoft.Data.Sqlite.SqliteType.Text); + cmd.Parameters.Add("@famiglia", Microsoft.Data.Sqlite.SqliteType.Text); + cmd.Parameters.Add("@codice", Microsoft.Data.Sqlite.SqliteType.Text); + cmd.Parameters.Add("@tipo", Microsoft.Data.Sqlite.SqliteType.Text); + cmd.Parameters.Add("@versione", Microsoft.Data.Sqlite.SqliteType.Integer); + cmd.Parameters.Add("@dimensione", Microsoft.Data.Sqlite.SqliteType.Integer); + cmd.Parameters.Add("@modifica", Microsoft.Data.Sqlite.SqliteType.Text); + } + + private static void ImpostaParametriRecord(SqliteCommand cmd, string percorso, string relativo, string nome, + string cartella, string? famiglia, string codice, string tipo, int? versione, long dimensione, string modifica) + { + cmd.Parameters["@percorso"].Value = percorso; + cmd.Parameters["@relativo"].Value = relativo; + cmd.Parameters["@nome"].Value = nome; + cmd.Parameters["@cartella"].Value = cartella; + cmd.Parameters["@famiglia"].Value = (object?)famiglia ?? DBNull.Value; + cmd.Parameters["@codice"].Value = codice; + cmd.Parameters["@tipo"].Value = tipo; + cmd.Parameters["@versione"].Value = (object?)versione ?? DBNull.Value; + cmd.Parameters["@dimensione"].Value = dimensione; + cmd.Parameters["@modifica"].Value = modifica; + } + + private static (string codice, string tipo, int? versione) ParseFilename(string nomeFile) + { + var match = FilenameRegex.Match(nomeFile); + if (match.Success) + { + return (match.Groups["codice"].Value, match.Groups["tipo"].Value, + int.Parse(match.Groups["versione"].Value)); + } + return (nomeFile, "altro", null); + } + + private static IEnumerable Walk(string root) + { + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + string current = stack.Pop(); + IEnumerable entries; + try + { + entries = new DirectoryInfo(current).EnumerateFileSystemInfos(); + } + catch (Exception) + { + continue; + } + + foreach (var entry in entries) + { + if (entry is DirectoryInfo dir) + { + stack.Push(dir.FullName); + } + else if (entry is FileInfo file) + { + yield return file; + } + } + } + } +} diff --git a/CadSearchGui/Program.cs b/CadSearchGui/Program.cs new file mode 100644 index 0000000..84ac229 --- /dev/null +++ b/CadSearchGui/Program.cs @@ -0,0 +1,16 @@ +namespace CadSearchGui; + +static class Program +{ + /// + /// The main entry point for the application. + /// + [STAThread] + static void Main() + { + // To customize application configuration such as set high DPI settings or default font, + // see https://aka.ms/applicationconfiguration. + ApplicationConfiguration.Initialize(); + Application.Run(new Form1()); + } +} \ No newline at end of file diff --git a/CadSearchGui/ProgressForm.cs b/CadSearchGui/ProgressForm.cs new file mode 100644 index 0000000..7b445be --- /dev/null +++ b/CadSearchGui/ProgressForm.cs @@ -0,0 +1,110 @@ +namespace CadSearchGui; + +public sealed class ProgressForm : Form +{ + private readonly Label _lblCartella = new(); + private readonly ProgressBar _progressBar = new(); + private readonly Label _lblConteggio = new(); + private readonly Label _lblDettaglio = new(); + private readonly Label _lblEta = new(); + private readonly Button _btnAnnulla = new(); + + public event EventHandler? Annullato; + + public ProgressForm() + { + Text = "Aggiornamento indice in corso..."; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MinimizeBox = false; + MaximizeBox = false; + ControlBox = false; + ClientSize = new Size(460, 190); + + _lblCartella.Text = "Preparazione..."; + _lblCartella.Location = new Point(16, 16); + _lblCartella.AutoSize = true; + _lblCartella.Font = new Font(Font, FontStyle.Bold); + + _progressBar.Location = new Point(16, 44); + _progressBar.Size = new Size(428, 24); + _progressBar.Minimum = 0; + _progressBar.Maximum = 1000; + _progressBar.Style = ProgressBarStyle.Marquee; + + _lblConteggio.Location = new Point(16, 80); + _lblConteggio.AutoSize = true; + + _lblDettaglio.Location = new Point(16, 104); + _lblDettaglio.AutoSize = true; + _lblDettaglio.ForeColor = SystemColors.GrayText; + + _lblEta.Location = new Point(16, 128); + _lblEta.AutoSize = true; + _lblEta.Font = new Font(Font, FontStyle.Bold); + + _btnAnnulla.Text = "Annulla"; + _btnAnnulla.Location = new Point(354, 152); + _btnAnnulla.Size = new Size(90, 28); + _btnAnnulla.Click += (_, e) => Annullato?.Invoke(this, e); + + Controls.AddRange(new Control[] + { + _lblCartella, _progressBar, _lblConteggio, _lblDettaglio, _lblEta, _btnAnnulla + }); + } + + public void Aggiorna(ScanProgress p) + { + _lblCartella.Text = $"Scansione {p.Cartella}..."; + + if (p.PrevistiTotali > 0) + { + _progressBar.Style = ProgressBarStyle.Continuous; + double frazione = Math.Min(1.0, (double)p.FattiTotali / p.PrevistiTotali); + _progressBar.Value = (int)(frazione * _progressBar.Maximum); + } + else + { + _progressBar.Style = ProgressBarStyle.Marquee; + } + + _lblConteggio.Text = p.PrevistiTotali > 0 + ? $"{p.FattiTotali:N0} / ~{p.PrevistiTotali:N0} file" + : $"{p.FattiTotali:N0} file processati"; + + _lblDettaglio.Text = $"nuovi: {p.Nuovi:N0} aggiornati: {p.Aggiornati:N0} invariati: {p.Invariati:N0}" + + (p.Rimossi > 0 ? $" rimossi: {p.Rimossi:N0}" : ""); + + _lblEta.Text = $"Trascorso: {FormatTempo(p.Trascorso)} — {StimaRimanente(p)}"; + } + + private static string StimaRimanente(ScanProgress p) + { + if (p.PrevistiTotali <= 0 || p.FattiTotali <= 0) + { + return "stima non disponibile"; + } + double filePerSecondo = p.FattiTotali / Math.Max(p.Trascorso.TotalSeconds, 0.001); + if (filePerSecondo <= 0) + { + return "stima non disponibile"; + } + long rimanenti = Math.Max(p.PrevistiTotali - p.FattiTotali, 0); + var eta = TimeSpan.FromSeconds(rimanenti / filePerSecondo); + return $"tempo rimanente stimato: {FormatTempo(eta)}"; + } + + private static string FormatTempo(TimeSpan t) + { + if (t.TotalHours >= 1) + { + return $"{(int)t.TotalHours}h {t.Minutes}m {t.Seconds}s"; + } + if (t.TotalMinutes >= 1) + { + return $"{t.Minutes}m {t.Seconds}s"; + } + return $"{t.Seconds}s"; + } +}