Add explicit working folder; rename project from CadSearchGui to PrepStep

Working folder (phase 1): CSV/raw/step/step_finali all used to live split
across two different implicit locations (CSV in the "repo root" found by
walking up for CLAUDE.md, raw/step next to the input distinta file) with
neither shown to the user. That repo-root lookup also silently breaks once
the app runs as a standalone published exe on a machine with no repo/
CLAUDE.md at all. Replaced both with one explicit "cartella di lavoro",
auto-suggested (last used, remembered the same way as the DB path) and
changeable from phase 1, with no dependency on any repo marker file.

Rename: CadSearchGui was the working name from before the app had a
product identity; renamed folders, csproj files, namespace and the
AppData settings folder to PrepStep throughout, matching the exe name
already in use. Product-facing name stays "Cevolani StepPrep".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-21 11:18:43 +02:00
1 parent 6726dd2216
commit 13798ad47c
24 files changed
+211 -129

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
using System.Text;
using System.Text.RegularExpressions;
namespace PrepStep;
/// <summary>
/// Estrae i codici componente referenziati dentro un file .asm Pro-E/Creo.
/// Il formato è per lo più binario, ma i codici dei sotto-componenti compaiono
/// come stringhe ASCII leggibili al suo interno.
///
/// Due famiglie di codici, due strategie:
///
/// 1) La maggioranza (&gt;90%) sono codici numerici Pro-E del gestionale, con una forma
/// nota (stesso stile usato nei nomi file, vedi Indexer.FilenameRegex): si riconoscono
/// da soli, senza bisogno del DB, e possono quindi essere segnalati come "referenziati
/// ma non trovati nell'indice" quando un codice risulta rinominato/spostato - il
/// chiamante fa la ricerca nel DB e riporta i mancanti.
///
/// 2) Una minoranza sono parti commerciali scaricate come STEP da cataloghi online
/// (viti, anelli, boccole...), che in Creo hanno nomi alfabetici senza una forma
/// riconoscibile a priori (es. "ANELLO_ESTERNO-1-SOLID1", non un numero). Per questi
/// non esiste un pattern da indovinare: si tokenizza in modo generico (qualunque run
/// di lettere/cifre/underscore/trattino) e si lascia decidere al DB, già caricato dal
/// chiamante, se il token è un codice vero - altrimenti il rumore binario sarebbe
/// ingestibile. Un token così "convalidato" è per costruzione già nell'indice, quindi
/// non contribuisce mai ai "non trovati": per le parti commerciali mancanti dall'indice
/// non c'è modo di distinguerle dal rumore, restano semplicemente invisibili come lo
/// erano prima.
///
/// Verificato su un caso reale confrontando con l'albero componenti di Creo: i
/// riferimenti veri compaiono più volte nel file (una volta per feature/placement),
/// mentre il rumore binario casuale che assomiglia a un codice compare quasi sempre
/// una sola volta. Si scartano quindi, per entrambe le famiglie, i candidati "nudi" (senza
/// un suffisso di revisione/variante o una forma family-table nota) che non abbiano anche
/// un vero placement testuale nel binario.
///
/// Falso positivo noto ed escluso (famiglia 1): gli ID interni di viste salvate, nel
/// formato "ModelView#&lt;numero&gt;", hanno la stessa forma di un codice (8-12 cifre) e
/// compaiono più volte come un riferimento vero. Vedi docs/formato-asm.md.
///
/// Viti/dadi/rosette normalizzati UNI (e cuscinetti CUSCI_...) sono family table Creo: il
/// nome che compare nel binario (es. "R6--_-_U6592&lt;UNI6592&gt;",
/// "2510060303&lt;CUSCI_ACBB7&gt;") è la singola istanza, non un file separato su disco - il
/// file reale indicizzato è il generico "uni6592.prt.&lt;versione&gt;"/
/// "cusci_acbb7.prt.&lt;versione&gt;". Con la tokenizzazione generica "UNI6592"/
/// "CUSCI_ACBB7" vengono catturati da soli: "&lt;" e "&gt;" non fanno parte del charset del
/// token e ne delimitano naturalmente i confini. Vedi docs/formato-asm.md.
///
/// Lettera di revisione e suffisso "_..." possono comparire insieme sullo stesso codice
/// (es. "9284200366A_C01"), e il suffisso dopo "_" può essere puramente numerico (es.
/// "2702220182_8188", una variante dimensionale, non lettera+cifre come "_C01"/"_D100").
///
/// Un codice "nudo" (senza suffisso "_..." e senza forma family-table nota) ripetuto più
/// volte non basta: può trattarsi del riferimento al nome di un altro assieme che lo
/// contiene (non noto a priori, quindi non escludibile per confronto diretto come il
/// proprio codice) o di un residuo di feature soppresse/superate, oppure - per la
/// tokenizzazione generica - di puro rumore binario che coincide con un codice DB corto o
/// generico (vedi docs/formato-asm.md). Per questi si richiede anche un vero placement, in
/// una delle quattro forme osservate nel binario: "Componente ID N (&lt;codice&gt;...)"/
/// "Component id N (&lt;codice&gt;...)" (la stringa cambia con la localizzazione Creo del
/// file); il codice ripetuto due volte di seguito separato da un singolo byte NUL
/// ("&lt;codice&gt;\0&lt;codice&gt;\0", osservato per componenti di family table non-UNI,
/// es. cuscinetti "CUSCI_..."); il campo "family_name\0&lt;codice&gt;\0" (osservato una
/// sola volta per file, su un sotto-assieme referenziato come family table generica invece
/// che con un placement testuale esplicito); oppure il codice seguito immediatamente da un
/// bracket "&lt;codice&gt;&lt;GENERICO&gt;" - l'istanza di una family table non-UNI vista
/// nel nome (es. "2510060303&lt;CUSCI_ACBB7&gt;", un cuscinetto): il codice dell'istanza
/// non è mai indicizzato a sé (solo il generico tra "&lt;" e "&gt;" lo è), ma questo
/// segnale lo fa comunque sopravvivere fino alla ricerca nel DB invece di scomparire in
/// silenzio come "nudo senza placement" - lì verrà comunque scartato come "non in indice",
/// ma in modo tracciabile.
/// </summary>
static class AssemblyComponentScanner
{
private static readonly Regex CodiceNumericoRegex = new(
@"(?<!ModelView#)\b\d{8,12}[A-Za-z]{0,2}(?:_[A-Za-z]?\d{2,4})?\b",
RegexOptions.Compiled);
private static readonly Regex CodiceNudoRegex = new(@"^\d{8,12}[A-Za-z]{0,2}$", RegexOptions.Compiled);
// Nessuna forma ipotizzata a mano per le parti commerciali: qualunque run di
// lettere/cifre/underscore/trattino è un candidato, validato poi contro il DB.
private static readonly Regex TokenGenericoRegex = new(@"[A-Za-z0-9_-]{3,}", RegexOptions.Compiled);
// Suffisso di revisione/variante (es. "_C01", "_D100", "_8188") o prefisso di family
// table nota (UNI/CUSCI_): segnale forte abbastanza da non richiedere un placement
// esplicito, come per i codici numerici nudi con lo stesso suffisso.
private static readonly Regex SuffissoRegex = new(@"_[A-Za-z]?\d{2,4}$", RegexOptions.Compiled);
private const int OccorrenzeMinime = 2;
/// <summary>Codice radice (parte prima del primo "_") di un codice componente. Un
/// assieme con suffisso "_D.../_C..." referenzia sempre al suo interno anche l'assieme
/// generale a cui appartiene (codice radice, senza suffisso) - riferimento fantasma
/// verso l'alto, non un componente vero (vedi docs/formato-asm.md). Chi fa una
/// risoluzione ricorsiva dell'albero deve seminare l'insieme dei codici già visitati
/// anche con la radice del codice di partenza, non solo col codice stesso, altrimenti
/// risale fino all'assieme generale e riporta dentro l'intero albero del macchinario.</summary>
public static string CodiceRadice(string codice) => codice.Split('_')[0];
/// <param name="codiciNoti">Tutti i codici prt/asm noti all'indice (caricati una sola
/// volta dal chiamante, non per-file): usati per validare i token generici della
/// famiglia 2 (parti commerciali). Non serve per la famiglia 1 (codici numerici), che
/// si riconosce dalla forma senza bisogno del DB.</param>
public static HashSet<string> EstraiCodiciReferenziati(string percorsoAsm, ICollection<string>? codiciNoti = null)
{
var testo = Encoding.Latin1.GetString(File.ReadAllBytes(percorsoAsm));
bool HaPlacement(string codice) =>
Regex.IsMatch(testo, $@"Component(?:e)? id \d+ \([^()]*{Regex.Escape(codice)}[^()]*\)", RegexOptions.IgnoreCase)
|| testo.Contains(codice + "\0" + codice + "\0")
|| testo.Contains("family_name\0" + codice + "\0")
|| testo.Contains(codice + "<");
var risultato = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var occorrenzeNumeriche = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in CodiceNumericoRegex.Matches(testo))
occorrenzeNumeriche[m.Value] = occorrenzeNumeriche.GetValueOrDefault(m.Value) + 1;
foreach (var (codice, occorrenze) in occorrenzeNumeriche)
{
if (occorrenze < OccorrenzeMinime)
continue;
if (CodiceNudoRegex.IsMatch(codice) && !HaPlacement(codice))
continue;
risultato.Add(codice);
}
if (codiciNoti is { Count: > 0 })
{
var occorrenzeGeneriche = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in TokenGenericoRegex.Matches(testo))
{
if (codiciNoti.Contains(m.Value))
occorrenzeGeneriche[m.Value] = occorrenzeGeneriche.GetValueOrDefault(m.Value) + 1;
}
foreach (var (codice, occorrenze) in occorrenzeGeneriche)
{
if (risultato.Contains(codice))
continue;
bool formaNota = SuffissoRegex.IsMatch(codice)
|| codice.StartsWith("UNI", StringComparison.OrdinalIgnoreCase)
|| codice.StartsWith("CUSCI_", StringComparison.OrdinalIgnoreCase);
if (formaNota ? occorrenze >= OccorrenzeMinime : HaPlacement(codice))
risultato.Add(codice);
}
}
return risultato;
}
}
+124
View File
@@ -0,0 +1,124 @@
using System.Text;
using System.Xml.Linq;
using ExcelDataReader;
namespace PrepStep;
/// <summary>
/// Converte il file "Distinta Base" esportato dal gestionale (SpreadsheetML
/// XML con estensione .xls/.xml, oppure .xls/.xlsx binario vero) nelle sole
/// colonne Componente/Descrizione in CSV.
/// </summary>
static class DistintaBaseImporter
{
private static readonly XNamespace Ss = "urn:schemas-microsoft-com:office:spreadsheet";
private const string NomeFoglio = "Distinta Base";
// Il gestionale esporta la distinta come "DBP_<codice assieme generale>_<timestamp>.xls":
// il foglio elenca solo i componenti SOTTO l'assieme, non l'assieme generale in sé, quindi
// va aggiunto a parte al CSV leggendo questo codice dal nome del file di input.
private static readonly System.Text.RegularExpressions.Regex NomeFileDistintaRegex =
new(@"^DBP_(?<codice>\d+)_\d+$", System.Text.RegularExpressions.RegexOptions.Compiled);
static DistintaBaseImporter()
{
Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
}
public static (string PercorsoCsv, List<(string Componente, string Descrizione)> Righe) ConvertiInCsv(
string percorsoInput, string? cartellaOutput = null)
{
var righeGrezze = EImmagineSpreadsheetMl(percorsoInput)
? LeggiRigheSpreadsheetMl(percorsoInput)
: LeggiRigheExcelBinario(percorsoInput);
var righe = righeGrezze.Skip(1) // salta l'intestazione
.Where(r => r.Componente.Length > 0 || r.Descrizione.Length > 0)
.ToList();
var matchGenerale = NomeFileDistintaRegex.Match(Path.GetFileNameWithoutExtension(percorsoInput));
if (matchGenerale.Success)
{
string codiceGenerale = matchGenerale.Groups["codice"].Value;
if (!righe.Any(r => string.Equals(r.Componente, codiceGenerale, StringComparison.OrdinalIgnoreCase)))
righe.Insert(0, (codiceGenerale, "Assieme generale"));
}
var percorsoCsv = Path.Combine(
cartellaOutput ?? Path.GetDirectoryName(Path.GetFullPath(percorsoInput))!,
Path.GetFileNameWithoutExtension(percorsoInput) + ".csv");
using var writer = new StreamWriter(percorsoCsv, false, Encoding.UTF8);
writer.WriteLine("Componente;Descrizione");
foreach (var (componente, descrizione) in righe)
{
writer.WriteLine($"{EscapeCsv(componente)};{EscapeCsv(descrizione)}");
}
return (percorsoCsv, righe);
}
/// <summary>I file XML "SpreadsheetML" iniziano con l'intestazione XML testuale;
/// i file .xls/.xlsx binari veri iniziano invece con una firma binaria (OLE2 o ZIP).</summary>
private static bool EImmagineSpreadsheetMl(string percorso)
{
Span<byte> primiByte = stackalloc byte[5];
using var stream = File.OpenRead(percorso);
var letti = stream.Read(primiByte);
return letti >= 5 && Encoding.ASCII.GetString(primiByte) == "<?xml";
}
private static List<(string Componente, string Descrizione)> LeggiRigheSpreadsheetMl(string percorsoInput)
{
var doc = XDocument.Load(percorsoInput);
var foglio = doc.Descendants(Ss + "Worksheet")
.FirstOrDefault(w => (string?)w.Attribute(Ss + "Name") == NomeFoglio)
?? throw new InvalidDataException($"Foglio '{NomeFoglio}' non trovato nel file.");
var righeXml = foglio.Descendants(Ss + "Row").ToList();
if (righeXml.Count == 0)
throw new InvalidDataException($"Il foglio '{NomeFoglio}' non contiene righe.");
return righeXml.Select(LeggiColonneBeCSpreadsheetMl).ToList();
}
private static (string Componente, string Descrizione) LeggiColonneBeCSpreadsheetMl(XElement riga)
{
string b = "", c = "";
int colonna = 0;
foreach (var cella in riga.Elements(Ss + "Cell"))
{
// ss:Index salta le celle vuote iniziali: senza attributo la colonna è quella precedente + 1
var indice = cella.Attribute(Ss + "Index");
colonna = indice != null ? int.Parse(indice.Value) : colonna + 1;
if (colonna == 2) b = cella.Element(Ss + "Data")?.Value ?? "";
else if (colonna == 3) c = cella.Element(Ss + "Data")?.Value ?? "";
}
return (b, c);
}
private static List<(string Componente, string Descrizione)> LeggiRigheExcelBinario(string percorsoInput)
{
using var stream = File.Open(percorsoInput, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = ExcelReaderFactory.CreateReader(stream);
var dataSet = reader.AsDataSet();
var tabella = dataSet.Tables[NomeFoglio]
?? throw new InvalidDataException($"Foglio '{NomeFoglio}' non trovato nel file.");
var righe = new List<(string Componente, string Descrizione)>();
foreach (System.Data.DataRow riga in tabella.Rows)
{
var b = riga.Table.Columns.Count > 1 ? riga[1]?.ToString() ?? "" : "";
var c = riga.Table.Columns.Count > 2 ? riga[2]?.ToString() ?? "" : "";
righe.Add((b, c));
}
return righe;
}
private static string EscapeCsv(string valore)
{
if (valore.Contains(';') || valore.Contains('"') || valore.Contains('\n'))
return $"\"{valore.Replace("\"", "\"\"")}\"";
return valore;
}
}
+28
View File
@@ -0,0 +1,28 @@
#nullable enable
namespace PrepStep;
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);
Name = "Form1";
ResumeLayout(false);
}
}
+1289
View File
File diff suppressed because it is too large. Load diff
+295
View File
@@ -0,0 +1,295 @@
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(@"^(?<codice>.+)\.(?<tipo>[A-Za-z0-9]+)\.(?<versione>\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);
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<ScanProgress> progress, CancellationToken ct)
=> Task.Run(() => Scan(root, dbPath, progress, ct), ct);
private static void Scan(string root, string dbPath, IProgress<ScanProgress> 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<string, long>();
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<FileInfo> Walk(string root)
{
var stack = new Stack<string>();
stack.Push(root);
while (stack.Count > 0)
{
string current = stack.Pop();
IEnumerable<FileSystemInfo> 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;
}
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<ApplicationIcon>app.ico</ApplicationIcon>
<!-- Nome tecnico (cartella/csproj/namespace) e nome del file .exe pubblicato: PrepStep.
Il nome prodotto mostrato all'utente ("Cevolani StepPrep") resta separato, sotto. -->
<AssemblyTitle>Cevolani StepPrep</AssemblyTitle>
<Product>Cevolani StepPrep</Product>
<Company>Cevolani</Company>
<Version>0.1.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
<PackageReference Include="ExcelDataReader.DataSet" Version="3.9.0" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>PrepStep.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
+16
View File
@@ -0,0 +1,16 @@
namespace PrepStep;
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[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());
}
}
+118
View File
@@ -0,0 +1,118 @@
namespace PrepStep;
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();
private readonly ToolTip _tipCartella = 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);
// AutoSize=false + AutoEllipsis: un percorso lungo (share di rete) va troncato con "..."
// invece di sforare il bordo del dialog, che è FixedDialog e non si può allargare.
// Il testo completo resta disponibile nel tooltip (stesso pattern di Form1._lblImportStatus).
_lblCartella.AutoSize = false;
_lblCartella.AutoEllipsis = true;
_lblCartella.Size = new Size(428, 20);
_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);
UiStile.Secondario(_btnAnnulla);
_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}...";
_tipCartella.SetToolTip(_lblCartella, _lblCartella.Text);
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";
}
}
+37
View File
@@ -0,0 +1,37 @@
namespace PrepStep;
public sealed class ProgressoComponentiForm : Form
{
private readonly Label _lbl = new();
private readonly ProgressBar _bar = new();
public ProgressoComponentiForm()
{
Text = "Ricerca componenti referenziati in corso...";
FormBorderStyle = FormBorderStyle.FixedDialog;
StartPosition = FormStartPosition.CenterParent;
MinimizeBox = false;
MaximizeBox = false;
ControlBox = false;
ClientSize = new Size(400, 88);
_lbl.Text = "Preparazione...";
_lbl.AutoSize = true;
_lbl.Location = new Point(16, 16);
_bar.Location = new Point(16, 44);
_bar.Size = new Size(368, 24);
_bar.Minimum = 0;
_bar.Maximum = 100;
Controls.Add(_lbl);
Controls.Add(_bar);
}
public void Aggiorna(int fatti, int totale)
{
int percento = totale > 0 ? (int)Math.Clamp(100.0 * fatti / totale, 0, 100) : 0;
_bar.Value = percento;
_lbl.Text = $"{fatti} / {totale} componenti analizzati ({percento}%)";
}
}
+74
View File
@@ -0,0 +1,74 @@
using System.Diagnostics;
namespace PrepStep;
/// <summary>Mostra l'esito della risoluzione ricorsiva dell'albero componenti: i PRT e i
/// sotto-assiemi trovati nell'indice e già copiati in "raw" a partire dagli assiemi iniziali.</summary>
public class PrtMancantiForm : Form
{
public PrtMancantiForm(List<(string Codice, string Tipo, string Percorso)> righe, List<string> codiciNonTrovati, string cartellaRaw)
{
int numPrt = righe.Count(r => r.Tipo == "prt");
int numAsm = righe.Count(r => r.Tipo == "asm");
Text = "Componenti referenziati trovati";
ClientSize = new Size(720, 440);
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9.5f);
var lblInfo = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
Padding = new Padding(12, 8, 12, 8),
Text = $"Copiati in \"{cartellaRaw}\": {numPrt} PRT e {numAsm} sotto-assiemi trovati nell'indice."
+ (codiciNonTrovati.Count > 0
? $" {codiciNonTrovati.Count} codici referenziati non corrispondono a nessun file indicizzato "
+ "(righe 'non trovato' sotto: possono essere disegni, componenti davvero mancanti, "
+ "o falsi positivi della scansione)."
: ""),
};
var grid = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
RowHeadersVisible = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
};
grid.Columns.Add("codice", "Codice");
grid.Columns.Add("tipo", "Tipo");
grid.Columns.Add("percorso", "Percorso CAD");
grid.Columns["codice"]!.FillWeight = 20;
grid.Columns["tipo"]!.FillWeight = 10;
grid.Columns["percorso"]!.FillWeight = 70;
foreach (var (codice, tipo, percorso) in righe.OrderBy(r => r.Tipo).ThenBy(r => r.Codice))
{
grid.Rows.Add(codice, tipo, percorso);
}
foreach (var codice in codiciNonTrovati.OrderBy(c => c))
{
int i = grid.Rows.Add(codice, "non trovato", "");
grid.Rows[i].DefaultCellStyle.ForeColor = Color.DarkRed;
}
var btnApriCartella = new Button { Text = "Apri cartella raw", Dock = DockStyle.Bottom, Height = 34 };
UiStile.Secondario(btnApriCartella);
btnApriCartella.Click += (_, _) =>
{
try { Process.Start("explorer.exe", cartellaRaw); }
catch (Exception ex)
{
MessageBox.Show(this, $"Impossibile aprire Esplora risorse:\n{ex.Message}", "Errore",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
};
Controls.Add(grid);
Controls.Add(lblInfo);
Controls.Add(btnApriCartella);
}
}
+61
View File
@@ -0,0 +1,61 @@
namespace PrepStep;
/// <summary>Mostra, per ogni assieme della distinta base, se tutti i suoi componenti
/// (ricorsivamente fino ai PRT) sono presenti nell'indice (v) o mancano (x).</summary>
public class ReportCompletezzaForm : Form
{
public ReportCompletezzaForm(List<(string Componente, string Descrizione, bool? Completo)> righe)
{
int completi = righe.Count(r => r.Completo == true);
int incompleti = righe.Count(r => r.Completo == false);
int nonTrovati = righe.Count(r => r.Completo is null);
Text = "Completezza assiemi";
ClientSize = new Size(720, 440);
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9.5f);
var lblInfo = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
Padding = new Padding(12, 8, 12, 8),
Text = $"{completi} completi, {incompleti} incompleti, {nonTrovati} non trovati nell'indice "
+ $"su {righe.Count} componenti. Nota: la scansione dei .asm è euristica, qualche "
+ "falso 'incompleto' è possibile.",
};
var grid = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
RowHeadersVisible = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
};
grid.Columns.Add("componente", "Componente");
grid.Columns.Add("descrizione", "Descrizione");
grid.Columns.Add("completo", "Completo");
grid.Columns["componente"]!.FillWeight = 20;
grid.Columns["descrizione"]!.FillWeight = 55;
grid.Columns["completo"]!.FillWeight = 25;
foreach (var (componente, descrizione, completo) in righe.OrderBy(r => r.Completo).ThenBy(r => r.Componente))
{
string testo = completo switch { true => "✓", false => "✗", null => "?" };
int i = grid.Rows.Add(componente, descrizione, testo);
grid.Rows[i].DefaultCellStyle.ForeColor = completo switch
{
true => Color.DarkGreen,
false => Color.DarkRed,
null => Color.Gray,
};
grid.Rows[i].Cells["completo"].Style.Alignment = DataGridViewContentAlignment.MiddleCenter;
}
Controls.Add(grid);
Controls.Add(lblInfo);
}
}
+85
View File
@@ -0,0 +1,85 @@
using System.Diagnostics;
namespace PrepStep;
public enum EsitoStep
{
/// <summary>File .stp trovato in "step" e copiato in "step_finali".</summary>
Creato,
/// <summary>Il componente è nell'indice CAD (asm/prt trovato), ma nessuno STEP esportato.</summary>
StepMancante,
/// <summary>Il componente non è nemmeno nell'indice CAD.</summary>
ComponenteNonTrovato,
}
/// <summary>Mostra, per ogni componente della distinta base, se lo STEP corrispondente è stato
/// trovato in "step" e copiato in "step_finali", se manca solo lo STEP (il CAD sorgente c'è,
/// ma non è ancora stato esportato) o se manca anche il CAD sorgente nell'indice.</summary>
public class ReportStepForm : Form
{
public ReportStepForm(List<(string Componente, string Descrizione, EsitoStep Esito)> righe, string cartellaStepFinali)
{
int creati = righe.Count(r => r.Esito == EsitoStep.Creato);
int stepMancanti = righe.Count(r => r.Esito == EsitoStep.StepMancante);
int nonTrovati = righe.Count(r => r.Esito == EsitoStep.ComponenteNonTrovato);
Text = "Report STEP finali";
ClientSize = new Size(720, 440);
StartPosition = FormStartPosition.CenterParent;
Font = new Font("Segoe UI", 9.5f);
var lblInfo = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
Padding = new Padding(12, 8, 12, 8),
Text = $"{creati} copiati, {stepMancanti} da esportare (CAD presente), "
+ $"{nonTrovati} non nell'indice, su {righe.Count} totali. In \"{cartellaStepFinali}\".",
};
var grid = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
RowHeadersVisible = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
};
grid.Columns.Add("componente", "Componente");
grid.Columns.Add("descrizione", "Descrizione");
grid.Columns.Add("step", "STEP");
grid.Columns["componente"]!.FillWeight = 20;
grid.Columns["descrizione"]!.FillWeight = 55;
grid.Columns["step"]!.FillWeight = 25;
foreach (var (componente, descrizione, esito) in righe.OrderBy(r => r.Esito).ThenBy(r => r.Componente))
{
var (testo, colore) = esito switch
{
EsitoStep.Creato => ("Creato", Color.DarkGreen),
EsitoStep.StepMancante => ("Da esportare (CAD presente)", Color.DarkOrange),
_ => ("Componente non trovato nel server", Color.DarkRed),
};
int i = grid.Rows.Add(componente, descrizione, testo);
grid.Rows[i].DefaultCellStyle.ForeColor = colore;
}
var btnApriCartella = new Button { Text = "Apri cartella step_finali", Dock = DockStyle.Bottom, Height = 34 };
UiStile.Secondario(btnApriCartella);
btnApriCartella.Click += (_, _) =>
{
try { Process.Start("explorer.exe", cartellaStepFinali); }
catch (Exception ex)
{
MessageBox.Show(this, $"Impossibile aprire Esplora risorse:\n{ex.Message}", "Errore",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
};
Controls.Add(grid);
Controls.Add(lblInfo);
Controls.Add(btnApriCartella);
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Drawing.Drawing2D;
namespace PrepStep;
/// <summary>Stile bottoni condiviso tra la form principale e i dialog secondari: piatto con
/// bordo sottile e hover, per un aspetto coerente sia per l'azione primaria di ogni fase
/// (blu pieno) sia per le azioni secondarie, al posto del bottone grigio di default.</summary>
internal static class UiStile
{
// AutoSize+Padding invece di una Size fissa in pixel: il bottone si dimensiona sempre
// al proprio testo (a qualunque lunghezza/font), niente più testo che va a capo e viene
// tagliato in verticale quando non ci sta nella larghezza indovinata a mano.
private static void DimensionamentoAutomatico(Button b)
{
b.AutoSize = true;
b.AutoSizeMode = AutoSizeMode.GrowAndShrink;
b.Padding = new Padding(18, 4, 18, 4);
b.MinimumSize = new Size(0, 32);
}
public static void Primario(Button b)
{
DimensionamentoAutomatico(b);
b.FlatStyle = FlatStyle.Flat;
b.FlatAppearance.BorderSize = 0;
b.FlatAppearance.MouseOverBackColor = Color.FromArgb(0, 84, 152);
b.FlatAppearance.MouseDownBackColor = Color.FromArgb(0, 70, 130);
b.BackColor = Color.FromArgb(0, 99, 177);
b.ForeColor = Color.White;
b.Font = new Font(b.Font, FontStyle.Bold);
b.Cursor = Cursors.Hand;
}
public static void Secondario(Button b)
{
DimensionamentoAutomatico(b);
b.FlatStyle = FlatStyle.Flat;
b.FlatAppearance.BorderColor = Color.FromArgb(190, 195, 200);
b.FlatAppearance.BorderSize = 1;
b.FlatAppearance.MouseOverBackColor = Color.FromArgb(230, 238, 245);
b.FlatAppearance.MouseDownBackColor = Color.FromArgb(213, 226, 238);
b.BackColor = Color.White;
b.Cursor = Cursors.Hand;
}
/// <summary>Arrotonda gli angoli del bottone. Si aggancia a SizeChanged invece di calcolare
/// la Region una volta sola: con AutoSize la dimensione finale si conosce solo a layout
/// avvenuto (dopo Text/Padding), non subito dopo la creazione del controllo.</summary>
public static void Arrotonda(Button b, int raggio = 6)
{
b.SizeChanged += (_, _) => AggiornaRegione(b, raggio);
AggiornaRegione(b, raggio);
}
private static void AggiornaRegione(Button b, int raggio)
{
if (b.Width <= 0 || b.Height <= 0)
return;
int d = raggio * 2;
var rect = new Rectangle(Point.Empty, b.Size);
using var percorso = new GraphicsPath();
percorso.AddArc(rect.X, rect.Y, d, d, 180, 90);
percorso.AddArc(rect.Right - d, rect.Y, d, d, 270, 90);
percorso.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90);
percorso.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90);
percorso.CloseFigure();
b.Region?.Dispose();
b.Region = new Region(percorso);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB