Files
OpenNest/OpenNest/Controls/DrawingSetBrowser.cs
T
ajandClaude Opus 5.5 87672543cd feat(desktop): import a BOM into a drawing set
Drawing Sets > More > Import BOM reads a BOM and its drawing folder,
matches each drawing to the library by its D4 identity and geometry
(reuse, new revision, or new drawing) and opens the set editor with a
new set prefilled with the BOM quantities. Reading only queries the
library; Create Set lists the uploads and revisions and asks before
storing them, and the set is stored only by the editor's Save.

Parts with no material or thickness still import and are flagged for
Edit Drawing. DrawingPackage.GeometryKey compares geometry independent
of the fresh entity ids each DXF import assigns. In Database mode File >
Import BOM opens this flow instead of creating nests directly; File mode
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00

508 lines
21 KiB
C#

using System;
using System.ComponentModel;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Forms;
namespace OpenNest.Controls
{
/// <summary>
/// Browses the server's drawing sets one bounded page at a time through
/// <see cref="DrawingSetBrowseSession"/>, and creates, edits, archives or restores them and
/// shows their history. <see cref="DrawingSetsForm"/> hosts it on its Sets tab to manage and
/// release sets, and shows a set's Releases on its Releases tab. New Set, Edit and Make Nests
/// are buttons; Archive/Restore, History and Releases, then Import BOM, are in the More menu,
/// which is also the list's right-click menu. In pick mode
/// (<see cref="ChooseDrawingSetForm"/>) only active sets are listed, and choosing a set or
/// New Set raises <see cref="SetActivated"/> or <see cref="NewSetChosen"/> instead.
/// </summary>
public partial class DrawingSetBrowser : UserControl
{
private IDrawingSetStore sets;
private IDrawingLibraryStore library;
private string serverUrl;
private INestRepository nests;
private Action<Nest, NestRecord> openNest;
private Func<Guid, bool> isNestOpen = _ => false;
private DrawingSetBrowseSession session;
private BrowsePager<DrawingSetPage, DrawingSetSortField> pager;
private bool pick;
private bool busy;
/// <summary>Shows a message and returns the operator's answer; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
/// <summary>Shows the set editor and returns the saved record, or null; replaced by tests.</summary>
internal Func<DrawingSetEditSession, DrawingSetRecord> EditSet { get; set; }
/// <summary>Shows a set's revision history; replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowHistory { get; set; }
/// <summary>Shows a set's releases; set by the host (Releases is disabled without it) and replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowReleases { get; set; }
/// <summary>
/// Shows Import BOM and returns the new set it prefilled (its library drawings already
/// stored with the operator's consent), or null when cancelled; replaced by tests.
/// </summary>
internal Func<DrawingSetEditSession> ImportBom { get; set; }
/// <summary>Shows the release dialog for a set read with its members; replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowRelease { get; set; }
/// <summary>Pick mode: an active set was double-clicked (or chosen with Enter).</summary>
public event EventHandler SetActivated;
/// <summary>Pick mode: New Set was chosen.</summary>
public event EventHandler NewSetChosen;
/// <summary>The selection changed.</summary>
public event EventHandler SelectedSetChanged;
public DrawingSetBrowser()
{
InitializeComponent();
GridStyle.Apply(setGrid);
nameColumn.Tag = DrawingSetSortField.Name;
customerColumn.Tag = DrawingSetSortField.Customer;
revisionColumn.Tag = DrawingSetSortField.Revision;
savedColumn.Tag = DrawingSetSortField.SavedAt;
}
/// <summary>True once <see cref="Initialize"/> has run; the first <see cref="RefreshAsync"/> loads the list.</summary>
public bool IsInitialized => session != null;
/// <summary>True after the list has been requested at least once.</summary>
public bool HasLoaded { get; private set; }
/// <summary>The highlighted set as listed (without members).</summary>
public DrawingSetRecord SelectedSet => setGrid.CurrentRow?.Tag as DrawingSetRecord;
/// <param name="library">Used by the set editor to pick drawings.</param>
/// <param name="pick">Choose an active set instead of managing them.</param>
public void Initialize(IDrawingSetStore sets, IDrawingLibraryStore library, string serverUrl, bool pick = false)
{
if (session != null)
throw new InvalidOperationException("The set browser is already initialized.");
this.sets = sets ?? throw new ArgumentNullException(nameof(sets));
this.library = library ?? throw new ArgumentNullException(nameof(library));
this.serverUrl = serverUrl ?? "";
this.pick = pick;
session = new DrawingSetBrowseSession(sets);
Disposed += (_, _) => session.Dispose();
pager = new BrowsePager<DrawingSetPage, DrawingSetSortField>(
session, setGrid, summaryLabel, previousButton, nextButton, "the drawing sets")
{
Populate = Populate,
Settled = UpdateCommands,
};
ShowMessage = (text, buttons, icon) => MessageBox.Show(FindForm(), text, FindForm()?.Text ?? "Drawing Sets", buttons, icon);
EditSet = edit =>
{
using var form = new EditDrawingSetForm(edit, library, serverUrl);
return form.ShowDialog(FindForm()) == DialogResult.OK ? form.Saved : null;
};
ShowHistory = set =>
{
using var form = new DrawingSetHistoryForm(sets, set);
form.ShowDialog(FindForm());
};
ImportBom = () =>
{
var defaults = MachineSettings.LoadSavedNestDefaults(out _);
var cutType = Enum.TryParse<CutType>(Properties.Settings.Default.DrawingLibraryCutType, out var saved)
&& Enum.IsDefined(saved) ? saved : CutType.Laser;
using var form = new ImportBomToSetForm(sets, library, defaults.Units, cutType, Environment.UserName);
return form.ShowDialog(FindForm()) == DialogResult.OK ? form.Edit : null;
};
setGrid.ContextMenuStrip = pick ? null : moreMenu;
ShowRelease = set =>
{
using var form = new ReleaseSetForm(sets, library, nests, this.serverUrl, set, openNest) { IsNestOpen = isNestOpen };
form.ShowDialog(FindForm());
};
if (pick)
{
newButton.Text = "New Set";
showArchivedCheckBox.Visible = false;
editButton.Visible = false;
moreButton.Visible = false;
}
pager.UpdateNavigation();
UpdateCommands();
}
/// <summary>
/// Shows Release, which makes nests from the selected set and any others the operator
/// adds: saves them through <paramref name="nests"/> and opens each with
/// <paramref name="openNest"/>. <paramref name="isNestOpen"/> says whether a server nest
/// is open in a tab, which keeps a release from merging into it.
/// </summary>
public void EnableRelease(INestRepository nests, Action<Nest, NestRecord> openNest, Func<Guid, bool> isNestOpen = null)
{
this.isNestOpen = isNestOpen ?? (_ => false);
if (session == null || pick)
throw new InvalidOperationException("Release needs an initialized set browser that is not picking.");
this.nests = nests ?? throw new ArgumentNullException(nameof(nests));
this.openNest = openNest ?? throw new ArgumentNullException(nameof(openNest));
releaseButton.Visible = true;
UpdateCommands();
}
/// <summary>Re-reads the current page (the first call loads the list).</summary>
public Task RefreshAsync()
{
HasLoaded = true;
return RunAsync(session.RefreshAsync);
}
private void SearchBox_TextChanged(object sender, EventArgs e)
{
searchTimer.Stop();
searchTimer.Start();
}
private async void SearchTimer_Tick(object sender, EventArgs e)
{
searchTimer.Stop();
if (session != null)
await RunAsync(() => session.SetSearchAsync(searchBox.Text));
}
private async void ShowArchivedCheckBox_CheckedChanged(object sender, EventArgs e)
{
if (session != null)
await RunAsync(() => session.SetIncludeArchivedAsync(showArchivedCheckBox.Checked));
}
private async void SetGrid_ColumnHeaderMouseClick(object sender, DataGridViewCellMouseEventArgs e)
{
// Sorting a page locally would misrepresent the list; the server sorts all matches.
if (session != null && e.Button == MouseButtons.Left && setGrid.Columns[e.ColumnIndex].Tag is DrawingSetSortField field)
await RunAsync(() => session.SortByAsync(field));
}
private async void PreviousButton_Click(object sender, EventArgs e) =>
await RunAsync(session.PreviousPageAsync);
private async void NextButton_Click(object sender, EventArgs e) =>
await RunAsync(session.NextPageAsync);
private void SetGrid_CurrentCellChanged(object sender, EventArgs e)
{
notesBox.Text = SelectedSet?.Notes ?? "";
UpdateCommands();
SelectedSetChanged?.Invoke(this, EventArgs.Empty);
}
private async void SetGrid_CellDoubleClick(object sender, DataGridViewCellEventArgs e)
{
if (e.RowIndex < 0)
return;
if (pick)
ActivateSelected();
else
await EditSelectedAsync();
}
private void SetGrid_CellMouseDown(object sender, DataGridViewCellMouseEventArgs e)
{
// A right-click acts on the row under the pointer, as the menu's commands read the selection.
if (e.Button == MouseButtons.Right && e.RowIndex >= 0 && !pick)
setGrid.CurrentCell = setGrid.Rows[e.RowIndex].Cells[System.Math.Max(e.ColumnIndex, 0)];
}
private void MoreButton_Click(object sender, EventArgs e) =>
moreMenu.Show(moreButton, new Point(0, moreButton.Height));
private void MoreMenu_Opening(object sender, CancelEventArgs e)
{
UpdateCommands();
e.Cancel = session == null || pick;
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (keyData == Keys.Enter && setGrid.ContainsFocus && SelectedSet != null)
{
if (pick)
ActivateSelected();
else
_ = EditSelectedAsync();
return true;
}
return base.ProcessCmdKey(ref msg, keyData);
}
private async void NewButton_Click(object sender, EventArgs e) => await NewSetAsync();
private async void EditButton_Click(object sender, EventArgs e) => await EditSelectedAsync();
private async void ArchiveMenuItem_Click(object sender, EventArgs e) => await ToggleArchivedAsync();
private void HistoryMenuItem_Click(object sender, EventArgs e)
{
if (SelectedSet is { } set)
ShowHistory(set);
}
private void ReleasesMenuItem_Click(object sender, EventArgs e)
{
if (SelectedSet is { } set)
ShowReleases?.Invoke(set);
}
private async void ReleaseButton_Click(object sender, EventArgs e) => await ReleaseSelectedAsync();
private async void ImportBomMenuItem_Click(object sender, EventArgs e) => await ImportBomAsync();
/// <summary>
/// Imports a BOM into a new set: the import dialog matches its drawings to the library,
/// then the set editor opens prefilled for review and Save, as New Set does.
/// </summary>
internal async Task ImportBomAsync()
{
if (session == null || pick || busy)
return;
var edit = ImportBom();
if (edit == null || IsDisposed)
return;
var saved = EditSet(edit);
if (IsDisposed)
return;
if (saved == null)
{
statusLabel.Text = "The imported set was not saved.";
return;
}
await RunAsync(session.RefreshAsync);
if (!IsDisposed)
statusLabel.Text = $"Saved set '{saved.Name}' revision {saved.Revision}.";
}
/// <summary>Reads the selected active set with its members' current drawings and opens the release dialog.</summary>
internal async Task ReleaseSelectedAsync()
{
var listed = SelectedSet;
if (session == null || nests == null || listed == null || listed.Archived || busy)
return;
DrawingSetRecord current;
busy = true;
UpdateCommands();
statusLabel.Text = $"Reading set '{listed.Name}'...";
try
{
current = await sets.GetAsync(listed.Id);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
Fail($"Could not read set '{listed.Name}': {ex.Message}");
return;
}
finally
{
busy = false;
if (!IsDisposed)
UpdateCommands();
}
if (IsDisposed)
return;
statusLabel.Text = "";
if (current == null || current.Archived)
{
ShowMessage(current == null
? $"Set '{listed.Name}' no longer exists on the server."
: $"Set '{listed.Name}' was archived. Restore it to release it.", MessageBoxButtons.OK, MessageBoxIcon.Warning);
await RunAsync(session.RefreshAsync);
return;
}
ShowRelease(current);
}
private void ActivateSelected()
{
if (SelectedSet is { Archived: false })
SetActivated?.Invoke(this, EventArgs.Empty);
}
/// <summary>Creates a set in the editor; in pick mode, raises <see cref="NewSetChosen"/> instead.</summary>
internal async Task NewSetAsync()
{
if (session == null || busy)
return;
if (pick)
{
NewSetChosen?.Invoke(this, EventArgs.Empty);
return;
}
var saved = EditSet(new DrawingSetEditSession(sets, null, Environment.UserName));
if (saved == null || IsDisposed)
return;
await RunAsync(session.RefreshAsync);
if (!IsDisposed)
statusLabel.Text = $"Saved set '{saved.Name}' revision {saved.Revision}.";
}
/// <summary>
/// Reads the selected set with its members' current drawings, edits it, then reloads
/// the page so the list shows the saved revision.
/// </summary>
internal async Task EditSelectedAsync()
{
var listed = SelectedSet;
if (session == null || listed == null || listed.Archived || busy)
return;
DrawingSetRecord current;
busy = true;
UpdateCommands();
statusLabel.Text = $"Reading set '{listed.Name}'...";
try
{
current = await sets.GetAsync(listed.Id);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
Fail($"Could not read set '{listed.Name}': {ex.Message}");
return;
}
finally
{
busy = false;
if (!IsDisposed)
UpdateCommands();
}
if (IsDisposed)
return;
statusLabel.Text = "";
if (current == null || current.Archived)
{
ShowMessage(current == null
? $"Set '{listed.Name}' no longer exists on the server."
: $"Set '{listed.Name}' was archived. Restore it to edit it.", MessageBoxButtons.OK, MessageBoxIcon.Warning);
await RunAsync(session.RefreshAsync);
return;
}
var saved = EditSet(new DrawingSetEditSession(sets, current, Environment.UserName));
if (saved == null || IsDisposed)
return;
await RunAsync(session.RefreshAsync);
if (!IsDisposed)
statusLabel.Text = $"Saved set '{saved.Name}' revision {saved.Revision}.";
}
/// <summary>Archives the selected active set (after confirming) or restores an archived one.</summary>
internal async Task ToggleArchivedAsync()
{
var listed = SelectedSet;
if (session == null || listed == null || busy || pick)
return;
var archive = !listed.Archived;
if (archive && ShowMessage($"Archive set '{listed.Name}'? It is hidden from the list until restored; nothing is deleted.",
MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK)
return;
busy = true;
UpdateCommands();
try
{
var changed = archive
? await sets.ArchiveAsync(listed.Id, listed.Revision)
: await sets.RestoreAsync(listed.Id, listed.Revision);
if (IsDisposed)
return;
statusLabel.Text = $"{(archive ? "Archived" : "Restored")} set '{changed.Name}'.";
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
// A conflict (changed since it was listed) is an IOException too; nothing was changed.
Fail(ex is DrawingSetSaveConflictException
? ex.Message
: $"Could not {(archive ? "archive" : "restore")} set '{listed.Name}': {ex.Message}");
}
finally
{
busy = false;
}
if (!IsDisposed)
{
var status = statusLabel.Text;
await RunAsync(session.RefreshAsync);
if (!IsDisposed)
statusLabel.Text = status;
}
}
private void Fail(string message)
{
if (IsDisposed)
return;
statusLabel.Text = "";
ShowMessage(message, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private Task RunAsync(Func<Task<bool>> request) => pager.RunAsync(request);
private void Populate(DrawingSetPage page)
{
setGrid.Rows.Clear();
foreach (var record in page?.Items ?? Array.Empty<DrawingSetRecord>())
{
// Tagged before it is added: adding the first row selects it, and the
// selection handlers read the tag.
var row = new DataGridViewRow { Tag = record };
row.CreateCells(setGrid,
record.Name,
record.Customer,
record.MemberCount,
record.Revision,
record.SavedAt == default ? "" : record.SavedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture),
record.SavedBy,
record.Archived ? "Archived" : "");
setGrid.Rows.Add(row);
}
// The grid only sometimes makes the first row current by itself (it depends on focus).
if (setGrid.CurrentCell == null && setGrid.Rows.Count > 0)
setGrid.CurrentCell = setGrid.Rows[0].Cells[0];
pager.ShowSortGlyphs();
notesBox.Text = SelectedSet?.Notes ?? "";
}
private void UpdateCommands()
{
var selected = SelectedSet;
var ready = session != null && !busy;
newButton.Enabled = ready;
editButton.Enabled = ready && selected is { Archived: false };
archiveMenuItem.Enabled = ready && selected != null;
archiveMenuItem.Text = selected is { Archived: true } ? "&Restore" : "&Archive";
historyMenuItem.Enabled = ready && selected != null;
releasesMenuItem.Enabled = ready && selected != null && ShowReleases != null;
importBomMenuItem.Enabled = ready && !pick;
moreButton.Enabled = archiveMenuItem.Enabled || historyMenuItem.Enabled || releasesMenuItem.Enabled
|| importBomMenuItem.Enabled;
releaseButton.Enabled = ready && nests != null && selected is { Archived: false, MemberCount: > 0 };
}
}
}