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:
1 parent
6726dd2216
commit
13798ad47c
24 files changed
+211
-129
No files matched your search
@@ -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 (>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#<numero>", 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<UNI6592>",
|
||||
/// "2510060303<CUSCI_ACBB7>") è la singola istanza, non un file separato su disco - il
|
||||
/// file reale indicizzato è il generico "uni6592.prt.<versione>"/
|
||||
/// "cusci_acbb7.prt.<versione>". Con la tokenizzazione generica "UNI6592"/
|
||||
/// "CUSCI_ACBB7" vengono catturati da soli: "<" e ">" 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 (<codice>...)"/
|
||||
/// "Component id N (<codice>...)" (la stringa cambia con la localizzazione Creo del
|
||||
/// file); il codice ripetuto due volte di seguito separato da un singolo byte NUL
|
||||
/// ("<codice>\0<codice>\0", osservato per componenti di family table non-UNI,
|
||||
/// es. cuscinetti "CUSCI_..."); il campo "family_name\0<codice>\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 "<codice><GENERICO>" - l'istanza di una family table non-UNI vista
|
||||
/// nel nome (es. "2510060303<CUSCI_ACBB7>", un cuscinetto): il codice dell'istanza
|
||||
/// non è mai indicizzato a sé (solo il generico tra "<" e ">" 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+28
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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}%)";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Reference in new issue
Block a user