feat(ui): wire file and database nest storage
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// File > 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user