using System.Diagnostics; using System.Text.RegularExpressions; using Microsoft.Data.Sqlite; namespace PrepStep; 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 { private static readonly Regex FilenameRegex = new(@"^(?.+)\.(?[A-Za-z0-9]+)\.(?\d+)$", RegexOptions.Compiled); internal 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); -- DROP+CREATE invece di "IF NOT EXISTS": la vista e' ricalcolata a ogni avvio di scan, -- cosi' un DB gia' esistente prende la definizione corrente senza reindicizzare. DROP VIEW IF EXISTS files_ultima_versione; CREATE VIEW 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 -- A parita' di versione (stesso codice in due sottocartelle della stessa -- radice, es. una copia vecchia in una cartella di lavoro) vince il file -- modificato piu' di recente: senza questo criterio il pareggio e' rotto in -- modo arbitrario e puo' vincere la copia obsoleta, nascondendo del tutto -- quella buona. Il file copiato in "raw" sarebbe allora un modello diverso da -- quello che l'assieme si aspetta, e Creo non riesce ad aprirlo. ORDER BY versione DESC, data_modifica DESC, percorso_completo ) 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(); } // Le cartelle "temp_*" (temp_paris, temp_davide, temp_studi esterni, ...) sono lavori // personali/temporanei: contengono spesso copie datate o versioni scollegate di codici // che esistono ANCHE nel resto della share, con lo stesso codice ma contenuto diverso. // Un .asm nella struttura "ufficiale" può referenziare per coincidenza numerica un file // dentro una di queste cartelle (vedi docs/formato-asm.md, falso positivo su // "2502020809"), trascinando dentro un intero assieme di un progetto scollegato. Vanno // escluse a monte, non solo filtrate caso per caso nelle singole query. using (var deleteTempCmd = connection.CreateCommand()) { deleteTempCmd.CommandText = @"DELETE FROM files WHERE cartella_radice LIKE 'temp\_%' ESCAPE '\'"; deleteTempCmd.ExecuteNonQuery(); } // Indicizza tutte le cartelle di primo livello sotto la radice (non un elenco fisso), // così una nuova cartella creata sulla share entra nell'indice senza modificare il codice. var cartelleRadice = Directory.GetDirectories(root) .Select(p => Path.GetFileName(p)!) .Where(c => !c.StartsWith("temp_", StringComparison.OrdinalIgnoreCase)) .ToArray(); 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; } internal 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; } } } } }