diff --git a/OpenNest.Data/NestRecordFactory.cs b/OpenNest.Data/NestRecordFactory.cs new file mode 100644 index 0000000..cce9048 --- /dev/null +++ b/OpenNest.Data/NestRecordFactory.cs @@ -0,0 +1,45 @@ +using System.Linq; + +namespace OpenNest.Data; + +/// +/// Builds the shareable metadata from a live . +/// Counts are computed here (not carried on itself) so every caller +/// that saves to the nest server gets the same definition of "plate" and "part" counts. +/// +public static class NestRecordFactory +{ + /// + /// Builds a record for uploading . is the + /// client-tracked id: for a first save (the server + /// assigns one), or the previously returned id to update an existing record. + /// is the size of the serialized .nest archive being + /// uploaded alongside this record; the server also recomputes it independently. + /// + 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); +} diff --git a/OpenNest.Data/NestSaveSession.cs b/OpenNest.Data/NestSaveSession.cs new file mode 100644 index 0000000..c337e3d --- /dev/null +++ b/OpenNest.Data/NestSaveSession.cs @@ -0,0 +1,46 @@ +namespace OpenNest.Data; + +/// +/// 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. +/// +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 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('/'); +} diff --git a/OpenNest.Tests/Data/NestRecordFactoryTests.cs b/OpenNest.Tests/Data/NestRecordFactoryTests.cs new file mode 100644 index 0000000..6003743 --- /dev/null +++ b/OpenNest.Tests/Data/NestRecordFactoryTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/Data/NestSaveSessionTests.cs b/OpenNest.Tests/Data/NestSaveSessionTests.cs new file mode 100644 index 0000000..01db1fb --- /dev/null +++ b/OpenNest.Tests/Data/NestSaveSessionTests.cs @@ -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(() => + 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 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 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> ListAsync(CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); + } +} diff --git a/OpenNest/Document.cs b/OpenNest/Document.cs index d2ec600..4379bee 100644 --- a/OpenNest/Document.cs +++ b/OpenNest/Document.cs @@ -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); } + + /// Serializes the current nest to a .nest archive in memory. + public byte[] Serialize() + { + using var stream = new MemoryStream(); + new NestWriter(Nest).Write(stream); + return stream.ToArray(); + } + + /// Uploads this nest, preserving its server identity only on success. + public async Task 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; + } } } diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 5d31d5b..c605d8b 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -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) diff --git a/OpenNest/Forms/NestStorage.cs b/OpenNest/Forms/NestStorage.cs new file mode 100644 index 0000000..23531de --- /dev/null +++ b/OpenNest/Forms/NestStorage.cs @@ -0,0 +1,51 @@ +using System; +using OpenNest.Data; + +namespace OpenNest.Forms +{ + /// + /// App-scoped nest storage-mode selection (File vs Database) and the shared + /// for the configured server. Settings persist + /// to %APPDATA%\OpenNest\storage.json via . + /// + public static class NestStorage + { + private static NestStorageSettings settings = + NestStorageSettings.Load(NestStorageSettings.DefaultPath); + + private static RemoteNestRepository cachedRepository; + private static string cachedServerUrl; + + /// Current settings, loaded once at process start. + public static NestStorageSettings Settings => settings; + + /// + /// Persists new settings and invalidates the cached repository so the next + /// call picks up a changed server URL. + /// If persistence fails the current mode remains unchanged. + /// + public static void Save(NestStorageMode mode, string serverUrl) + { + var next = new NestStorageSettings { Mode = mode, ServerUrl = serverUrl }; + next.Save(NestStorageSettings.DefaultPath); + settings = next; + } + + /// + /// The shared repository for the current server URL, recreated only when the + /// URL changes. Throws if the configured URL + /// is not a valid absolute http/https address. + /// + public static INestRepository CreateRepository() + { + if (cachedRepository == null || cachedServerUrl != settings.ServerUrl) + { + cachedRepository?.Dispose(); + cachedRepository = new RemoteNestRepository(settings.ServerUrl); + cachedServerUrl = settings.ServerUrl; + } + + return cachedRepository; + } + } +} diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs new file mode 100644 index 0000000..d58d747 --- /dev/null +++ b/OpenNest/Forms/SavedNestsForm.cs @@ -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; + +/// +/// File > Open (Database mode): lists nests from the shared nest server with +/// filterable/sortable metadata columns, and lets the operator open or delete one. +/// +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(); + + /// Set to the chosen record's id when the dialog closes with OK. + 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; + } + } +} diff --git a/OpenNest/Forms/StorageModeForm.cs b/OpenNest/Forms/StorageModeForm.cs new file mode 100644 index 0000000..ef3409b --- /dev/null +++ b/OpenNest/Forms/StorageModeForm.cs @@ -0,0 +1,140 @@ +using System; +using System.Drawing; +using System.Windows.Forms; +using OpenNest.Data; + +namespace OpenNest.Forms; + +/// +/// 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). +/// +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(); + } +} diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 29f0776..d72b1ea 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -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