feat(ui): wire file and database nest storage

This commit is contained in:
aj
2026-09-29 16:21:17 -04:00
parent 437d659c9d
commit 64045be968
10 changed files with 896 additions and 27 deletions
+45
View File
@@ -0,0 +1,45 @@
using System.Linq;
namespace OpenNest.Data;
/// <summary>
/// Builds the shareable <see cref="NestRecord"/> metadata from a live <see cref="Nest"/>.
/// Counts are computed here (not carried on <see cref="Nest"/> itself) so every caller
/// that saves to the nest server gets the same definition of "plate" and "part" counts.
/// </summary>
public static class NestRecordFactory
{
/// <summary>
/// Builds a record for uploading <paramref name="nest"/>. <paramref name="id"/> is the
/// client-tracked id: <see cref="System.Guid.Empty"/> for a first save (the server
/// assigns one), or the previously returned id to update an existing record.
/// <paramref name="fileSize"/> is the size of the serialized .nest archive being
/// uploaded alongside this record; the server also recomputes it independently.
/// </summary>
public static NestRecord FromNest(Nest nest, System.Guid id, long fileSize)
{
ArgumentNullException.ThrowIfNull(nest);
return new NestRecord
{
Id = id,
Name = nest.Name ?? "",
Customer = nest.Customer ?? "",
DateCreated = nest.DateCreated,
DateModified = nest.DateLastModified,
Material = nest.Material?.Name ?? "",
Thickness = nest.Thickness,
Status = nest.Status,
PlateCount = nest.Plates.Count,
PartCount = nest.Plates.Sum(CountNonCutoffParts),
// Nest has no separate "comments" field; Notes is the closest analog
// shown in the nest info dialog, so it round-trips as Comments here.
Comments = nest.Notes ?? "",
MadeBy = nest.MadeBy ?? "",
FileSize = fileSize,
};
}
private static int CountNonCutoffParts(Plate plate) =>
plate.Parts.Count(part => !part.BaseDrawing.IsCutOff);
}
+46
View File
@@ -0,0 +1,46 @@
namespace OpenNest.Data;
/// <summary>
/// Tracks a document's database identity, bound to a particular server address.
/// A failed upload leaves that identity untouched. Switching servers creates a new
/// record rather than accidentally updating an unrelated record with the same id.
/// </summary>
public sealed class NestSaveSession
{
public Guid RemoteId { get; private set; }
public string ServerUrl { get; private set; } = "";
public void Bind(Guid id, string serverUrl)
{
if (id == Guid.Empty)
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
RemoteId = id;
ServerUrl = Normalize(serverUrl);
}
public async Task<NestRecord> SaveAsync(
INestRepository repository, string serverUrl, Nest nest, byte[] archive,
bool saveCopy = false, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(nest);
ArgumentNullException.ThrowIfNull(archive);
var normalizedUrl = Normalize(serverUrl);
if (normalizedUrl.Length == 0)
throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
var record = NestRecordFactory.FromNest(nest, id, archive.LongLength);
var saved = id == Guid.Empty
? await repository.UploadAsync(archive, record, cancellationToken)
: await repository.UpdateFileAsync(id, archive, record, cancellationToken);
if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id))
throw new InvalidDataException("Server returned an invalid nest id.");
Bind(saved.Id, normalizedUrl);
return saved;
}
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
}
@@ -0,0 +1,82 @@
using OpenNest.Data;
using Vector = OpenNest.Geometry.Vector;
namespace OpenNest.Tests.Data;
public class NestRecordFactoryTests
{
[Fact]
public void FromNest_CountsPlatesAndNonCutoffParts()
{
var nest = new Nest("Job 42")
{
Customer = "Cozy Cat",
Material = new Material("Aluminum"),
Thickness = 0.125,
Status = NestStatus.ToBeCut,
MadeBy = "AJ",
Notes = "rush order",
DateCreated = new DateTime(2026, 1, 1),
DateLastModified = new DateTime(2026, 2, 2),
};
var plate1 = nest.CreatePlate();
var drawingA = new Drawing("A");
var drawingB = new Drawing("B");
nest.Drawings.Add(drawingA);
nest.Drawings.Add(drawingB);
plate1.Parts.Add(new Part(drawingA));
plate1.Parts.Add(new Part(drawingB));
var plate2 = nest.CreatePlate();
var drawingC = new Drawing("C");
nest.Drawings.Add(drawingC);
plate2.Parts.Add(new Part(drawingC));
var cutoff = new CutOff(new Vector(0, 0), CutOffAxis.Vertical);
plate2.CutOffs.Add(cutoff);
plate2.Parts.Add(new Part(cutoff.Drawing));
var record = NestRecordFactory.FromNest(nest, Guid.Empty, fileSize: 1024);
Assert.Equal(Guid.Empty, record.Id);
Assert.Equal("Job 42", record.Name);
Assert.Equal("Cozy Cat", record.Customer);
Assert.Equal("Aluminum", record.Material);
Assert.Equal(0.125, record.Thickness);
Assert.Equal(NestStatus.ToBeCut, record.Status);
Assert.Equal("AJ", record.MadeBy);
Assert.Equal("rush order", record.Comments);
Assert.Equal(new DateTime(2026, 1, 1), record.DateCreated);
Assert.Equal(new DateTime(2026, 2, 2), record.DateModified);
Assert.Equal(2, record.PlateCount);
// 2 real parts on plate1 + 1 real part on plate2; the cutoff part is excluded.
Assert.Equal(3, record.PartCount);
Assert.Equal(1024, record.FileSize);
}
[Fact]
public void FromNest_EmptyNest_ZeroCounts()
{
var nest = new Nest("Empty");
var record = NestRecordFactory.FromNest(nest, Guid.NewGuid(), fileSize: 0);
Assert.Equal(0, record.PlateCount);
Assert.Equal(0, record.PartCount);
Assert.Equal("", record.Material);
Assert.Equal("", record.Comments);
Assert.Equal("", record.MadeBy);
}
[Fact]
public void FromNest_PreservesGivenId()
{
var id = Guid.NewGuid();
var nest = new Nest("Keep Id");
var record = NestRecordFactory.FromNest(nest, id, fileSize: 5);
Assert.Equal(id, record.Id);
}
}
@@ -0,0 +1,85 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class NestSaveSessionTests
{
[Fact]
public async Task Save_CreatesThenUpdatesAndSaveCopyCreatesNewRecord()
{
var repository = new FakeRepository();
var session = new NestSaveSession();
var nest = new Nest("Shop job");
var bytes = new byte[] { 1, 2, 3 };
await session.SaveAsync(repository, "http://shop:8090/", nest, bytes);
var firstId = session.RemoteId;
Assert.NotEqual(Guid.Empty, firstId);
Assert.Equal(3, repository.LastRecord!.FileSize);
Assert.Equal("Shop job", repository.LastRecord.Name);
Assert.Equal(1, repository.Uploads);
await session.SaveAsync(repository, "http://shop:8090", nest, bytes);
Assert.Equal(firstId, repository.UpdatedId);
Assert.Equal(1, repository.Updates);
await session.SaveAsync(repository, "http://shop:8090", nest, bytes, saveCopy: true);
Assert.NotEqual(firstId, session.RemoteId);
Assert.Equal(2, repository.Uploads);
}
[Fact]
public async Task FailedSaveAndServerSwitch_NeverOverwritePriorIdentity()
{
var repository = new FakeRepository();
var session = new NestSaveSession();
var oldId = Guid.NewGuid();
session.Bind(oldId, "http://old:8090");
repository.Fail = true;
await Assert.ThrowsAsync<IOException>(() =>
session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]));
Assert.Equal(oldId, session.RemoteId);
Assert.Equal("http://old:8090", session.ServerUrl);
Assert.Equal(Guid.Empty, repository.LastRecord!.Id);
Assert.Equal(0, repository.Updates);
repository.Fail = false;
await session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]);
Assert.NotEqual(oldId, session.RemoteId);
Assert.Equal("http://new:8090", session.ServerUrl);
}
private sealed class FakeRepository : INestRepository
{
public int Uploads { get; private set; }
public int Updates { get; private set; }
public Guid UpdatedId { get; private set; }
public NestRecord? LastRecord { get; private set; }
public bool Fail { get; set; }
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default)
{
Uploads++;
LastRecord = record;
if (Fail) throw new IOException("Offline");
record.Id = Guid.NewGuid();
return Task.FromResult(record);
}
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default)
{
Updates++;
UpdatedId = id;
LastRecord = record;
if (Fail) throw new IOException("Offline");
return Task.FromResult(record);
}
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
}
}
+29
View File
@@ -1,5 +1,8 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Data;
using OpenNest.IO;
namespace OpenNest
@@ -12,6 +15,12 @@ namespace OpenNest
public string LastSavePath { get; private set; }
private readonly NestSaveSession remoteSession = new();
public Guid RemoteId => remoteSession.RemoteId;
public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl);
public string Name => Nest?.Name;
public bool HasSavePath => File.Exists(LastSavePath);
@@ -26,5 +35,25 @@ namespace OpenNest
var writer = new NestWriter(Nest);
writer.Write(path);
}
/// <summary>Serializes the current nest to a .nest archive in memory.</summary>
public byte[] Serialize()
{
using var stream = new MemoryStream();
new NestWriter(Nest).Write(stream);
return stream.ToArray();
}
/// <summary>Uploads this nest, preserving its server identity only on success.</summary>
public async Task<NestRecord> SaveToDatabaseAsync(
INestRepository repository, string serverUrl, bool saveCopy = false,
CancellationToken cancellationToken = default)
{
var bytes = Serialize();
var saved = await remoteSession.SaveAsync(
repository, serverUrl, Nest, bytes, saveCopy, cancellationToken);
LastSaveDate = DateTime.Now;
return saved;
}
}
}
+152 -23
View File
@@ -30,6 +30,10 @@ namespace OpenNest.Forms
private bool clickUpdateLocation;
private bool nestingInProgress;
private CancellationTokenSource nestingCts;
private bool databaseSaveInProgress;
private bool databaseOpenInProgress;
private readonly ToolStripMenuItem storageModeMenu = new("Storage Mode...");
private readonly ToolStripMenuItem exportNestMenu = new("Export .nest...");
private const float ZoomInFactor = 1.5f;
private const float ZoomOutFactor = 1.0f / ZoomInFactor;
@@ -48,6 +52,10 @@ namespace OpenNest.Forms
public MainForm()
{
InitializeComponent();
storageModeMenu.Click += StorageMode_Click;
exportNestMenu.Click += ExportNest_Click;
mnuFile.DropDownItems.Insert(mnuFile.DropDownItems.IndexOf(mnuFileExport), exportNestMenu);
mnuFile.DropDownItems.Insert(mnuFile.DropDownItems.IndexOf(mnuFileExit), storageModeMenu);
UpdateOverlapMenu();
LoadSettings();
@@ -150,9 +158,12 @@ namespace OpenNest.Forms
return result;
}
private void LoadNest(Nest nest, FormWindowState windowState = FormWindowState.Maximized)
private void LoadNest(Nest nest, FormWindowState windowState = FormWindowState.Maximized,
Guid remoteId = default, string serverUrl = null)
{
var editForm = new EditNestForm(nest);
if (remoteId != Guid.Empty)
editForm.Document.BindRemote(remoteId, serverUrl);
editForm.MdiParent = this;
editForm.PlateChanged += (sender, e) =>
{
@@ -198,10 +209,11 @@ namespace OpenNest.Forms
var hasValue = activeForm != null;
btnZoomToFit.Enabled = hasValue;
mnuFileSave.Enabled = hasValue;
btnSave.Enabled = hasValue;
btnSaveAs.Enabled = hasValue;
mnuFileSaveAs.Enabled = hasValue;
mnuFileSave.Enabled = hasValue && !databaseSaveInProgress;
btnSave.Enabled = hasValue && !databaseSaveInProgress;
btnSaveAs.Enabled = hasValue && !databaseSaveInProgress;
mnuFileSaveAs.Enabled = hasValue && !databaseSaveInProgress;
exportNestMenu.Enabled = hasValue;
mnuFileExport.Enabled = hasValue;
mnuFileExportAll.Enabled = hasValue;
btnZoomOut.Enabled = hasValue;
@@ -563,21 +575,57 @@ namespace OpenNest.Forms
LoadNest(nest, windowState);
}
private void Open_Click(object sender, EventArgs e)
private async void Open_Click(object sender, EventArgs e)
{
var dlg = new OpenFileDialog();
dlg.Filter = NestFormat.FileFilter;
dlg.Multiselect = true;
if (dlg.ShowDialog() == DialogResult.OK)
if (NestStorage.Settings.Mode == NestStorageMode.File)
{
var reader = new NestReader(dlg.FileName);
var nest = reader.Read();
LoadNest(nest);
if (reader.Warnings.Count > 0)
MessageBox.Show(this, string.Join(Environment.NewLine, reader.Warnings),
"Nest Load Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
using var dlg = new OpenFileDialog { Filter = NestFormat.FileFilter, Multiselect = true };
if (dlg.ShowDialog(this) == DialogResult.OK)
{
var reader = new NestReader(dlg.FileName);
var nest = reader.Read();
LoadNest(nest);
ShowNestWarnings(reader);
}
return;
}
try
{
var repository = NestStorage.CreateRepository();
using var dlg = new SavedNestsForm(repository);
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
var serverUrl = NestStorage.Settings.ServerUrl;
databaseOpenInProgress = true;
storageModeMenu.Enabled = false;
var file = await repository.GetFileAsync(dlg.SelectedId);
if (file == null)
throw new FileNotFoundException("This nest no longer exists on the server.");
using var stream = new MemoryStream(file);
var reader = new NestReader(stream);
var nest = reader.Read();
LoadNest(nest, remoteId: dlg.SelectedId, serverUrl: serverUrl);
ShowNestWarnings(reader);
}
catch (Exception ex)
{
ShowStorageError("open", ex);
}
finally
{
databaseOpenInProgress = false;
if (!IsDisposed)
storageModeMenu.Enabled = !databaseSaveInProgress;
}
}
private void ShowNestWarnings(NestReader reader)
{
if (reader.Warnings.Count > 0)
MessageBox.Show(this, string.Join(Environment.NewLine, reader.Warnings),
"Nest Load Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
private void ImportBom_Click(object sender, EventArgs e)
@@ -587,18 +635,99 @@ namespace OpenNest.Forms
form.ShowDialog(this);
}
private void Save_Click(object sender, EventArgs e)
private async void Save_Click(object sender, EventArgs e)
{
if (activeForm != null)
activeForm.Save();
var form = activeForm;
if (form == null || databaseSaveInProgress)
return;
if (NestStorage.Settings.Mode == NestStorageMode.File)
form.Save();
else
await SaveDatabaseAsync(form, saveCopy: false);
}
private void SaveAs_Click(object sender, EventArgs e)
private async void SaveAs_Click(object sender, EventArgs e)
{
if (activeForm != null)
activeForm.SaveAs();
var form = activeForm;
if (form == null || databaseSaveInProgress)
return;
if (NestStorage.Settings.Mode == NestStorageMode.File)
form.SaveAs();
else
await SaveDatabaseAsync(form, saveCopy: true);
}
private async Task SaveDatabaseAsync(EditNestForm form, bool saveCopy)
{
databaseSaveInProgress = true;
storageModeMenu.Enabled = false;
EnableCheck();
try
{
var serverUrl = NestStorage.Settings.ServerUrl;
var saved = await form.Document.SaveToDatabaseAsync(
NestStorage.CreateRepository(), serverUrl, saveCopy);
if (!form.IsDisposed)
form.Text = form.Document.Name;
if (!IsDisposed)
statusLabel1.Text = $"Saved {saved.Name} to nest database";
}
catch (Exception ex)
{
if (!IsDisposed)
ShowStorageError("save", ex);
}
finally
{
databaseSaveInProgress = false;
if (!IsDisposed)
{
storageModeMenu.Enabled = !databaseOpenInProgress;
EnableCheck();
}
}
}
private void StorageMode_Click(object sender, EventArgs e)
{
using var dlg = new StorageModeForm();
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
try
{
NestStorage.Save(dlg.SelectedMode, dlg.ServerUrl);
statusLabel1.Text = $"Nest storage: {dlg.SelectedMode}";
}
catch (Exception ex)
{
ShowStorageError("change storage mode", ex);
}
}
private void ExportNest_Click(object sender, EventArgs e)
{
var form = activeForm;
if (form == null)
return;
using var dlg = new SaveFileDialog { Filter = NestFormat.FileFilter, FileName = form.Nest.Name };
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
try
{
// Export never changes the document's save path, remote id, or nest name.
new NestWriter(form.Nest).Write(dlg.FileName);
statusLabel1.Text = $"Exported .nest to {dlg.FileName}";
}
catch (Exception ex)
{
ShowStorageError("export", ex);
}
}
private void ShowStorageError(string action, Exception ex) =>
MessageBox.Show(this, $"Could not {action} nest: {ex.Message}",
"Nest Storage", MessageBoxButtons.OK, MessageBoxIcon.Error);
private void Export_Click(object sender, EventArgs e)
{
if (activeForm == null)
+51
View File
@@ -0,0 +1,51 @@
using System;
using OpenNest.Data;
namespace OpenNest.Forms
{
/// <summary>
/// App-scoped nest storage-mode selection (File vs Database) and the shared
/// <see cref="RemoteNestRepository"/> for the configured server. Settings persist
/// to %APPDATA%\OpenNest\storage.json via <see cref="NestStorageSettings"/>.
/// </summary>
public static class NestStorage
{
private static NestStorageSettings settings =
NestStorageSettings.Load(NestStorageSettings.DefaultPath);
private static RemoteNestRepository cachedRepository;
private static string cachedServerUrl;
/// <summary>Current settings, loaded once at process start.</summary>
public static NestStorageSettings Settings => settings;
/// <summary>
/// Persists new settings and invalidates the cached repository so the next
/// <see cref="CreateRepository"/> call picks up a changed server URL.
/// If persistence fails the current mode remains unchanged.
/// </summary>
public static void Save(NestStorageMode mode, string serverUrl)
{
var next = new NestStorageSettings { Mode = mode, ServerUrl = serverUrl };
next.Save(NestStorageSettings.DefaultPath);
settings = next;
}
/// <summary>
/// The shared repository for the current server URL, recreated only when the
/// URL changes. Throws <see cref="ArgumentException"/> if the configured URL
/// is not a valid absolute http/https address.
/// </summary>
public static INestRepository CreateRepository()
{
if (cachedRepository == null || cachedServerUrl != settings.ServerUrl)
{
cachedRepository?.Dispose();
cachedRepository = new RemoteNestRepository(settings.ServerUrl);
cachedServerUrl = settings.ServerUrl;
}
return cachedRepository;
}
}
}
+257
View File
@@ -0,0 +1,257 @@
using System;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
namespace OpenNest.Forms;
/// <summary>
/// File &gt; Open (Database mode): lists nests from the shared nest server with
/// filterable/sortable metadata columns, and lets the operator open or delete one.
/// </summary>
public sealed class SavedNestsForm : Form
{
private readonly INestRepository repository;
private readonly DataGridView grid;
private readonly TextBox searchBox;
private readonly Button openButton;
private readonly Button deleteButton;
private readonly Button refreshButton;
private NestRecord[] records = Array.Empty<NestRecord>();
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
public Guid SelectedId { get; private set; }
public SavedNestsForm(INestRepository repository)
{
this.repository = repository;
Text = "Open Nest — Database";
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(880, 480);
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
var topPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(6) };
var searchLabel = new Label { Text = "Filter:", AutoSize = true, Location = new Point(6, 10) };
searchBox = new TextBox { Location = new Point(50, 6), Width = 300 };
searchBox.TextChanged += (_, _) => ApplyFilter();
refreshButton = new Button { Text = "Refresh", AutoSize = true, Location = new Point(360, 5) };
refreshButton.Click += async (_, _) => await LoadAsync();
topPanel.Controls.Add(searchLabel);
topPanel.Controls.Add(searchBox);
topPanel.Controls.Add(refreshButton);
grid = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
MultiSelect = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells,
};
grid.CellDoubleClick += (_, e) =>
{
if (e.RowIndex >= 0)
Open_Click(this, EventArgs.Empty);
};
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 44,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(6),
};
var cancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
deleteButton = new Button { Text = "Delete", AutoSize = true, Enabled = false };
deleteButton.Click += Delete_Click;
openButton = new Button { Text = "Open", AutoSize = true, Enabled = false };
openButton.Click += Open_Click;
buttons.Controls.Add(cancel);
buttons.Controls.Add(openButton);
buttons.Controls.Add(deleteButton);
grid.SelectionChanged += (_, _) =>
{
var hasSelection = grid.SelectedRows.Count > 0;
openButton.Enabled = hasSelection;
deleteButton.Enabled = hasSelection;
};
Controls.Add(grid);
Controls.Add(topPanel);
Controls.Add(buttons);
CancelButton = cancel;
Shown += async (_, _) => await LoadAsync();
}
private async Task LoadAsync()
{
Cursor = Cursors.WaitCursor;
try
{
var list = await repository.ListAsync();
if (IsDisposed)
return;
records = list.ToArray();
ApplyFilter();
}
catch (Exception ex)
{
if (!IsDisposed)
MessageBox.Show(
this,
$"Could not load nests from the server: {ex.Message}",
"Open Nest — Database",
MessageBoxButtons.OK,
MessageBoxIcon.Error
);
}
finally
{
if (!IsDisposed)
Cursor = Cursors.Default;
}
}
private void ApplyFilter()
{
var filter = searchBox.Text.Trim();
var filtered = string.IsNullOrEmpty(filter)
? records
: records
.Where(r =>
Contains(r.Name, filter)
|| Contains(r.Customer, filter)
|| Contains(r.Material, filter)
|| Contains(r.MadeBy, filter)
|| Contains(r.Comments, filter)
|| Contains(r.Status.ToString(), filter)
|| Contains(FormatStatus(r.Status), filter)
|| Contains(r.DateCreated.ToString("g"), filter)
|| Contains(r.DateModified.ToString("g"), filter)
|| Contains(r.SavedAt.ToString("g"), filter)
|| Contains(r.Thickness.ToString(), filter)
|| Contains(r.PlateCount.ToString(), filter)
|| Contains(r.PartCount.ToString(), filter)
|| Contains(r.FileSize.ToString(), filter)
)
.ToArray();
Populate(filtered);
}
private static bool Contains(string value, string filter) =>
!string.IsNullOrEmpty(value) && value.Contains(filter, StringComparison.OrdinalIgnoreCase);
private void Populate(NestRecord[] rows)
{
var table = new DataTable();
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Customer", typeof(string));
table.Columns.Add("Status", typeof(string));
table.Columns.Add("Material", typeof(string));
table.Columns.Add("Date Created", typeof(DateTime));
table.Columns.Add("Date Modified", typeof(DateTime));
table.Columns.Add("Thickness", typeof(double));
table.Columns.Add("Plates", typeof(int));
table.Columns.Add("Parts", typeof(int));
table.Columns.Add("Made By", typeof(string));
table.Columns.Add("Comments", typeof(string));
table.Columns.Add("File Size", typeof(long));
table.Columns.Add("Saved", typeof(DateTime));
table.Columns.Add("Id", typeof(Guid));
foreach (var record in rows.OrderByDescending(r => r.SavedAt))
{
table.Rows.Add(
record.Name,
record.Customer,
FormatStatus(record.Status),
record.Material,
record.DateCreated,
record.DateModified,
record.Thickness,
record.PlateCount,
record.PartCount,
record.MadeBy,
record.Comments,
record.FileSize,
record.SavedAt,
record.Id
);
}
grid.DataSource = table;
if (grid.Columns.Contains("Id"))
grid.Columns["Id"].Visible = false;
}
private static string FormatStatus(NestStatus status) => status switch
{
NestStatus.Quote => "Quote",
NestStatus.ToBeCut => "To Be Cut",
NestStatus.HasBeenCut => "Has Been Cut",
_ => status.ToString(),
};
private void Open_Click(object sender, EventArgs e)
{
if (grid.SelectedRows.Count == 0)
return;
SelectedId = (Guid)grid.SelectedRows[0].Cells["Id"].Value;
DialogResult = DialogResult.OK;
Close();
}
private async void Delete_Click(object sender, EventArgs e)
{
if (grid.SelectedRows.Count == 0)
return;
var id = (Guid)grid.SelectedRows[0].Cells["Id"].Value;
var name = grid.SelectedRows[0].Cells["Name"].Value?.ToString() ?? "";
var confirm = MessageBox.Show(
this,
$"Delete '{name}' from the server? This cannot be undone.",
"Delete Nest",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning
);
if (confirm != DialogResult.Yes)
return;
deleteButton.Enabled = false;
try
{
await repository.DeleteAsync(id);
if (!IsDisposed)
await LoadAsync();
}
catch (Exception ex)
{
if (!IsDisposed)
MessageBox.Show(
this,
$"Could not delete the nest: {ex.Message}",
"Delete Nest",
MessageBoxButtons.OK,
MessageBoxIcon.Error
);
}
finally
{
if (!IsDisposed)
deleteButton.Enabled = grid.SelectedRows.Count > 0;
}
}
}
+140
View File
@@ -0,0 +1,140 @@
using System;
using System.Drawing;
using System.Windows.Forms;
using OpenNest.Data;
namespace OpenNest.Forms;
/// <summary>
/// File &gt; Storage Mode...: choose File mode (local .nest files, the default and
/// current behavior) or Database mode (Save/Open go through the shared nest server).
/// </summary>
public sealed class StorageModeForm : Form
{
private readonly RadioButton fileModeRadio;
private readonly RadioButton databaseModeRadio;
private readonly TextBox serverUrlBox;
private readonly Label serverUrlLabel;
public StorageModeForm()
{
Text = "Storage Mode";
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(420, 190);
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
FormBorderStyle = FormBorderStyle.FixedDialog;
var info = new Label
{
Dock = DockStyle.Top,
Height = 40,
Padding = new Padding(10, 10, 10, 0),
Text = "Database mode shares saved nests with every shop PC through a central server.",
};
fileModeRadio = new RadioButton
{
Text = "File — save/open local .nest files (default)",
AutoSize = true,
Location = new Point(14, 54),
Checked = true,
};
databaseModeRadio = new RadioButton
{
Text = "Database — save/open through a shared nest server",
AutoSize = true,
Location = new Point(14, 80),
};
serverUrlLabel = new Label
{
Text = "Server URL:",
AutoSize = true,
Location = new Point(32, 112),
Enabled = false,
};
serverUrlBox = new TextBox
{
Location = new Point(110, 108),
Width = 280,
PlaceholderText = "http://barge.lan:8090",
Enabled = false,
};
fileModeRadio.CheckedChanged += (_, _) => UpdateServerUrlEnabled();
databaseModeRadio.CheckedChanged += (_, _) => UpdateServerUrlEnabled();
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 44,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(6),
};
var cancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
var ok = new Button { Text = "OK", AutoSize = true };
ok.Click += Ok_Click;
buttons.Controls.Add(cancel);
buttons.Controls.Add(ok);
Controls.Add(databaseModeRadio);
Controls.Add(fileModeRadio);
Controls.Add(serverUrlBox);
Controls.Add(serverUrlLabel);
Controls.Add(info);
Controls.Add(buttons);
AcceptButton = ok;
CancelButton = cancel;
LoadCurrentSettings();
}
public NestStorageMode SelectedMode =>
databaseModeRadio.Checked ? NestStorageMode.Database : NestStorageMode.File;
public string ServerUrl => serverUrlBox.Text.Trim();
private void LoadCurrentSettings()
{
var settings = NestStorage.Settings;
databaseModeRadio.Checked = settings.Mode == NestStorageMode.Database;
fileModeRadio.Checked = !databaseModeRadio.Checked;
serverUrlBox.Text = settings.ServerUrl;
UpdateServerUrlEnabled();
}
private void UpdateServerUrlEnabled()
{
serverUrlBox.Enabled = databaseModeRadio.Checked;
serverUrlLabel.Enabled = databaseModeRadio.Checked;
}
private void Ok_Click(object sender, EventArgs e)
{
if (databaseModeRadio.Checked)
{
var url = ServerUrl;
var isValidUrl =
Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
if (!isValidUrl)
{
MessageBox.Show(
this,
"Enter a valid server URL, e.g. http://barge.lan:8090",
"Storage Mode",
MessageBoxButtons.OK,
MessageBoxIcon.Warning
);
return;
}
}
DialogResult = DialogResult.OK;
Close();
}
}
+9 -4
View File
@@ -4,10 +4,15 @@ The desktop app can save nests two ways:
- **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP
archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md).
- **Database mode**: Save uploads the nest to a central `OpenNest.Server`
instance shared by every shop PC, instead of writing a local file. A separate
**File > Export .nest...** command is always available (in both modes) for
producing a local file to share or back up.
- **Database mode**: Open lists server records with filterable metadata and
sortable columns; Save creates a record on first save and updates that same
record afterward. Save As creates a new record (a copy). A failed save keeps
the previous record association; switching server URLs creates a record on the
new server rather than updating an id from the previous one. Delete in the
saved-nest list permanently removes the selected server record after confirmation.
A separate **File > Export .nest...** command is always available (in both
modes) for producing a local file to share or back up. Export does not change
the document's file save path or database record association.
The mode and server address are stored per-PC at `%APPDATA%\OpenNest\storage.json`
(`OpenNest.Data.NestStorageSettings`), defaulting to File mode so existing