Files
prep-step/PrepStep/Form1.cs
T
davideandClaude Sonnet 5 ff4e7a71e0 Trasforma la barra di navigazione in una floatbar e uniforma i bottoni Avanti/Indietro
La barra Avanti/Indietro era un footer bianco a tutta larghezza con una
linea di separazione da 1px. Ora è una card bianca staccata su tutti e
4 i lati dalla finestra (UiStile.FloatBar, angoli arrotondati, bordo
sottile), riusata anche in PrtMancantiForm per Chiudi/Apri cartella raw.

Avanti e Indietro avevano dimensioni diverse (testi di lunghezza
diversa, più il grassetto di Primario che richiede più altezza del
testo normale) ed erano visivamente disomogenei (bordo sottile quasi
invisibile su sfondo bianco per Indietro contro il blu pieno di
Avanti). Aggiunti UiStile.SecondarioPieno (variante piena, senza
bordo, stessa costruzione di Primario ma grigio chiaro) e
UiStile.LarghezzaUguale (stessa larghezza e altezza tra due bottoni,
chiamata PRIMA di arrotondare gli angoli: arrotondare e poi
ridimensionare lasciava un bordo residuo della Region vecchia).

Doc aggiornati nello stesso commit: docs/architettura.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:13:34 +02:00

1361 lines
57 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Drawing.Drawing2D;
using Microsoft.Data.Sqlite;
namespace PrepStep;
public partial class Form1 : Form
{
private readonly Button _btnApriDb = new();
private readonly Button _btnAggiorna = new();
private readonly Button _btnCreaRaw = new();
private readonly Button _btnTrovaPrt = new();
private readonly Button _btnVerifica = new();
private readonly FlowLayoutPanel _rigaVerifica = new();
private readonly ListView _lstAnteprimaAsm = new();
private readonly Label _lblStatus = new();
private readonly Label _lblDb = new();
private readonly Label _lblUltimoAggiornamento = new();
private readonly Panel _indicatoreDb = new();
private bool _dbCaricato;
private readonly DataGridView _gridImport = new();
private readonly DataGridView _gridRaw = new();
private readonly Label _lblImportStatus = new();
private readonly ToolTip _tipImportStatus = new();
private readonly MenuStrip _menu = new();
private readonly ToolStripMenuItem _mnuNuovaEstrazione = new("Nuovo");
private readonly ToolStripMenuItem _mnuPulisci = new("Pulisci raw e step...");
private readonly ToolStripMenuItem _mnuCambiaCartella = new("Scegli cartella di lavoro...");
// Wizard: una fase alla volta occupa tutta la finestra, si avanza solo quando quella
// corrente è "pronta" (vedi AvantiConsentito). NomiFasi/_panelFasi sono paralleli per indice.
private static readonly string[] NomiFasi =
{
"1. Database",
"2. Importa Distinta Base",
"3. Crea cartella raw",
"4. Trova componenti referenziati",
"5. Verifica completezza",
};
private readonly Panel[] _panelFasi = new Panel[NomiFasi.Length];
private readonly Panel _faseContainer = new() { Dock = DockStyle.Fill };
private readonly Label _lblFase = new();
private readonly Button _btnIndietro = new();
private readonly Button _btnAvanti = new();
private int _faseAttuale;
private static readonly string[] TipiPredefiniti = { "prt", "asm" };
private const string RadiceDiscoDefault = @"X:\";
private string? _dbPath;
private string? _cartellaLavoro;
private string? _percorsoInputCorrente;
private List<(string Componente, string Descrizione)>? _righeCorrenti;
private Dictionary<string, List<string>>? _ultimaVerifica;
private string? _ultimaCartellaRaw;
public Form1()
{
InitializeComponent();
BuildUi();
Load += (_, _) =>
{
InizializzaDatabase();
InizializzaCartellaLavoro();
};
}
// Palette: blu primario per l'azione principale del flusso ("Crea cartella raw"),
// il resto resta ai colori di sistema per restare coerente col tema Windows dell'utente.
private static readonly Color ColorePrimario = Color.FromArgb(0, 99, 177);
private void BuildUi()
{
AllowDrop = true;
// Versione letta dall'assembly (<Version> nel csproj) invece che duplicata qui a mano.
var versione = typeof(Form1).Assembly.GetName().Version;
Text = $"Cevolani StepPrep {versione?.ToString(3)}";
MinimumSize = new Size(760, 480);
Font = new Font("Segoe UI", 9.5f);
// Stessa icona di ApplicationIcon nel csproj: la si rilegge dall'exe compilato invece
// di duplicare il file .ico come Content, funziona identica in Debug/Release/publish.
try
{
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
}
catch (Exception ex) when (ex is IOException or ArgumentException)
{
// ponytail: nessuna icona nell'exe (es. lanciato da dotnet run) -> resta quella di default
}
var headerPanel = new Panel { Dock = DockStyle.Top, Height = 40, BackColor = SystemColors.ControlLight };
_lblFase.Dock = DockStyle.Fill;
_lblFase.TextAlign = ContentAlignment.MiddleLeft;
_lblFase.Padding = new Padding(12, 0, 0, 0);
_lblFase.Font = new Font(Font, FontStyle.Bold);
headerPanel.Controls.Add(_lblFase);
// Floatbar di navigazione: card staccata dai bordi della finestra invece di un footer
// a tutta larghezza appoggiato direttamente sul contenuto/sulla barra di stato.
var (navContainer, navPanel) = UiStile.FloatBar();
_btnAvanti.Text = "Avanti ›";
UiStile.Primario(_btnAvanti);
_btnIndietro.Text = "‹ Indietro";
_btnIndietro.Margin = new Padding(8, 0, 0, 0);
UiStile.SecondarioPieno(_btnIndietro);
// Dimensioni uguali decise PRIMA di arrotondare: se si arrotonda un bottone e solo dopo
// lo si ridimensiona, la Region calcolata sulla dimensione vecchia lascia un bordo
// "fantasma" finché non arriva un repaint completo della finestra.
UiStile.LarghezzaUguale(_btnAvanti, _btnIndietro);
UiStile.Arrotonda(_btnAvanti, 10);
UiStile.Arrotonda(_btnIndietro, 10);
_btnAvanti.Click += (_, _) => VaiAllaFase(_faseAttuale + 1);
navPanel.Controls.Add(_btnAvanti);
_btnIndietro.Click += (_, _) => VaiAllaFase(_faseAttuale - 1);
navPanel.Controls.Add(_btnIndietro);
var statusPanel = new Panel { Dock = DockStyle.Bottom, Height = 28, BackColor = SystemColors.ControlLight };
_lblStatus.Dock = DockStyle.Fill;
_lblStatus.TextAlign = ContentAlignment.MiddleLeft;
_lblStatus.Padding = new Padding(12, 0, 0, 0);
_lblStatus.Text = "Pronto.";
statusPanel.Controls.Add(_lblStatus);
_panelFasi[0] = BuildFaseDatabase();
_panelFasi[1] = BuildFaseImporta();
_panelFasi[2] = BuildFaseCreaRaw();
_panelFasi[3] = BuildFaseTrovaComponenti();
_panelFasi[4] = BuildFaseVerifica();
foreach (var panel in _panelFasi)
{
panel.Dock = DockStyle.Fill;
panel.Visible = false;
_faseContainer.Controls.Add(panel);
}
// Ordine dei Dock.Bottom: l'ultimo aggiunto è il più esterno (il vero bordo inferiore
// della finestra). La barra di stato va tra contenuto e azioni; i bottoni Avanti/Indietro
// restano il footer più esterno, come in ogni wizard/dialog standard di Windows.
var mnuFile = new ToolStripMenuItem("File");
_mnuNuovaEstrazione.ShortcutKeys = Keys.Control | Keys.N;
_mnuNuovaEstrazione.Click += (_, _) => NuovaEstrazione();
mnuFile.DropDownItems.Add(_mnuNuovaEstrazione);
_mnuCambiaCartella.Click += (_, _) => ScegliCartellaLavoro();
mnuFile.DropDownItems.Add(_mnuCambiaCartella);
_mnuPulisci.Click += (_, _) => PulisciRawEStep();
mnuFile.DropDownItems.Add(_mnuPulisci);
var mnuAiuto = new ToolStripMenuItem("Aiuto");
var mnuGuida = new ToolStripMenuItem("Guida") { ShortcutKeys = Keys.F1 };
mnuGuida.Click += (_, _) => new GuidaForm().ShowDialog(this);
mnuAiuto.DropDownItems.Add(mnuGuida);
_menu.Items.Add(mnuFile);
_menu.Items.Add(mnuAiuto);
MainMenuStrip = _menu;
Controls.Add(_faseContainer);
Controls.Add(statusPanel);
Controls.Add(navContainer);
Controls.Add(headerPanel);
Controls.Add(_menu);
DragEnter += (_, e) =>
e.Effect = e.Data?.GetDataPresent(DataFormats.FileDrop) == true
? DragDropEffects.Copy
: DragDropEffects.None;
DragDrop += (_, e) =>
{
if (e.Data?.GetData(DataFormats.FileDrop) is string[] { Length: > 0 } file)
ImportaDistinta(file[0]);
};
VaiAllaFase(0);
}
/// <summary>Nasconde la fase corrente e mostra quella richiesta (se nei limiti e se la fase
/// corrente permette di avanzare); aggiorna titolo e stato dei pulsanti Indietro/Avanti.</summary>
private void VaiAllaFase(int fase)
{
if (fase < 0 || fase >= NomiFasi.Length)
return;
if (fase > _faseAttuale && !AvantiConsentito(_faseAttuale))
return;
_panelFasi[_faseAttuale].Visible = false;
_faseAttuale = fase;
_panelFasi[_faseAttuale].Visible = true;
_lblFase.Text = NomiFasi[_faseAttuale];
_btnIndietro.Visible = _faseAttuale > 0;
_btnAvanti.Visible = _faseAttuale < NomiFasi.Length - 1;
AggiornaStatoAvanti();
if (_faseAttuale == 4)
AggiornaAnteprimaAsm();
}
/// <summary>Popola le icone trascinabili (drag&amp;drop) dei file .asm della distinta importata
/// (non i sotto-assiemi di fase 4, che in "raw" servono solo a completarla) già copiati in
/// "raw" (non riletti dal DB/share: sono già in locale), mostrate in fase 5 prima ancora di
/// avviare la verifica.</summary>
private void AggiornaAnteprimaAsm()
{
_lstAnteprimaAsm.Items.Clear();
_lstAnteprimaAsm.SmallImageList!.Images.Clear();
if (_ultimaCartellaRaw is null || !Directory.Exists(_ultimaCartellaRaw) || _righeCorrenti is null)
return;
var codiciDistinta = _righeCorrenti.Select(r => r.Componente).ToHashSet(StringComparer.OrdinalIgnoreCase);
var percorsiAsm = Directory.GetFiles(_ultimaCartellaRaw)
.Where(p =>
{
var (codice, tipo, _) = Indexer.ParseFilename(Path.GetFileName(p));
return tipo.Equals("asm", StringComparison.OrdinalIgnoreCase) && codiciDistinta.Contains(codice);
});
foreach (var percorso in percorsiAsm)
{
Icon? icona = null;
try
{
icona = Icon.ExtractAssociatedIcon(percorso);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
// ponytail: file irraggiungibile (share offline) -> icona generica, non blocca gli altri
}
int indice = _lstAnteprimaAsm.SmallImageList.Images.Count;
_lstAnteprimaAsm.SmallImageList.Images.Add(icona?.ToBitmap() ?? SystemIcons.WinLogo.ToBitmap());
icona?.Dispose();
_lstAnteprimaAsm.Items.Add(new ListViewItem(Path.GetFileName(percorso), indice) { Tag = percorso });
}
}
/// <summary>Condizione per cui la fase indicata è "conclusa" e si può passare alla successiva.</summary>
private bool AvantiConsentito(int fase) => fase switch
{
0 => _dbPath is not null && _cartellaLavoro is not null,
1 => _righeCorrenti is { Count: > 0 } && _ultimaVerifica is not null,
2 => _ultimaCartellaRaw is not null,
3 => true, // risoluzione ricorsiva opzionale: non tutti gli assiemi hanno sotto-componenti
_ => true,
};
private void AggiornaStatoAvanti() => _btnAvanti.Enabled = AvantiConsentito(_faseAttuale);
/// <summary>Azzera distinta/raw/verifica (fasi 2-5) per ripartire con una nuova distinta;
/// DB indicizzato e cartella di lavoro (fase 1) restano quelli già caricati, non serve
/// rifare lo scan del disco per ogni estrazione.</summary>
private void NuovaEstrazione()
{
if (_dbPath is null || _cartellaLavoro is null)
{
MessageBox.Show(this, "Carica prima il database in fase 1.", "Nuovo",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
_percorsoInputCorrente = null;
_righeCorrenti = null;
_ultimaCartellaRaw = null;
_ultimaVerifica = null;
_gridImport.Rows.Clear();
_gridRaw.Rows.Clear();
_lstAnteprimaAsm.Items.Clear();
ImpostaStatoImport("", Color.Black);
_rigaVerifica.Visible = false;
VaiAllaFase(1);
}
private Panel BuildFaseDatabase()
{
var contenuto = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
Padding = new Padding(16),
};
// Colonna al 100%: le label sotto (Dock=Top) vanno a capo sulla larghezza reale della
// finestra invece che su un limite fisso in pixel, e si riadattano se viene ridimensionata.
contenuto.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var lblIstruzioni = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
ForeColor = SystemColors.GrayText,
Margin = new Padding(0, 0, 0, 16),
Text = "L'indice CAD viene caricato automaticamente all'avvio, se già presente.",
};
contenuto.Controls.Add(lblIstruzioni);
var cardDb = UiStile.Card();
var contenutoCardDb = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
};
contenutoCardDb.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var rigaTitoloDb = new FlowLayoutPanel
{
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
Margin = new Padding(0, 0, 0, 6),
};
_indicatoreDb.Size = new Size(10, 10);
_indicatoreDb.Margin = new Padding(0, 5, 8, 0);
_indicatoreDb.Paint += (_, e) =>
{
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
var colore = _dbCaricato ? Color.FromArgb(30, 160, 90) : Color.FromArgb(200, 60, 60);
using var pennello = new SolidBrush(colore);
e.Graphics.FillEllipse(pennello, 0, 0, _indicatoreDb.Width - 1, _indicatoreDb.Height - 1);
};
rigaTitoloDb.Controls.Add(_indicatoreDb);
var lblTitoloDb = new Label
{
AutoSize = true,
Font = new Font(Font, FontStyle.Bold),
Text = "Database",
};
rigaTitoloDb.Controls.Add(lblTitoloDb);
contenutoCardDb.Controls.Add(rigaTitoloDb);
_lblDb.Dock = DockStyle.Top;
_lblDb.AutoSize = true;
_lblDb.ForeColor = SystemColors.ControlText;
_lblDb.Margin = new Padding(0, 0, 0, 2);
contenutoCardDb.Controls.Add(_lblDb);
_lblUltimoAggiornamento.AutoSize = true;
_lblUltimoAggiornamento.ForeColor = SystemColors.GrayText;
_lblUltimoAggiornamento.Margin = new Padding(0, 0, 0, 14);
contenutoCardDb.Controls.Add(_lblUltimoAggiornamento);
var bottoniDb = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight };
_btnApriDb.Text = "Apri DB...";
_btnApriDb.Margin = new Padding(0, 0, 8, 0);
UiStile.Secondario(_btnApriDb);
UiStile.Arrotonda(_btnApriDb);
_btnApriDb.Click += (_, _) => ScegliDatabase();
bottoniDb.Controls.Add(_btnApriDb);
_btnAggiorna.Text = "Aggiorna indice";
UiStile.Secondario(_btnAggiorna);
UiStile.Arrotonda(_btnAggiorna);
_btnAggiorna.Click += (_, _) => AggiornaIndice();
bottoniDb.Controls.Add(_btnAggiorna);
contenutoCardDb.Controls.Add(bottoniDb);
cardDb.Controls.Add(contenutoCardDb);
contenuto.Controls.Add(cardDb);
var panel = new Panel();
panel.Controls.Add(contenuto);
return panel;
}
private Panel BuildFaseImporta()
{
var contenuto = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
Padding = new Padding(16),
};
contenuto.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var lblIstruzioni = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
ForeColor = SystemColors.GrayText,
Margin = new Padding(0, 0, 0, 8),
Text = "Trascina qui il file .xls/.xml esportato dal gestionale (foglio 'Distinta "
+ "Base'), oppure scegli il file col pulsante sotto: genera un CSV con Componente "
+ "e Descrizione, in anteprima nella griglia.",
};
contenuto.Controls.Add(lblIstruzioni);
var rigaImporta = new FlowLayoutPanel
{
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
Margin = new Padding(0, 0, 0, 8),
};
var btnSfoglia = new Button
{
Text = "Scegli file distinta...",
Margin = new Padding(0, 0, 8, 0),
};
UiStile.Secondario(btnSfoglia);
btnSfoglia.Click += (_, _) => ScegliFileDistinta();
rigaImporta.Controls.Add(btnSfoglia);
rigaImporta.Controls.Add(new Label
{
AutoSize = true,
ForeColor = SystemColors.GrayText,
Margin = new Padding(0, 9, 0, 0),
Text = "1. Carica la distinta",
});
contenuto.Controls.Add(rigaImporta);
_rigaVerifica.AutoSize = true;
_rigaVerifica.FlowDirection = FlowDirection.LeftToRight;
_rigaVerifica.Margin = new Padding(0, 0, 0, 8);
_rigaVerifica.Visible = false; // compare solo dopo un import riuscito (file o drag&drop)
_btnVerifica.Text = "Verifica presenza";
_btnVerifica.Margin = new Padding(0, 0, 8, 0);
UiStile.Secondario(_btnVerifica);
_btnVerifica.Click += (_, _) => VerificaCodiciNelDb();
_rigaVerifica.Controls.Add(_btnVerifica);
_rigaVerifica.Controls.Add(new Label
{
AutoSize = true,
ForeColor = SystemColors.GrayText,
Margin = new Padding(0, 9, 0, 0),
Text = "2. Verifica la presenza dei file sul server",
});
contenuto.Controls.Add(_rigaVerifica);
_lblImportStatus.Dock = DockStyle.Top;
_lblImportStatus.AutoSize = false;
_lblImportStatus.AutoEllipsis = true;
_lblImportStatus.Height = 24;
_lblImportStatus.ForeColor = SystemColors.GrayText;
contenuto.Controls.Add(_lblImportStatus);
_gridImport.Dock = DockStyle.Fill;
_gridImport.ReadOnly = true;
_gridImport.BorderStyle = BorderStyle.None;
_gridImport.BackgroundColor = SystemColors.Window;
_gridImport.RowHeadersVisible = false;
_gridImport.AllowUserToAddRows = false;
_gridImport.AllowUserToDeleteRows = false;
_gridImport.AllowUserToResizeRows = false;
_gridImport.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
_gridImport.MultiSelect = false;
_gridImport.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
_gridImport.ColumnHeadersHeight = 32;
_gridImport.ColumnHeadersDefaultCellStyle.Font = new Font(Font, FontStyle.Bold);
_gridImport.ColumnHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleLeft;
_gridImport.RowTemplate.Height = 26;
_gridImport.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(245, 247, 250);
_gridImport.DefaultCellStyle.SelectionBackColor = ColorePrimario;
_gridImport.DefaultCellStyle.Padding = new Padding(4, 0, 4, 0);
_gridImport.Columns.Add("componente", "Componente");
_gridImport.Columns.Add("descrizione", "Descrizione");
_gridImport.Columns["componente"]!.FillWeight = 30;
_gridImport.Columns["descrizione"]!.FillWeight = 70;
var panel = new Panel();
panel.Controls.Add(_gridImport);
panel.Controls.Add(contenuto);
return panel;
}
private Panel BuildFaseCreaRaw()
{
var contenuto = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
Padding = new Padding(16),
};
contenuto.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var rigaCreaRaw = new FlowLayoutPanel
{
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
Margin = new Padding(0, 0, 0, 12),
};
_btnCreaRaw.Text = "Crea cartella raw";
_btnCreaRaw.Margin = new Padding(0, 0, 8, 0);
UiStile.Primario(_btnCreaRaw);
_btnCreaRaw.Click += (_, _) => CreaCartellaRaw();
rigaCreaRaw.Controls.Add(_btnCreaRaw);
rigaCreaRaw.Controls.Add(new Label
{
AutoSize = true,
ForeColor = SystemColors.GrayText,
Margin = new Padding(0, 9, 0, 0),
Text = "Copia i file dal server nella cartella 'raw'.",
});
contenuto.Controls.Add(rigaCreaRaw);
_gridRaw.Dock = DockStyle.Fill;
_gridRaw.ReadOnly = true;
_gridRaw.BorderStyle = BorderStyle.None;
_gridRaw.BackgroundColor = SystemColors.Window;
_gridRaw.RowHeadersVisible = false;
_gridRaw.AllowUserToAddRows = false;
_gridRaw.AllowUserToDeleteRows = false;
_gridRaw.AllowUserToResizeRows = false;
_gridRaw.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
_gridRaw.MultiSelect = false;
_gridRaw.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
_gridRaw.ColumnHeadersHeight = 32;
_gridRaw.ColumnHeadersDefaultCellStyle.Font = new Font(Font, FontStyle.Bold);
_gridRaw.RowTemplate.Height = 26;
_gridRaw.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(245, 247, 250);
_gridRaw.DefaultCellStyle.SelectionBackColor = ColorePrimario;
_gridRaw.DefaultCellStyle.Padding = new Padding(4, 0, 4, 0);
_gridRaw.Columns.Add("componente", "Componente");
_gridRaw.Columns.Add("percorso", "Percorso CAD");
_gridRaw.Columns["componente"]!.FillWeight = 25;
_gridRaw.Columns["percorso"]!.FillWeight = 75;
_gridRaw.CellDoubleClick += (_, _) => ApriCartellaFileSelezionato();
var panel = new Panel();
panel.Controls.Add(_gridRaw);
panel.Controls.Add(contenuto);
return panel;
}
private Panel BuildFaseTrovaComponenti()
{
var contenuto = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
Padding = new Padding(16),
};
contenuto.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var lblIstruzioni = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
ForeColor = SystemColors.GrayText,
Margin = new Padding(0, 0, 0, 12),
Text = "Gli assiemi referenziano altri PRT e sotto-assiemi al loro interno: premi "
+ "qui per risolvere l'albero componenti (ricorsivamente) e copiare tutto ciò che "
+ "viene trovato nell'indice dentro 'raw'.",
};
contenuto.Controls.Add(lblIstruzioni);
_btnTrovaPrt.Text = "Trova componenti referenziati";
UiStile.Secondario(_btnTrovaPrt);
_btnTrovaPrt.Click += (_, _) => TrovaPrtReferenziati();
contenuto.Controls.Add(_btnTrovaPrt);
var panel = new Panel();
panel.Controls.Add(contenuto);
return panel;
}
private Panel BuildFaseVerifica()
{
// Padding qui invece che su ogni sotto-pannello: si applica uniformemente a top/fill/bottom
// (il layout docked usa il DisplayRectangle del contenitore, già ridotto dal Padding).
var panel = new Panel { Padding = new Padding(16) };
var top = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 1,
};
top.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var lblAnteprimaAsm = new Label
{
AutoSize = true,
ForeColor = SystemColors.GrayText,
Margin = new Padding(0, 0, 0, 4),
Text = "File .asm della distinta trovati nell'indice (trascinali su Creo/Esplora risorse):",
};
top.Controls.Add(lblAnteprimaAsm);
// View.Details invece di LargeIcon: con codici Pro-E lunghi e simili tra loro
// ("9244218...", "9244228..."), le icone in griglia troncavano tutti i nomi allo stesso
// modo (illeggibili, indistinguibili) e lasciavano un grande vuoto sotto le poche righe.
// Una lista a riga singola mostra il nome per intero, in linea con lo stile delle altre
// griglie dell'app (_gridImport/_gridRaw), restando comunque trascinabile.
_lstAnteprimaAsm.View = View.Details;
_lstAnteprimaAsm.MultiSelect = true;
_lstAnteprimaAsm.FullRowSelect = true;
_lstAnteprimaAsm.GridLines = false;
_lstAnteprimaAsm.HeaderStyle = ColumnHeaderStyle.Nonclickable;
_lstAnteprimaAsm.SmallImageList = new ImageList { ImageSize = new Size(20, 20) };
_lstAnteprimaAsm.Columns.Add("File .asm", -2);
_lstAnteprimaAsm.Dock = DockStyle.Fill;
_lstAnteprimaAsm.Margin = new Padding(0, 4, 0, 12);
_lstAnteprimaAsm.BorderStyle = BorderStyle.None;
// La colonna unica segue sempre la larghezza disponibile (niente scroll orizzontale
// né spazio sprecato a destra quando la finestra viene ingrandita).
_lstAnteprimaAsm.Resize += (_, _) =>
{
if (_lstAnteprimaAsm.Columns.Count > 0)
_lstAnteprimaAsm.Columns[0].Width = _lstAnteprimaAsm.ClientSize.Width;
};
_lstAnteprimaAsm.ItemDrag += (_, e) =>
{
var percorsi = _lstAnteprimaAsm.SelectedItems.Cast<ListViewItem>()
.Select(item => (string)item.Tag!)
.ToArray();
if (percorsi.Length > 0)
_lstAnteprimaAsm.DoDragDrop(new DataObject(DataFormats.FileDrop, percorsi), DragDropEffects.Copy);
};
panel.Controls.Add(_lstAnteprimaAsm);
panel.Controls.Add(top);
return panel;
}
private void ScegliFileDistinta()
{
using var dialog = new OpenFileDialog
{
Filter = "File distinta base (*.xls;*.xml)|*.xls;*.xml|Tutti i file|*.*",
Title = "Seleziona il file Distinta Base",
};
if (dialog.ShowDialog(this) == DialogResult.OK)
ImportaDistinta(dialog.FileName);
}
private void ImportaDistinta(string percorso)
{
try
{
var (_, righe) = DistintaBaseImporter.ConvertiInCsv(percorso, _cartellaLavoro!);
_percorsoInputCorrente = percorso;
_righeCorrenti = righe;
_gridImport.Rows.Clear();
foreach (var (componente, descrizione) in righe)
{
_gridImport.Rows.Add(componente, descrizione);
}
_gridRaw.Rows.Clear();
_ultimaCartellaRaw = null;
_ultimaVerifica = null;
ImpostaStatoImport("", Color.Black);
_rigaVerifica.Visible = true;
}
catch (Exception ex)
{
_percorsoInputCorrente = null;
_righeCorrenti = null;
_gridImport.Rows.Clear();
ImpostaStatoImport($"Errore durante la conversione: {ex.Message}", Color.DarkRed);
_rigaVerifica.Visible = false;
}
AggiornaStatoAvanti();
}
/// <summary>Imposta testo/colore dello stato import e il tooltip con il testo completo
/// (la label tronca con "..." quando il percorso non ci sta in una riga).</summary>
private void ImpostaStatoImport(string testo, Color colore)
{
_lblImportStatus.ForeColor = colore;
_lblImportStatus.Text = testo;
_tipImportStatus.SetToolTip(_lblImportStatus, testo);
}
private void VerificaCodiciNelDb()
{
if (_righeCorrenti is null)
return;
var trovati = TrovaPercorsiCad(_righeCorrenti.Select(r => r.Componente));
_ultimaVerifica = trovati;
foreach (DataGridViewRow riga in _gridImport.Rows)
{
string componente = (string)riga.Cells["componente"].Value!;
riga.DefaultCellStyle.BackColor = trovati.ContainsKey(componente)
? Color.FromArgb(198, 239, 206)
: Color.FromArgb(255, 199, 206);
}
AggiornaStatoAvanti();
}
private void CreaCartellaRaw()
{
if (_percorsoInputCorrente is null || _righeCorrenti is null)
return;
// L'assieme generale è già una riga di _righeCorrenti (aggiunta da
// DistintaBaseImporter.ConvertiInCsv leggendo il nome del file distinta), quindi viene
// trovato e copiato in raw come qualsiasi altro componente, senza bisogno di un caso speciale.
var percorsiTrovati = _ultimaVerifica ?? TrovaPercorsiCad(_righeCorrenti.Select(r => r.Componente));
_gridRaw.Rows.Clear();
foreach (var (componente, _) in _righeCorrenti)
{
string percorsoCad = percorsiTrovati.TryGetValue(componente, out var lista)
? string.Join("; ", lista)
: "";
_gridRaw.Rows.Add(componente, percorsoCad);
}
var cartellaRaw = Path.Combine(_cartellaLavoro!, "raw");
int copiati = CopiaFileTrovati(percorsiTrovati.Values.SelectMany(p => p).Distinct(), cartellaRaw);
_ultimaCartellaRaw = cartellaRaw;
ImpostaStatoImport(
$"{percorsiTrovati.Count} componenti con percorso CAD trovato su {_righeCorrenti.Count}. "
+ $"{copiati} file copiati in {cartellaRaw}",
Color.DarkGreen);
AggiornaStatoAvanti();
}
/// <summary>Svuota in un colpo solo le cartelle "raw" e "step" nella cartella di lavoro,
/// previa un'unica conferma. Non tocca l'indice CAD: solo le copie locali generate dalla
/// pipeline.</summary>
private void PulisciRawEStep()
{
string radice = _cartellaLavoro!;
var cartelle = new[] { Path.Combine(radice, "raw"), Path.Combine(radice, "step") }
.Where(Directory.Exists)
.ToList();
if (cartelle.Count == 0)
{
MessageBox.Show(this, $"Nessuna cartella 'raw' o 'step' trovata in {radice}.",
"Pulisci raw e step", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var conferma = MessageBox.Show(this,
$"Eliminare tutti i file in:\n{string.Join("\n", cartelle)}\n\nL'operazione non è reversibile.",
"Pulisci raw e step", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (conferma != DialogResult.Yes)
return;
int eliminati = 0;
foreach (var cartella in cartelle)
{
foreach (var file in Directory.GetFiles(cartella))
{
try
{
File.Delete(file);
eliminati++;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// ponytail: file in uso (es. aperto in Creo) -> si salta, non blocca gli altri
}
}
}
MessageBox.Show(this, $"{eliminati} file eliminati da {string.Join(" e ", cartelle)}.",
"Pulisci raw e step", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
/// <summary>Tutti i codici prt/asm noti all'indice, con i relativi percorsi (ultima
/// versione, un solo file per tipo - stesso criterio di dedup per data_modifica DESC
/// usato ovunque in questa classe). Caricato in un'unica query invece che una per
/// codice: serve sia a risolvere i percorsi dei componenti trovati, sia a
/// AssemblyComponentScanner per validare i token generici (parti commerciali).</summary>
private static Dictionary<string, List<(string Tipo, string Percorso)>> CaricaCodiciNoti(string dbPath)
{
var codiciNoti = new Dictionary<string, List<(string Tipo, string Percorso)>>(StringComparer.OrdinalIgnoreCase);
using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione "
+ "WHERE LOWER(tipo_file) IN ('prt', 'asm') ORDER BY codice, tipo_file, data_modifica DESC";
using var reader = command.ExecuteReader();
while (reader.Read())
{
string codice = reader.GetString(0);
string tipo = reader.GetString(1).ToLowerInvariant();
var lista = codiciNoti.TryGetValue(codice, out var l) ? l : codiciNoti[codice] = new();
if (!lista.Any(x => x.Tipo == tipo))
lista.Add((tipo, reader.GetString(2)));
}
return codiciNoti;
}
/// <summary>Tutti i file prt/asm dell'indice raggruppati per codice, tenendo per ogni
/// cartella la sola versione più alta. A differenza di <see cref="CaricaCodiciNoti"/> non
/// sceglie un vincitore fra le cartelle: lo stesso codice può esistere in più punti della
/// share (copie in cartelle di studio, di lavoro, temporanee) con contenuto diverso, e
/// quale sia quello giusto dipende da chi lo referenzia - vedi
/// <see cref="ScegliPerCartella"/>.</summary>
private static Dictionary<string, List<(string Tipo, string Percorso, int Versione, string Data)>> CaricaCandidati(string dbPath)
{
var candidati = new Dictionary<string, List<(string Tipo, string Percorso, int Versione, string Data)>>(StringComparer.OrdinalIgnoreCase);
using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT codice, tipo_file, percorso_completo, versione, data_modifica FROM files "
+ "WHERE LOWER(tipo_file) IN ('prt', 'asm')";
using var reader = command.ExecuteReader();
while (reader.Read())
{
string codice = reader.GetString(0);
string tipo = reader.GetString(1).ToLowerInvariant();
string percorso = reader.GetString(2);
int versione = reader.GetInt32(3);
var lista = candidati.TryGetValue(codice, out var l) ? l : candidati[codice] = new();
string cartella = Path.GetDirectoryName(percorso) ?? "";
int esistente = lista.FindIndex(x => x.Tipo == tipo
&& string.Equals(Path.GetDirectoryName(x.Percorso), cartella, StringComparison.OrdinalIgnoreCase));
if (esistente < 0)
lista.Add((tipo, percorso, versione, reader.GetString(4)));
else if (versione > lista[esistente].Versione)
lista[esistente] = (tipo, percorso, versione, reader.GetString(4));
}
return candidati;
}
/// <summary>Fra più copie dello stesso codice sulla share sceglie quella che userebbe Creo:
/// prima cerca nella cartella dell'assieme che la referenzia (Creo risolve i componenti
/// partendo da lì), poi ripiega sulla versione più alta e, a parità, sulla più recente.
/// Senza questo criterio si finisce per copiare in "raw" una copia omonima ma di un altro
/// modello - Creo la carica e i vincoli di accoppiamento non si risolvono, quindi l'assieme
/// non si apre affatto.</summary>
private static List<(string Tipo, string Percorso)> ScegliPerCartella(
List<(string Tipo, string Percorso, int Versione, string Data)> candidati, string? cartellaPadre)
{
return candidati
.GroupBy(c => c.Tipo)
.Select(perTipo =>
{
var scelto = perTipo
.OrderByDescending(c => cartellaPadre is not null
&& string.Equals(Path.GetDirectoryName(c.Percorso), cartellaPadre, StringComparison.OrdinalIgnoreCase))
.ThenByDescending(c => c.Versione)
.ThenByDescending(c => c.Data)
.First();
return (scelto.Tipo, scelto.Percorso);
})
.ToList();
}
/// <summary>Codici referenziati direttamente in un assieme: prova prima il parser esatto
/// dei record binari (<see cref="AsmRecordReader"/>, vedi docs/formato-asm-record.md), che
/// non ha i falsi positivi/negativi noti dell'euristica testuale (docs/formato-asm.md); se
/// il file non rispetta il formato validato (<see cref="InvalidDataException"/> - capita su
/// .asm di versioni Creo mai riscontrate nei campioni usati per il reverse engineering)
/// ripiega su <see cref="AssemblyComponentScanner"/>, che ha copertura più ampia anche se
/// meno precisa. Un'eventuale IOException (file irraggiungibile) non viene intercettata qui:
/// resta responsabilità del chiamante, come prima di questo cambiamento.</summary>
private static IEnumerable<string> EstraiCodiciReferenziati(string percorsoAsm, ICollection<string> codiciNoti)
{
try
{
return AsmRecordReader.EstraiCodiciReferenziati(percorsoAsm);
}
catch (InvalidDataException)
{
return AssemblyComponentScanner.EstraiCodiciReferenziati(percorsoAsm, codiciNoti);
}
}
/// <summary>Componenti soppressi di un assieme (<see cref="AsmRecordReader.EstraiCodiciSoppressi"/>):
/// non sono nella distinta, ma senza il loro file Creo non apre l'assieme. Vuoto per i file
/// che il parser esatto non sa leggere: lì si usa l'euristica testuale, che i piazzamenti li
/// riconosce già per conto suo (<see cref="AssemblyComponentScanner"/>).</summary>
private static IEnumerable<string> CodiciSoppressi(string percorsoAsm)
{
try
{
return AsmRecordReader.EstraiCodiciSoppressi(percorsoAsm);
}
catch (InvalidDataException)
{
return Array.Empty<string>();
}
}
/// <summary>Cartella della share da cui proviene un file già copiato in "raw": fra le copie
/// omonime indicizzate è quella di pari dimensione. Serve per gli assiemi di partenza, che
/// arrivano dalla fase precedente senza portarsi dietro l'origine. Null se non si riesce a
/// stabilirla (il file non è nell'indice, o la share non risponde): in quel caso la scelta
/// del componente ricade sul criterio generale.</summary>
private static string? CartellaDiOrigine(string fileInRaw,
Dictionary<string, List<(string Tipo, string Percorso, int Versione, string Data)>> candidati)
{
string nome = Path.GetFileName(fileInRaw);
if (!candidati.TryGetValue(nome.Split('.')[0], out var perCodice))
return null;
try
{
long dimensione = new FileInfo(fileInRaw).Length;
foreach (var c in perCodice.Where(c => string.Equals(Path.GetFileName(c.Percorso), nome, StringComparison.OrdinalIgnoreCase)))
{
var info = new FileInfo(c.Percorso);
if (info.Exists && info.Length == dimensione)
return Path.GetDirectoryName(c.Percorso);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// share non raggiungibile: si prosegue col criterio generale
}
return null;
}
/// <summary>Risolve ricorsivamente l'albero dei componenti a partire dai file .asm indicati
/// (già copiati in "raw"): per ogni assieme legge i codici referenziati al suo interno, li
/// cerca nell'indice, copia in "raw" quelli trovati e, se sono a loro volta assiemi, li
/// accoda per essere scansionati finché non restano solo PRT (foglie) o codici non presenti
/// nell'indice. Usata dal bottone "Trova componenti referenziati".</summary>
private (List<(string Codice, string Tipo, string Percorso)> Trovati, List<string> NonTrovati) RisolviAlberoComponenti(
IEnumerable<string> asmIniziali, IProgress<(int Fatti, int Totale)>? progress = null)
{
// Ogni assieme viaggia con la cartella della share da cui proviene: e' li' che Creo
// cerca per primi i suoi componenti, quindi e' li' che li cerchiamo anche noi.
// "Esterno" marca i rami raggiunti da un riferimento esterno invece che dalla distinta:
// servono su disco ma non sono componenti, quindi i loro codici mancanti non vanno fra i
// "non trovati" (sarebbero falsi allarmi, come per i soppressi).
var daScansionare = new Queue<(string File, string? CartellaOrigine, bool Esterno)>(
asmIniziali.Select(f => (f, (string?)null, false)));
var codiciIniziali = asmIniziali.Select(f => Path.GetFileName(f).Split('.')[0]).ToList();
var codiciVisti = new HashSet<string>(
codiciIniziali.Concat(codiciIniziali.Select(AssemblyComponentScanner.CodiceRadice)),
StringComparer.OrdinalIgnoreCase);
var codiciSoppressiVisti = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var codiciEsterniVisti = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var trovati = new List<(string Codice, string Tipo, string Percorso)>();
var codiciNonTrovati = new List<string>();
if (daScansionare.Count == 0 || _dbPath is null)
return (trovati, codiciNonTrovati);
var codiciNoti = CaricaCodiciNoti(_dbPath);
var candidati = CaricaCandidati(_dbPath);
int processati = 0;
while (daScansionare.Count > 0)
{
var (asm, cartellaOrigine, esterno) = daScansionare.Dequeue();
cartellaOrigine ??= CartellaDiOrigine(asm, candidati);
processati++;
foreach (var codice in EstraiCodiciReferenziati(asm, codiciNoti.Keys))
{
if (!codiciVisti.Add(codice))
continue;
if (!candidati.TryGetValue(codice, out var perCodice))
{
if (!esterno)
codiciNonTrovati.Add(codice);
continue;
}
foreach (var (tipo, percorso) in ScegliPerCartella(perCodice, cartellaOrigine))
{
CopiaFileTrovati(new[] { percorso }, _ultimaCartellaRaw!);
trovati.Add((codice, tipo, percorso));
if (tipo == "asm")
{
daScansionare.Enqueue((Path.Combine(_ultimaCartellaRaw!, Path.GetFileName(percorso)),
Path.GetDirectoryName(percorso), esterno));
}
}
}
// I componenti soppressi non compaiono nella BOM, ma Creo ne vuole comunque il file
// per aprire l'assieme: si copiano senza entrare nel conteggio dei "non trovati"
// (non essendo componenti della distinta, segnalarli come mancanti sarebbe un falso
// allarme) e senza ricorrervi dentro.
foreach (var codice in CodiciSoppressi(asm))
{
if (!codiciSoppressiVisti.Add(codice) || codiciVisti.Contains(codice))
continue;
if (!candidati.TryGetValue(codice, out var perCodice))
continue;
foreach (var (tipo, percorso) in ScegliPerCartella(perCodice, cartellaOrigine))
{
CopiaFileTrovati(new[] { percorso }, _ultimaCartellaRaw!);
trovati.Add((codice, tipo, percorso));
}
}
// I riferimenti esterni (assembly feature, datum copiati verso un altro modello) non
// sono componenti e non compaiono fra i piazzamenti, ma Creo carica il modello citato
// per aprire l'assieme - e con lui i *suoi* componenti, quindi qui, a differenza dei
// soppressi, si ricorre dentro. Vedi docs/formato-asm-record.md §7.5.
foreach (var codice in AsmRecordReader.EstraiCodiciRiferimentiEsterni(asm))
{
if (!codiciEsterniVisti.Add(codice) || codiciVisti.Contains(codice))
continue;
if (!candidati.TryGetValue(codice, out var perCodice))
continue;
foreach (var (tipo, percorso) in ScegliPerCartella(perCodice, cartellaOrigine))
{
CopiaFileTrovati(new[] { percorso }, _ultimaCartellaRaw!);
trovati.Add((codice, tipo, percorso));
if (tipo == "asm")
{
daScansionare.Enqueue((Path.Combine(_ultimaCartellaRaw!, Path.GetFileName(percorso)),
Path.GetDirectoryName(percorso), true));
}
}
}
progress?.Report((processati, processati + daScansionare.Count));
}
return (trovati, codiciNonTrovati);
}
private async void TrovaPrtReferenziati()
{
if (_ultimaCartellaRaw is null || !Directory.Exists(_ultimaCartellaRaw))
return;
if (_dbPath is null)
{
ImpostaStatoImport("Nessun database caricato: impossibile risolvere i componenti referenziati.", Color.DarkRed);
return;
}
// L'assieme generale è già in raw come qualsiasi altro assieme importato (vedi
// CreaCartellaRaw), quindi rientra qui senza bisogno di un caso speciale.
var asmIniziali = Directory.GetFiles(_ultimaCartellaRaw).Where(f => Path.GetFileName(f).Contains(".asm.")).ToList();
using var progressForm = new ProgressoComponentiForm();
var progress = new Progress<(int Fatti, int Totale)>(p => progressForm.Aggiorna(p.Fatti, p.Totale));
var task = Task.Run(() => RisolviAlberoComponenti(asmIniziali, progress));
progressForm.Shown += async (_, _) =>
{
await task;
progressForm.Close();
};
progressForm.ShowDialog(this);
var (trovati, codiciNonTrovati) = await task;
int numPrt = trovati.Count(r => r.Tipo == "prt");
int numAsm = trovati.Count(r => r.Tipo == "asm");
ImpostaStatoImport(
$"Componenti referenziati: {numPrt} PRT e {numAsm} sotto-assiemi trovati e copiati in raw, "
+ $"{codiciNonTrovati.Count} codici referenziati non trovati nell'indice.",
Color.DarkGreen);
using var dialog = new PrtMancantiForm(trovati, codiciNonTrovati, _ultimaCartellaRaw);
dialog.ShowDialog(this);
}
/// <summary>Copia i file trovati nella cartella "raw" indicata, creandola se necessario.
/// Ritorna il numero di file copiati con successo. Parallelizzata (grado limitato): il
/// collo di bottiglia è la latenza di rete verso la share, non la CPU, quindi poche copie
/// concorrenti bastano a sovrapporre le attese senza saturare la share.</summary>
internal static int CopiaFileTrovati(IEnumerable<string> percorsi, string cartellaRaw)
{
Directory.CreateDirectory(cartellaRaw);
int copiati = 0;
Parallel.ForEach(percorsi, new ParallelOptions { MaxDegreeOfParallelism = 6 }, percorso =>
{
try
{
File.Copy(percorso, Path.Combine(cartellaRaw, Path.GetFileName(percorso)), overwrite: true);
Interlocked.Increment(ref copiati);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// ponytail: file non raggiungibile (share offline, permessi) -> lo si salta, non blocca gli altri
}
});
return copiati;
}
/// <summary>Cerca, per ogni codice componente, i percorsi dei file CAD (ultima versione,
/// solo prt/asm) corrispondenti nell'indice.</summary>
private Dictionary<string, List<string>> TrovaPercorsiCad(IEnumerable<string> codici)
{
var risultato = new Dictionary<string, List<string>>();
if (_dbPath is null)
return risultato;
using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly");
connection.Open();
var nomiParametri = TipiPredefiniti.Select((_, i) => $"@tipo{i}");
using var command = connection.CreateCommand();
command.CommandText = $"""
SELECT tipo_file, percorso_completo FROM files_ultima_versione
WHERE LOWER(codice) = LOWER(@codice) AND LOWER(tipo_file) IN ({string.Join(",", nomiParametri)})
ORDER BY tipo_file, data_modifica DESC
""";
var pCodice = command.Parameters.Add("@codice", Microsoft.Data.Sqlite.SqliteType.Text);
for (int i = 0; i < TipiPredefiniti.Length; i++)
{
command.Parameters.AddWithValue($"@tipo{i}", TipiPredefiniti[i]);
}
foreach (var codice in codici.Distinct())
{
pCodice.Value = codice;
using var reader = command.ExecuteReader();
var percorsi = new List<string>();
// Lo stesso codice+tipo può esistere in più cartelle radice (duplicati sulla share):
// essendo ordinato per data_modifica DESC, il primo per tipo è il più recente, gli altri si scartano.
var tipiVisti = new HashSet<string>();
while (reader.Read())
{
if (tipiVisti.Add(reader.GetString(0).ToLowerInvariant()))
{
percorsi.Add(reader.GetString(1));
}
}
if (percorsi.Count > 0)
risultato[codice] = percorsi;
}
return risultato;
}
private static readonly string CartellaImpostazioni = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PrepStep");
private static readonly string PercorsoImpostazioni = Path.Combine(CartellaImpostazioni, "ultimo_db.txt");
private static readonly string PercorsoImpostazioniCartella = Path.Combine(CartellaImpostazioni, "ultima_cartella_lavoro.txt");
/// <summary>Propone l'ultima cartella di lavoro usata (se ancora esiste su disco) o, se non
/// c'è ancora una preferenza salvata (o punta a una cartella cancellata), la cartella dove si
/// trova il DB (più sensata dell'eseguibile: quella è dove l'utente tiene il proprio lavoro,
/// non dov'è installata la app). Richiede quindi che InizializzaDatabase sia già stata
/// chiamata. Sempre modificabile con "File → Scegli cartella di lavoro...".</summary>
private void InizializzaCartellaLavoro()
{
var ultima = LeggiUltimaCartellaLavoro();
_cartellaLavoro = ultima is not null && Directory.Exists(ultima)
? ultima
: (_dbPath is not null ? Path.GetDirectoryName(_dbPath) : null) ?? AppContext.BaseDirectory;
AggiornaStatoAvanti();
}
private void ScegliCartellaLavoro()
{
using var dialog = new FolderBrowserDialog
{
Description = "Seleziona la cartella di lavoro (CSV, raw, step)",
SelectedPath = _cartellaLavoro ?? string.Empty,
};
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
string percorso = dialog.SelectedPath;
_cartellaLavoro = percorso;
SalvaUltimaCartellaLavoro(percorso);
AggiornaStatoAvanti();
}
private static string? LeggiUltimaCartellaLavoro()
{
try
{
return File.Exists(PercorsoImpostazioniCartella) ? File.ReadAllText(PercorsoImpostazioniCartella).Trim() : null;
}
catch (IOException)
{
return null;
}
}
private static void SalvaUltimaCartellaLavoro(string path)
{
try
{
Directory.CreateDirectory(CartellaImpostazioni);
File.WriteAllText(PercorsoImpostazioniCartella, path);
}
catch (IOException)
{
// ponytail: non critico se non si riesce a salvare, si ripropone il default al prossimo avvio
}
}
private void InizializzaDatabase()
{
var candidati = new[]
{
LeggiUltimoDbUsato(),
Path.Combine(AppContext.BaseDirectory, "cad_index.db"),
Path.Combine(Directory.GetCurrentDirectory(), "cad_index.db"),
};
_dbPath = candidati.FirstOrDefault(p => p is not null && File.Exists(p));
if (_dbPath is null)
{
_lblStatus.Text = "Database non trovato. Usa 'Apri DB...' per selezionarlo oppure 'Aggiorna indice' per crearlo.";
_lblDb.Text = "Nessun database caricato. Premi 'Aggiorna indice' per crearlo da X:\\, oppure 'Apri DB...' se ne hai gi\u00e0 uno.";
_lblUltimoAggiornamento.Text = "";
_dbCaricato = false;
_indicatoreDb.Invalidate();
AggiornaStatoAvanti();
return;
}
CaricaDatabase(_dbPath);
}
private void ScegliDatabase()
{
using var dialog = new OpenFileDialog
{
Filter = "Database SQLite (*.db)|*.db|Tutti i file (*.*)|*.*",
Title = "Seleziona cad_index.db",
};
if (dialog.ShowDialog(this) == DialogResult.OK)
{
CaricaDatabase(dialog.FileName);
}
}
private void CaricaDatabase(string path)
{
try
{
using var connection = new SqliteConnection($"Data Source={path};Mode=ReadOnly");
connection.Open();
long totale = (long)(new SqliteCommand("SELECT COUNT(*) FROM files", connection).ExecuteScalar() ?? 0L);
object? ultimaScansioneRaw = new SqliteCommand("SELECT MAX(scansionato_il) FROM files", connection).ExecuteScalar();
_dbPath = path;
_lblDb.Text = $"Database: {path} ({totale:N0} file indicizzati)";
_lblUltimoAggiornamento.Text = ultimaScansioneRaw is string testoData
&& DateTime.TryParse(testoData, out var ultimaScansione)
? $"Ultimo aggiornamento indice: {ultimaScansione.ToLocalTime():dd/MM/yyyy HH:mm}"
: "Ultimo aggiornamento indice: sconosciuto";
_lblStatus.Text = "Pronto.";
SalvaUltimoDbUsato(path);
}
catch (Exception ex)
{
MessageBox.Show(this, $"Impossibile aprire il database:\n{ex.Message}", "Errore",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
_dbCaricato = _dbPath is not null;
_indicatoreDb.Invalidate();
AggiornaStatoAvanti();
}
/// <summary>Ricorda l'ultimo DB caricato con successo, così al prossimo avvio si
/// ricarica automaticamente anche se l'eseguibile gira da una cartella diversa.</summary>
private static string? LeggiUltimoDbUsato()
{
try
{
return File.Exists(PercorsoImpostazioni) ? File.ReadAllText(PercorsoImpostazioni).Trim() : null;
}
catch (IOException)
{
return null;
}
}
private static void SalvaUltimoDbUsato(string path)
{
try
{
Directory.CreateDirectory(CartellaImpostazioni);
File.WriteAllText(PercorsoImpostazioni, path);
}
catch (IOException)
{
// ponytail: non critico se non si riesce a salvare, si torna a chiedere "Apri DB..." la prossima volta
}
}
private async void AggiornaIndice()
{
string dbPath = _dbPath ?? Path.Combine(AppContext.BaseDirectory, "cad_index.db");
if (!Directory.Exists(RadiceDiscoDefault))
{
MessageBox.Show(this, $"Disco non raggiungibile: {RadiceDiscoDefault}", "Errore",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
_btnAggiorna.Enabled = false;
_lblStatus.Text = "Aggiornamento indice in corso...";
using var cts = new CancellationTokenSource();
using var dialog = new ProgressForm();
var progress = new Progress<ScanProgress>(dialog.Aggiorna);
dialog.Annullato += (_, _) => cts.Cancel();
var scanTask = Indexer.ScanAsync(RadiceDiscoDefault, dbPath, progress, cts.Token);
// Chiude automaticamente il dialog quando lo scan termina (successo, errore o annullamento)
_ = scanTask.ContinueWith(_ =>
{
if (dialog.Visible)
{
dialog.BeginInvoke(dialog.Close);
}
}, TaskScheduler.Default);
dialog.ShowDialog(this);
try
{
await scanTask;
_lblStatus.Text = "Indice aggiornato.";
CaricaDatabase(dbPath);
}
catch (OperationCanceledException)
{
_lblStatus.Text = "Aggiornamento annullato.";
}
catch (Exception ex)
{
MessageBox.Show(this, $"Errore durante l'aggiornamento dell'indice:\n{ex.Message}", "Errore",
MessageBoxButtons.OK, MessageBoxIcon.Error);
_lblStatus.Text = "Aggiornamento fallito.";
}
finally
{
_btnAggiorna.Enabled = true;
}
}
private void ApriCartellaFileSelezionato()
{
if (_gridRaw.CurrentRow is null)
{
return;
}
string? percorsi = _gridRaw.CurrentRow.Cells[1].Value as string;
if (string.IsNullOrEmpty(percorsi))
{
return;
}
string primoPercorso = percorsi.Split("; ", 2)[0];
try
{
Process.Start("explorer.exe", $"/select,\"{primoPercorso}\"");
}
catch (Exception ex)
{
MessageBox.Show(this, $"Impossibile aprire Esplora risorse:\n{ex.Message}", "Errore",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}