feat(data): storage-mode settings and remote nest repository

NestStorageSettings persists the file/database toggle and server URL at
%APPDATA%\OpenNest\storage.json following the EngineSelectionSettings
never-throwing pattern; missing config means File mode. NestRecord carries
the shareable job metadata, INestRepository defines the backend contract,
and RemoteNestRepository implements it against the nest server with
multipart uploads (metadata JSON part + .nest file part).
This commit is contained in:
aj
2026-09-29 16:21:17 -04:00
parent ef2f9aa714
commit 10ed3d3a30
5 changed files with 538 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
namespace OpenNest.Data;
/// <summary>
/// Storage backend for saved nests. <see cref="RemoteNestRepository"/> talks to the
/// central nest server; File mode in the desktop app keeps using SaveFileDialog and
/// does not go through this interface.
/// </summary>
public interface INestRepository
{
/// <summary>All stored nests, newest saved first.</summary>
Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default);
Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary>
Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Stores a new nest; returns the server-assigned record.</summary>
Task<NestRecord> UploadAsync(
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
/// <summary>Replaces the archive and metadata of an existing id, keeping the id.</summary>
Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default);
/// <summary>Replaces metadata only (status, made-by, comments...), leaving the archive.</summary>
Task<NestRecord> UpdateMetadataAsync(
Guid id, NestRecord record, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
+42
View File
@@ -0,0 +1,42 @@
using OpenNest;
namespace OpenNest.Data;
/// <summary>
/// Server-side metadata for one saved nest. The .nest archive itself is stored
/// alongside; this record is what lists, filters and status tracking read.
/// Counts are captured by the client at save time.
/// </summary>
public sealed class NestRecord
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
public DateTime DateCreated { get; set; }
public DateTime DateModified { get; set; }
public string Material { get; set; } = "";
public double Thickness { get; set; }
/// <summary>quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers).</summary>
public NestStatus Status { get; set; } = NestStatus.Quote;
public int PlateCount { get; set; }
public int PartCount { get; set; }
public string Comments { get; set; } = "";
public string MadeBy { get; set; } = "";
/// <summary>Size of the stored .nest archive in bytes.</summary>
public long FileSize { get; set; }
/// <summary>When the server last stored the nest contents or metadata.</summary>
public DateTime SavedAt { get; set; }
}
+83
View File
@@ -0,0 +1,83 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>Where the desktop app persists nests: local .nest files or the shared nest server.</summary>
public enum NestStorageMode
{
File,
Database,
}
/// <summary>
/// Storage-mode toggle and nest-server address, stored at %APPDATA%\OpenNest\storage.json.
/// Loading never throws; a missing or corrupt file means File mode so existing installs
/// keep the original behavior until the operator opts in.
/// </summary>
public sealed class NestStorageSettings
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public NestStorageMode Mode { get; set; } = NestStorageMode.File;
/// <summary>Base URL of the nest server, e.g. http://barge.lan:8090. No trailing slash required.</summary>
public string ServerUrl { get; set; } = "";
/// <summary>True when Database mode is active and a server URL is configured.</summary>
public bool IsDatabaseMode =>
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest", "storage.json");
public static NestStorageSettings Load(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return new();
try
{
var settings = JsonSerializer.Deserialize<NestStorageSettings>(
File.ReadAllText(path), JsonOptions) ?? new();
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
return settings;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return new();
}
}
/// <summary>Writes camelCase JSON, creating the parent directory and retrying IO collisions.</summary>
public void Save(string path)
{
var json = JsonSerializer.Serialize(
new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) },
JsonOptions);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
for (var attempt = 0; attempt < 3; attempt++)
{
try
{
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < 2)
{
Thread.Sleep(100);
}
}
}
private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/');
}
+159
View File
@@ -0,0 +1,159 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Data;
/// <summary>
/// Talks to the central OpenNest nest server over HTTP. Uploads are multipart:
/// a "metadata" JSON part and a "file" part holding the .nest archive.
/// Connection and HTTP failures surface as HttpRequestException/IOException to the caller.
/// </summary>
public sealed class RemoteNestRepository : INestRepository, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
public RemoteNestRepository(string baseUrl)
: this(new HttpClient(), baseUrl)
{
_ownsClient = true;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
public RemoteNestRepository(HttpClient httpClient, string baseUrl)
{
_httpClient = httpClient;
_ownsClient = false;
var url = (baseUrl ?? "").Trim().TrimEnd('/');
if (!Uri.TryCreate(url, UriKind.Absolute, out var baseUri)
|| (baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps))
{
throw new ArgumentException($"Invalid nest server URL: '{baseUrl}'", nameof(baseUrl));
}
// Keep the trailing slash so relative combines replace no path segment.
_baseUri = new Uri(baseUri.AbsoluteUri.TrimEnd('/') + "/");
}
private Uri Url(string relative) => new(_baseUri, relative);
public async Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
await EnsureSuccess(response, "list nests", cancellationToken);
var items = await response.Content
.ReadFromJsonAsync<List<NestRecord>>(JsonOptions, cancellationToken);
return items ?? new List<NestRecord>();
}
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
}
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
public async Task<NestRecord> UploadAsync(
byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
using var content = BuildMultipart(nestFile, record);
using var response = await _httpClient.PostAsync(Url("api/nests"), content, cancellationToken);
await EnsureSuccess(response, $"upload nest '{record.Name}'", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
using var content = BuildMultipart(nestFile, record);
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
await EnsureSuccess(response, $"update nest {id}", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task<NestRecord> UpdateMetadataAsync(
Guid id, NestRecord record, CancellationToken cancellationToken = default)
{
using var content = new StringContent(
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken);
await EnsureSuccess(response, $"delete nest {id}", cancellationToken);
}
public void Dispose()
{
if (_ownsClient)
_httpClient.Dispose();
}
private static MultipartFormDataContent BuildMultipart(byte[] nestFile, NestRecord record)
{
var content = new MultipartFormDataContent();
var metadata = new StringContent(
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
content.Add(metadata, "metadata");
var file = new ByteArrayContent(nestFile);
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
content.Add(file, "file", record.Name.Length > 0 ? $"{record.Name}.nest" : "nest.nest");
return content;
}
private static async Task<NestRecord> ReadRecord(
HttpResponseMessage response, CancellationToken cancellationToken)
{
var record = await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
return record ?? throw new IOException("Nest server returned an empty record.");
}
private static async Task EnsureSuccess(
HttpResponseMessage response, string action, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
return;
var detail = "";
try
{
detail = await response.Content.ReadAsStringAsync(cancellationToken);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
// The status code is the useful part; the body is best-effort.
}
if (detail.Length > 200)
detail = detail[..200] + "…";
throw new IOException(
$"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd());
}
}