diff --git a/OpenNest.Data/NestNameConflicts.cs b/OpenNest.Data/NestNameConflicts.cs
new file mode 100644
index 00000000..10356507
--- /dev/null
+++ b/OpenNest.Data/NestNameConflicts.cs
@@ -0,0 +1,62 @@
+using System.Net.Http;
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+///
+/// Duplicate-name check for a nest's first upload. Names may already be on posted CNC
+/// programs and labels, so a nest is never renamed silently: callers offer the
+/// replacement only when another active nest already has the exact same name.
+///
+public static class NestNameConflicts
+{
+ ///
+ /// A freshly allocated name for when an active server nest
+ /// already uses ; null when the name is free, or when the
+ /// check or the allocation fails, so the caller keeps the name it has.
+ ///
+ public static async Task FindReplacementAsync(
+ INestRepository repository, string name, int year, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(repository);
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+
+ try
+ {
+ if (!await IsNameTakenAsync(repository, name, cancellationToken))
+ return null;
+ var number = await repository.AllocateNestNumberAsync(year, cancellationToken);
+ return new NestNameAllocation { Year = year, Number = number, Name = NestNames.Format(year, number) };
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException
+ or NotSupportedException or ArgumentException or JsonException or InvalidOperationException)
+ {
+ return null;
+ }
+ }
+
+ /// True when an active (not deleted) server nest is named exactly .
+ public static async Task IsNameTakenAsync(
+ INestRepository repository, string name, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(repository);
+ // The search is a case-insensitive substring match; page through it for the exact name.
+ for (var offset = 0; ;)
+ {
+ var page = await repository.QueryAsync(new NestQuery
+ {
+ Search = name,
+ Sort = NestSortField.Name,
+ Descending = false,
+ Offset = offset,
+ Limit = NestQuery.MaxLimit,
+ }, cancellationToken);
+ if (page.Items.Any(r => !r.Deleted && string.Equals(r.Name, name, StringComparison.Ordinal)))
+ return true;
+ offset += page.Items.Count;
+ if (page.Items.Count == 0 || offset >= page.Total)
+ return false;
+ }
+ }
+}
diff --git a/OpenNest.Data/NestSaveSession.cs b/OpenNest.Data/NestSaveSession.cs
index b4f26d8d..59d6fe40 100644
--- a/OpenNest.Data/NestSaveSession.cs
+++ b/OpenNest.Data/NestSaveSession.cs
@@ -18,6 +18,13 @@ public sealed class NestSaveSession
private NestRecord? pendingCreateMetadata;
private bool pendingCreateWasCopy;
+ ///
+ /// True when a plain Save to would create a new record:
+ /// nothing is bound on that server and no earlier upload is pending.
+ ///
+ public bool IsFirstSave(string? serverUrl) =>
+ PendingCreateId == Guid.Empty && (RemoteId == Guid.Empty || ServerUrl != Normalize(serverUrl));
+
public void Bind(Guid id, string serverUrl, int revision)
{
if (revision < 0) throw new ArgumentOutOfRangeException(nameof(revision));
diff --git a/OpenNest.Server.Tests/NestSequenceTests.cs b/OpenNest.Server.Tests/NestSequenceTests.cs
index 8adb2fa0..efe89267 100644
--- a/OpenNest.Server.Tests/NestSequenceTests.cs
+++ b/OpenNest.Server.Tests/NestSequenceTests.cs
@@ -53,6 +53,26 @@ public sealed class NestSequenceTests
Assert.Equal(Enumerable.Range(1, 40), numbers.Order());
}
+ [Fact]
+ public async Task DuplicateFirstSaveName_GetsAFreshNameOnlyForActiveExactMatches()
+ {
+ using var factory = new ServerFactory();
+ using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
+ {
+ database.Insert(Guid.NewGuid(), new NestRecord { Name = NestNames.Format(2026, 40) }, [1]);
+ var deleted = database.Insert(Guid.NewGuid(), new NestRecord { Name = "Old job" }, [2]);
+ database.SetDeleted(deleted.Id, true, deleted.Revision);
+ }
+ using var client = factory.CreateClient();
+ var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
+
+ var replacement = await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40), 2026);
+ Assert.Equal(NestNames.Format(2026, 41), replacement?.Name);
+
+ Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Old job", 2026));
+ Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40).ToLowerInvariant(), 2026));
+ }
+
[Fact]
public async Task Endpoint_RejectsYearsOutsideTheNameRange()
{
diff --git a/OpenNest.Tests/Data/NestNameConflictsTests.cs b/OpenNest.Tests/Data/NestNameConflictsTests.cs
new file mode 100644
index 00000000..c4393a27
--- /dev/null
+++ b/OpenNest.Tests/Data/NestNameConflictsTests.cs
@@ -0,0 +1,132 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class NestNameConflictsTests
+{
+ [Fact]
+ public async Task FreeName_IsKeptWithoutAllocating()
+ {
+ var repository = new NamedRepository("N26-223X", "Other N26-223");
+
+ Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "N26-223", 2026));
+ Assert.Equal(0, repository.Allocations);
+ }
+
+ [Fact]
+ public async Task ExactDuplicate_OffersTheNextAllocatedName()
+ {
+ var repository = new NamedRepository("N26-223") { NextNumber = 31 };
+
+ var replacement = await NestNameConflicts.FindReplacementAsync(repository, "N26-223", 2026);
+
+ Assert.NotNull(replacement);
+ Assert.Equal(2026, replacement!.Year);
+ Assert.Equal(31, replacement.Number);
+ Assert.Equal(NestNames.Format(2026, 31), replacement.Name);
+ Assert.Equal(1, repository.Allocations);
+ Assert.Equal("N26-223", repository.Queries[0].Search);
+ }
+
+ [Fact]
+ public async Task MatchIsExact_NotCaseFoldedOrSubstring()
+ {
+ var repository = new NamedRepository("n26-223", "N26-2234");
+
+ Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "N26-223"));
+ }
+
+ [Fact]
+ public async Task DeletedNestsDoNotCount()
+ {
+ var repository = new NamedRepository();
+ repository.Records.Add(new NestRecord { Id = Guid.NewGuid(), Name = "Job", Deleted = true });
+
+ Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job"));
+ }
+
+ [Fact]
+ public async Task DuplicateBeyondTheFirstPage_IsFound()
+ {
+ var names = Enumerable.Range(0, NestQuery.MaxLimit + 5).Select(i => $"{i:D4} Job").Append("Job");
+ var repository = new NamedRepository(names.ToArray());
+
+ Assert.True(await NestNameConflicts.IsNameTakenAsync(repository, "Job"));
+ Assert.Equal(2, repository.Queries.Count);
+ Assert.Equal(NestQuery.MaxLimit, repository.Queries[1].Offset);
+ }
+
+ [Theory]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ public async Task FailedCheckOrAllocation_KeepsTheCurrentName(bool queryFails, bool allocationFails)
+ {
+ var repository = new NamedRepository("Job")
+ {
+ QueryFailure = queryFails ? new HttpRequestException("offline") : null,
+ AllocationFailure = allocationFails ? new NotSupportedException("old server") : null,
+ };
+
+ Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Job", 2026));
+ }
+
+ [Fact]
+ public async Task Timeout_KeepsTheCurrentName()
+ {
+ var repository = new NamedRepository("Job");
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+
+ Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Job", 2026, cancelled.Token));
+ }
+
+ private sealed class NamedRepository : INestRepository
+ {
+ public NamedRepository(params string[] names) =>
+ Records.AddRange(names.Select(n => new NestRecord { Id = Guid.NewGuid(), Name = n }));
+
+ public List Records { get; } = [];
+ public List Queries { get; } = [];
+ public int NextNumber { get; set; } = 1;
+ public int Allocations { get; private set; }
+ public Exception? QueryFailure { get; set; }
+ public Exception? AllocationFailure { get; set; }
+
+ public Task QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Queries.Add(query);
+ if (QueryFailure != null)
+ throw QueryFailure;
+ // Mirrors the server: case-insensitive substring search over active nests only.
+ var matches = Records
+ .Where(r => !r.Deleted && r.Name.Contains(query.NormalizedSearch, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ return Task.FromResult(new NestPage
+ {
+ Items = matches.Skip(query.Offset).Take(query.Limit).ToList(),
+ Total = matches.Count,
+ Offset = query.Offset,
+ Limit = query.Limit,
+ });
+ }
+
+ public Task AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (AllocationFailure != null)
+ throw AllocationFailure;
+ Allocations++;
+ return Task.FromResult(NextNumber++);
+ }
+
+ public Task> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
+ public Task GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
+ }
+}
diff --git a/OpenNest.Tests/Data/NestSaveSessionTests.cs b/OpenNest.Tests/Data/NestSaveSessionTests.cs
index 4525c7fe..d45e43ef 100644
--- a/OpenNest.Tests/Data/NestSaveSessionTests.cs
+++ b/OpenNest.Tests/Data/NestSaveSessionTests.cs
@@ -30,6 +30,23 @@ public class NestSaveSessionTests
Assert.Equal(2, repository.Uploads);
}
+ [Fact]
+ public async Task IsFirstSave_OnlyWhenNothingIsBoundOrPendingOnThatServer()
+ {
+ var repository = new FakeRepository();
+ var session = new NestSaveSession();
+ Assert.True(session.IsFirstSave("http://shop:8090"));
+
+ await session.SaveAsync(repository, "http://shop:8090", new Nest("Job"), [1]);
+ Assert.False(session.IsFirstSave("http://shop:8090/"));
+ Assert.True(session.IsFirstSave("http://other:8090"));
+
+ repository.Fail = true;
+ await Assert.ThrowsAsync(() =>
+ session.SaveAsync(repository, "http://other:8090", new Nest("Job"), [1]));
+ Assert.False(session.IsFirstSave("http://other:8090"));
+ }
+
[Fact]
public async Task FailedSaveAndServerSwitch_NeverOverwritePriorIdentity()
{
diff --git a/OpenNest/Document.cs b/OpenNest/Document.cs
index b83e9d90..5f3fa896 100644
--- a/OpenNest/Document.cs
+++ b/OpenNest/Document.cs
@@ -22,6 +22,7 @@ namespace OpenNest
public Guid PendingCreateId => remoteSession.PendingCreateId;
public bool PendingCreateRequiresReconciliation => remoteSession.PendingCreateId != Guid.Empty && remoteSession.RequiresReload;
public bool PendingCreateWasCopy => remoteSession.PendingCreateWasCopy;
+ public bool IsFirstDatabaseSave(string serverUrl) => remoteSession.IsFirstSave(serverUrl);
public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl);
public void BindRemote(Guid id, string serverUrl, int revision) => remoteSession.Bind(id, serverUrl, revision);
diff --git a/OpenNest/Forms/Commands/NestStorageController.cs b/OpenNest/Forms/Commands/NestStorageController.cs
index 3113e6bd..41246234 100644
--- a/OpenNest/Forms/Commands/NestStorageController.cs
+++ b/OpenNest/Forms/Commands/NestStorageController.cs
@@ -1,10 +1,12 @@
using System;
using System.IO;
+using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Engine.BestFit;
using OpenNest.IO;
+using OpenNest.Properties;
namespace OpenNest.Forms.Commands
{
@@ -15,6 +17,8 @@ namespace OpenNest.Forms.Commands
///
internal sealed class NestStorageController
{
+ private static readonly TimeSpan NameCheckTimeout = TimeSpan.FromSeconds(3);
+
private readonly Form owner;
private readonly Action showStatus;
@@ -93,6 +97,7 @@ namespace OpenNest.Forms.Commands
private async Task SaveAsync(EditNestForm form, bool saveCopy, string copyName = null)
{
var originalName = form.Nest.Name;
+ var renamed = false;
SetSaving(true);
try
{
@@ -109,6 +114,18 @@ namespace OpenNest.Forms.Commands
}
if (copyName != null)
form.Nest.Name = copyName;
+ else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl))
+ {
+ var chosen = await ChooseFirstSaveNameAsync(repository, form.Nest.Name);
+ if (chosen is null)
+ {
+ if (!owner.IsDisposed)
+ showStatus("Save cancelled");
+ return;
+ }
+ renamed = chosen != originalName;
+ form.Nest.Name = chosen;
+ }
var saved = await form.Document.SaveToDatabaseAsync(
repository, serverUrl, saveCopy);
if (!form.IsDisposed)
@@ -122,6 +139,9 @@ namespace OpenNest.Forms.Commands
// otherwise a later Save would rename the original nest.
if (copyName != null && !form.Document.PendingCreateWasCopy)
form.Nest.Name = originalName;
+ // Likewise a duplicate-name rename only sticks once its upload may exist.
+ if (renamed && form.Document.PendingCreateId == Guid.Empty)
+ form.Nest.Name = originalName;
if (!owner.IsDisposed)
ShowError("save", ex);
}
@@ -131,6 +151,27 @@ namespace OpenNest.Forms.Commands
}
}
+ ///
+ /// The name for a nest's first upload: its own name unless another server nest
+ /// already has it, in which case the user confirms or edits a newly allocated name.
+ /// Null when the user cancels. A failed check keeps the current name.
+ ///
+ private async Task ChooseFirstSaveNameAsync(INestRepository repository, string name)
+ {
+ var year = DateTime.Now.Year;
+ NestNameAllocation replacement;
+ using (var timeout = new CancellationTokenSource(NameCheckTimeout))
+ replacement = await NestNameConflicts.FindReplacementAsync(repository, name, year, timeout.Token);
+ if (replacement is null)
+ return name;
+
+ if (year == Settings.Default.LastNestCreatedDate.Year)
+ Settings.Default.NestNumber = System.Math.Max(Settings.Default.NestNumber, replacement.Number + 1);
+ using var dlg = new NestNameForm(replacement.Name, "Save",
+ $"A nest named \"{name}\" is already on the server. Save this nest as:");
+ return dlg.ShowDialog(owner) == DialogResult.OK ? dlg.NestName : null;
+ }
+
public void ShowError(string action, Exception ex) =>
MessageBox.Show(owner, $"Could not {action} nest: {ex.Message}",
"Nest Storage", MessageBoxButtons.OK, MessageBoxIcon.Error);
diff --git a/OpenNest/Forms/NestNameForm.cs b/OpenNest/Forms/NestNameForm.cs
index e746b80c..71a53cac 100644
--- a/OpenNest/Forms/NestNameForm.cs
+++ b/OpenNest/Forms/NestNameForm.cs
@@ -6,15 +6,16 @@ namespace OpenNest.Forms;
///
/// Database-mode Save As: asks for the name of the new nest copy, since there is no
-/// file dialog to supply one.
+/// file dialog to supply one. Save also uses it to rename a first upload whose name
+/// another server nest already has.
///
public sealed class NestNameForm : Form
{
private readonly TextBox nameBox;
- public NestNameForm(string currentName)
+ public NestNameForm(string currentName, string title = "Save As", string prompt = "New nest name:")
{
- Text = "Save As";
+ Text = title;
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(380, 110);
MinimizeBox = false;
@@ -24,8 +25,9 @@ public sealed class NestNameForm : Form
var label = new Label
{
- Text = "New nest name:",
+ Text = prompt,
AutoSize = true,
+ MaximumSize = new Size(356, 0),
Location = new Point(12, 16),
};
nameBox = new TextBox
@@ -48,6 +50,10 @@ public sealed class NestNameForm : Form
buttons.Controls.Add(cancel);
buttons.Controls.Add(ok);
+ // A long prompt wraps; keep the name box below it.
+ nameBox.Top = System.Math.Max(36, label.Top + label.PreferredHeight + 4);
+ ClientSize = new Size(ClientSize.Width, nameBox.Bottom + 51);
+
Controls.Add(nameBox);
Controls.Add(label);
Controls.Add(buttons);
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index 3c9c1fa8..c3628325 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -133,6 +133,15 @@ the nest. It resets when the year changes and is raised past each number the
server hands out. Two PCs naming nests offline at the same time can still pick
the same name; a nest's identity on the server is its `id`, never its name.
+A nest's name may already be on posted CNC programs or labels, so it is never
+renamed silently. When **Save** (not Save As, and not the reconciliation of a
+pending upload) creates a nest's first record on the current server, it first
+searches active nests for one with exactly the same name (case-sensitive;
+deleted nests do not count, `NestNameConflicts`). Only on a match does it
+allocate the year's next name and show it in an editable dialog: OK uploads
+under the chosen name, Cancel saves nothing. If the check or the allocation
+fails or takes longer than 3 seconds, the nest uploads under its current name.
+
There is no authentication; this is a LAN-only service. Do not expose it
outside the shop network without adding one.