mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
refactor: share duplicated paging, HTTP and helper code
Roslyn duplicate scans found the same code copied across layers: - Data: the three browse sessions share BrowseSession<TPage, TSort>; the remote clients share NestServerHttp (JSON options, If-Match, error text). - WinForms: five paged lists share BrowsePager for loading text, navigation, sort glyphs and failure messages. The Add Drawings to Release, Drawing Library and set browser dialogs now dispose their browse sessions. - Server: NestQueryRequest reuses DrawingLibraryRequest's helpers; revision history queries share count and paging binds. - Engine: RemnantPlateNester delegates to DefaultPlateNester; CuttingPlanService reuses JointCuttingPlanSearch.Map. - Core: LineArc and CleanHole lead-ins share LineArcApproach with unchanged arithmetic order, so output is bit-identical. - IO: Dxf.GetGeometry overloads share one reader; .nest JSON test helpers move to NestArchiveJson. Also fixes the production compiler warnings (CS8632, CS8603, CS0108) and moves a test off an obsolete forwarding API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a00e2c3233
commit
7ce5e73a82
37 files changed
+738
-1289
No files matched your search
@@ -1,11 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public class CleanHoleLeadIn : LeadIn
|
||||
{
|
||||
private const double ApproachAngle = 135.0;
|
||||
|
||||
public double LineLength { get; set; }
|
||||
public double ArcRadius { get; set; }
|
||||
public double Kerf { get; set; }
|
||||
@@ -14,42 +15,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
) => Approach(contourStartPoint, contourNormalAngle).ToCodes(contourStartPoint, winding);
|
||||
|
||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||
var arcStart = new Vector(
|
||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
{
|
||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
|
||||
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
|
||||
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
|
||||
|
||||
return new Vector(
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||
);
|
||||
}
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) =>
|
||||
Approach(contourStartPoint, contourNormalAngle).PiercePoint;
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new CleanHoleLeadIn
|
||||
@@ -58,5 +27,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
ArcRadius = ArcRadius * factor,
|
||||
Kerf = Kerf,
|
||||
};
|
||||
|
||||
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle) =>
|
||||
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// A straight line into an arc that ends on the contour start, shared by
|
||||
/// <see cref="LineArcLeadIn"/> and <see cref="CleanHoleLeadIn"/>. The arc's center lies
|
||||
/// the arc radius along the contour normal; the line meets the arc at the approach angle
|
||||
/// (degrees from the normal) and starts at the pierce point.
|
||||
/// </summary>
|
||||
internal readonly struct LineArcApproach
|
||||
{
|
||||
private LineArcApproach(Vector arcCenter, Vector arcStart, Vector piercePoint)
|
||||
{
|
||||
ArcCenter = arcCenter;
|
||||
ArcStart = arcStart;
|
||||
PiercePoint = piercePoint;
|
||||
}
|
||||
|
||||
public Vector ArcCenter { get; }
|
||||
|
||||
public Vector ArcStart { get; }
|
||||
|
||||
public Vector PiercePoint { get; }
|
||||
|
||||
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
|
||||
double arcRadius, double approachAngle, double lineLength)
|
||||
{
|
||||
var arcCenterX = contourStartPoint.X + arcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + arcRadius * System.Math.Sin(contourNormalAngle);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(approachAngle);
|
||||
var arcStartX = arcCenterX + arcRadius * System.Math.Cos(lineAngle);
|
||||
var arcStartY = arcCenterY + arcRadius * System.Math.Sin(lineAngle);
|
||||
|
||||
return new LineArcApproach(
|
||||
new Vector(arcCenterX, arcCenterY),
|
||||
new Vector(arcStartX, arcStartY),
|
||||
new Vector(
|
||||
arcStartX + lineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + lineLength * System.Math.Sin(lineAngle)));
|
||||
}
|
||||
|
||||
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
|
||||
public List<ICode> ToCodes(Vector contourStartPoint, RotationType winding) =>
|
||||
new List<ICode>
|
||||
{
|
||||
new RapidMove(PiercePoint),
|
||||
new LinearMove(ArcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
@@ -14,42 +13,10 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
Vector contourStartPoint,
|
||||
double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW
|
||||
)
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
) => Approach(contourStartPoint, contourNormalAngle).ToCodes(contourStartPoint, winding);
|
||||
|
||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||
var arcCenter = new Vector(arcCenterX, arcCenterY);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var arcStart = new Vector(
|
||||
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
|
||||
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
|
||||
);
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
{
|
||||
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
|
||||
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
|
||||
|
||||
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
|
||||
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
|
||||
|
||||
return new Vector(
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle)
|
||||
);
|
||||
}
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) =>
|
||||
Approach(contourStartPoint, contourNormalAngle).PiercePoint;
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new LineArcLeadIn
|
||||
@@ -58,5 +25,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
ArcRadius = ArcRadius * factor,
|
||||
ApproachAngle = ApproachAngle,
|
||||
};
|
||||
|
||||
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle) =>
|
||||
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength);
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ namespace OpenNest
|
||||
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
|
||||
/// the zone overlay. Carries no geometry arguments.
|
||||
/// </summary>
|
||||
public event EventHandler? ClampZonesChanged;
|
||||
public event EventHandler ClampZonesChanged;
|
||||
|
||||
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>The paging facts a <see cref="BrowseSession{TPage, TSort}"/> reads from a server page.</summary>
|
||||
public interface IBrowsePage
|
||||
{
|
||||
int Offset { get; }
|
||||
|
||||
/// <summary>Number of records matching the request across all pages.</summary>
|
||||
int Total { get; }
|
||||
|
||||
/// <summary>Number of records on this page.</summary>
|
||||
int ItemCount { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Browse state shared by the paged server lists: the requested search, sort and page, and the
|
||||
/// last page applied. Every change issues one bounded server query. A response that a later
|
||||
/// request superseded is discarded (and the earlier request is cancelled), so an older result
|
||||
/// can never replace a newer one. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public abstract class BrowseSession<TPage, TSort> : IDisposable
|
||||
where TPage : class, IBrowsePage
|
||||
where TSort : struct, Enum
|
||||
{
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
protected BrowseSession(int pageSize, int maxPageSize, TSort sort, bool descending = true)
|
||||
{
|
||||
if (pageSize < 1 || pageSize > maxPageSize)
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {maxPageSize}.");
|
||||
|
||||
PageSize = pageSize;
|
||||
Sort = sort;
|
||||
Descending = descending;
|
||||
}
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
/// <summary>Trimmed search text of the latest request.</summary>
|
||||
public string Search { get; private set; } = "";
|
||||
|
||||
public TSort Sort { get; private set; }
|
||||
|
||||
public bool Descending { get; private set; }
|
||||
|
||||
/// <summary>Offset of the latest request.</summary>
|
||||
public int Offset { get; private set; }
|
||||
|
||||
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
|
||||
public TPage? Page { get; private set; }
|
||||
|
||||
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
|
||||
public bool IsLoading { get; private set; }
|
||||
|
||||
public bool CanGoPrevious => Offset > 0;
|
||||
|
||||
public bool CanGoNext => Page is { } page && page.Offset + page.ItemCount < page.Total;
|
||||
|
||||
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
|
||||
public abstract string Summary { get; }
|
||||
|
||||
/// <summary>Applies new search text and returns to the first page.</summary>
|
||||
public Task<bool> SetSearchAsync(string? search)
|
||||
{
|
||||
Search = (search ?? "").Trim();
|
||||
return FirstPageAsync();
|
||||
}
|
||||
|
||||
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
|
||||
public Task<bool> SortByAsync(TSort field)
|
||||
{
|
||||
Descending = EqualityComparer<TSort>.Default.Equals(field, Sort) && !Descending;
|
||||
Sort = field;
|
||||
return FirstPageAsync();
|
||||
}
|
||||
|
||||
public Task<bool> NextPageAsync()
|
||||
{
|
||||
if (!CanGoNext)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = Page!.Offset + PageSize;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> PreviousPageAsync()
|
||||
{
|
||||
if (!CanGoPrevious)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = System.Math.Max(0, Offset - PageSize);
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
|
||||
public Task<bool> RefreshAsync() => RunAsync();
|
||||
|
||||
/// <summary>Re-queries from the first page, after a derived filter changed.</summary>
|
||||
protected Task<bool> FirstPageAsync()
|
||||
{
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Queries the page at <see cref="Offset"/> with the current filters.</summary>
|
||||
protected abstract Task<TPage> QueryAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The standard summary: <paramref name="empty"/> when nothing matches, else the shown range of <paramref name="noun"/>.</summary>
|
||||
protected string PageSummary(string noun, string empty)
|
||||
{
|
||||
if (Page is not { } page)
|
||||
return "";
|
||||
if (page.Total == 0)
|
||||
return empty;
|
||||
if (page.ItemCount == 0)
|
||||
return $"No {noun} on this page ({Count(page.Total)} in total).";
|
||||
|
||||
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.ItemCount)} of {Count(page.Total)} {noun}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the current request. Returns true when its page was applied, false when a later
|
||||
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
|
||||
/// failures of superseded requests are ignored.
|
||||
/// </summary>
|
||||
private async Task<bool> RunAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a request that completes synchronously when cancelled
|
||||
// must already see itself as superseded. Each source is released by its own request.
|
||||
var previous = _pending;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_pending = cancellation;
|
||||
var generation = ++_generation;
|
||||
IsLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
previous?.Cancel();
|
||||
var page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
|
||||
// A deletion, an archive or another PC can leave the requested page past the end.
|
||||
if (page.ItemCount == 0 && page.Offset > 0 && page.Total > 0)
|
||||
{
|
||||
Offset = (page.Total - 1) / PageSize * PageSize;
|
||||
page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
}
|
||||
|
||||
Page = page;
|
||||
return true;
|
||||
}
|
||||
catch (Exception) when (generation != _generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Page = null;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_pending, cancellation))
|
||||
_pending = null;
|
||||
if (generation == _generation)
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels any request in flight (which releases its own source when it ends);
|
||||
/// the server client is not owned and stays open.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_generation++;
|
||||
IsLoading = false;
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
pending?.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,23 @@
|
||||
using System.Globalization;
|
||||
using OpenNest;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Browse state for Add from Drawing Library: the requested search, sort, page and
|
||||
/// nest filter, and the last page applied. By default only drawings with the nest's
|
||||
/// material, grade and thickness are listed; <see cref="SetShowAllAsync"/> lifts that
|
||||
/// filter. Every change issues one bounded server query. A response that a later request
|
||||
/// superseded is discarded (and the earlier request is cancelled), so an older result can
|
||||
/// never replace a newer one. Call from one thread (the UI thread).
|
||||
/// filter. Paging, superseding and disposal follow <see cref="BrowseSession{TPage, TSort}"/>.
|
||||
/// Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class DrawingLibraryBrowseSession : IDisposable
|
||||
public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPage, DrawingSortField>
|
||||
{
|
||||
private readonly IDrawingLibrary _library;
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
|
||||
: base(pageSize, DrawingLibraryQuery.MaxLimit, DrawingSortField.SavedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(library);
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
if (pageSize < 1 || pageSize > DrawingLibraryQuery.MaxLimit)
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingLibraryQuery.MaxLimit}.");
|
||||
|
||||
_library = library;
|
||||
PageSize = pageSize;
|
||||
MaterialName = (nest.Material?.Name ?? "").Trim();
|
||||
MaterialGrade = (nest.Material?.Grade ?? "").Trim();
|
||||
// A nest without a thickness has nothing to match; it filters by material only.
|
||||
@@ -35,8 +26,6 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
|
||||
: null;
|
||||
}
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
/// <summary>The nest's material name, applied unless <see cref="ShowAll"/>; blank means any.</summary>
|
||||
public string MaterialName { get; }
|
||||
|
||||
@@ -53,148 +42,21 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
|
||||
public bool IsFilteredToNest =>
|
||||
!ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
|
||||
|
||||
/// <summary>Trimmed search text of the latest request.</summary>
|
||||
public string Search { get; private set; } = "";
|
||||
|
||||
public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt;
|
||||
|
||||
public bool Descending { get; private set; } = true;
|
||||
|
||||
/// <summary>Offset of the latest request.</summary>
|
||||
public int Offset { get; private set; }
|
||||
|
||||
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
|
||||
public DrawingLibraryPage? Page { get; private set; }
|
||||
|
||||
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
|
||||
public bool IsLoading { get; private set; }
|
||||
|
||||
public bool CanGoPrevious => Offset > 0;
|
||||
|
||||
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
|
||||
|
||||
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 drawings".</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Page is not { } page)
|
||||
return "";
|
||||
if (page.Total == 0)
|
||||
{
|
||||
if (Search.Length > 0)
|
||||
return "No drawings match the search.";
|
||||
return IsFilteredToNest
|
||||
? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
|
||||
: "No drawings in the library.";
|
||||
}
|
||||
if (page.Items.Count == 0)
|
||||
return $"No drawings on this page ({Count(page.Total)} in total).";
|
||||
|
||||
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} drawings";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies new search text and returns to the first page.</summary>
|
||||
public Task<bool> SetSearchAsync(string? search)
|
||||
{
|
||||
Search = (search ?? "").Trim();
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public override string Summary =>
|
||||
PageSummary("drawings",
|
||||
Search.Length > 0 ? "No drawings match the search."
|
||||
: IsFilteredToNest ? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
|
||||
: "No drawings in the library.");
|
||||
|
||||
/// <summary>Lifts or restores the nest filter and returns to the first page.</summary>
|
||||
public Task<bool> SetShowAllAsync(bool showAll)
|
||||
{
|
||||
ShowAll = showAll;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
return FirstPageAsync();
|
||||
}
|
||||
|
||||
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
|
||||
public Task<bool> SortByAsync(DrawingSortField field)
|
||||
{
|
||||
Descending = field == Sort && !Descending;
|
||||
Sort = field;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> NextPageAsync()
|
||||
{
|
||||
if (!CanGoNext)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = Page!.Offset + PageSize;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> PreviousPageAsync()
|
||||
{
|
||||
if (!CanGoPrevious)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = System.Math.Max(0, Offset - PageSize);
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
|
||||
public Task<bool> RefreshAsync() => RunAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Sends the current request. Returns true when its page was applied, false when a later
|
||||
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
|
||||
/// failures of superseded requests are ignored.
|
||||
/// </summary>
|
||||
private async Task<bool> RunAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a request that completes synchronously when cancelled
|
||||
// must already see itself as superseded. Each source is released by its own request.
|
||||
var previous = _pending;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_pending = cancellation;
|
||||
var generation = ++_generation;
|
||||
IsLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
previous?.Cancel();
|
||||
var page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
|
||||
// An archive or another PC can leave the requested page past the end.
|
||||
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
|
||||
{
|
||||
Offset = (page.Total - 1) / PageSize * PageSize;
|
||||
page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
}
|
||||
|
||||
Page = page;
|
||||
return true;
|
||||
}
|
||||
catch (Exception) when (generation != _generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Page = null;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_pending, cancellation))
|
||||
_pending = null;
|
||||
if (generation == _generation)
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
protected override Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
_library.QueryAsync(
|
||||
new DrawingLibraryQuery
|
||||
{
|
||||
@@ -208,23 +70,4 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
|
||||
Limit = PageSize,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels any request in flight (which releases its own source when it ends);
|
||||
/// the library client is not owned and stays open.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_generation++;
|
||||
IsLoading = false;
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
pending?.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>One bounded page of library drawing metadata.</summary>
|
||||
public sealed class DrawingLibraryPage
|
||||
public sealed class DrawingLibraryPage : IBrowsePage
|
||||
{
|
||||
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
|
||||
public IReadOnlyList<DrawingLibraryRecord> Items { get; init; } = Array.Empty<DrawingLibraryRecord>();
|
||||
@@ -12,4 +12,6 @@ public sealed class DrawingLibraryPage
|
||||
public int Offset { get; init; }
|
||||
|
||||
public int Limit { get; init; }
|
||||
|
||||
int IBrowsePage.ItemCount => Items.Count;
|
||||
}
|
||||
@@ -1,171 +1,36 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Browse state for Nest > Drawing Sets: the requested search, archived toggle, sort and
|
||||
/// page, and the last page applied. Every change issues one bounded server query. A response
|
||||
/// that a later request superseded is discarded (and the earlier request is cancelled), so an
|
||||
/// older result can never replace a newer one. Call from one thread (the UI thread).
|
||||
/// page, and the last page applied. Paging, superseding and disposal follow
|
||||
/// <see cref="BrowseSession{TPage, TSort}"/>. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class DrawingSetBrowseSession : IDisposable
|
||||
public sealed class DrawingSetBrowseSession : BrowseSession<DrawingSetPage, DrawingSetSortField>
|
||||
{
|
||||
private readonly IDrawingSetStore _sets;
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
public DrawingSetBrowseSession(IDrawingSetStore sets, int pageSize = DrawingSetQuery.DefaultLimit)
|
||||
: base(pageSize, DrawingSetQuery.MaxLimit, DrawingSetSortField.SavedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sets);
|
||||
if (pageSize < 1 || pageSize > DrawingSetQuery.MaxLimit)
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingSetQuery.MaxLimit}.");
|
||||
|
||||
_sets = sets;
|
||||
PageSize = pageSize;
|
||||
}
|
||||
|
||||
public int PageSize { get; }
|
||||
|
||||
/// <summary>Trimmed search text of the latest request.</summary>
|
||||
public string Search { get; private set; } = "";
|
||||
|
||||
/// <summary>True when archived sets are listed with the active ones.</summary>
|
||||
public bool IncludeArchived { get; private set; }
|
||||
|
||||
public DrawingSetSortField Sort { get; private set; } = DrawingSetSortField.SavedAt;
|
||||
|
||||
public bool Descending { get; private set; } = true;
|
||||
|
||||
/// <summary>Offset of the latest request.</summary>
|
||||
public int Offset { get; private set; }
|
||||
|
||||
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
|
||||
public DrawingSetPage? Page { get; private set; }
|
||||
|
||||
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
|
||||
public bool IsLoading { get; private set; }
|
||||
|
||||
public bool CanGoPrevious => Offset > 0;
|
||||
|
||||
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
|
||||
|
||||
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 1-100 of 240 sets".</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Page is not { } page)
|
||||
return "";
|
||||
if (page.Total == 0)
|
||||
return Search.Length > 0 ? "No sets match the search." : "No drawing sets yet. Choose New Set to make one.";
|
||||
if (page.Items.Count == 0)
|
||||
return $"No sets on this page ({Count(page.Total)} in total).";
|
||||
|
||||
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} sets";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies new search text and returns to the first page.</summary>
|
||||
public Task<bool> SetSearchAsync(string? search)
|
||||
{
|
||||
Search = (search ?? "").Trim();
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public override string Summary =>
|
||||
PageSummary("sets", Search.Length > 0 ? "No sets match the search." : "No drawing sets yet. Choose New Set to make one.");
|
||||
|
||||
/// <summary>Lists or hides archived sets and returns to the first page.</summary>
|
||||
public Task<bool> SetIncludeArchivedAsync(bool includeArchived)
|
||||
{
|
||||
IncludeArchived = includeArchived;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
return FirstPageAsync();
|
||||
}
|
||||
|
||||
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
|
||||
public Task<bool> SortByAsync(DrawingSetSortField field)
|
||||
{
|
||||
Descending = field == Sort && !Descending;
|
||||
Sort = field;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> NextPageAsync()
|
||||
{
|
||||
if (!CanGoNext)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = Page!.Offset + PageSize;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> PreviousPageAsync()
|
||||
{
|
||||
if (!CanGoPrevious)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = System.Math.Max(0, Offset - PageSize);
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
|
||||
public Task<bool> RefreshAsync() => RunAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Sends the current request. Returns true when its page was applied, false when a later
|
||||
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
|
||||
/// failures of superseded requests are ignored.
|
||||
/// </summary>
|
||||
private async Task<bool> RunAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a request that completes synchronously when cancelled
|
||||
// must already see itself as superseded. Each source is released by its own request.
|
||||
var previous = _pending;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_pending = cancellation;
|
||||
var generation = ++_generation;
|
||||
IsLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
previous?.Cancel();
|
||||
var page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
|
||||
// An archive or another PC can leave the requested page past the end.
|
||||
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
|
||||
{
|
||||
Offset = (page.Total - 1) / PageSize * PageSize;
|
||||
page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
}
|
||||
|
||||
Page = page;
|
||||
return true;
|
||||
}
|
||||
catch (Exception) when (generation != _generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Page = null;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_pending, cancellation))
|
||||
_pending = null;
|
||||
if (generation == _generation)
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<DrawingSetPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
protected override Task<DrawingSetPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
_sets.QueryAsync(
|
||||
new DrawingSetQuery
|
||||
{
|
||||
@@ -177,23 +42,4 @@ public sealed class DrawingSetBrowseSession : IDisposable
|
||||
Limit = PageSize,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels any request in flight (which releases its own source when it ends);
|
||||
/// the set client is not owned and stays open.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_generation++;
|
||||
IsLoading = false;
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
pending?.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>One bounded page of drawing set records, without their members.</summary>
|
||||
public sealed class DrawingSetPage
|
||||
public sealed class DrawingSetPage : IBrowsePage
|
||||
{
|
||||
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
|
||||
public IReadOnlyList<DrawingSetRecord> Items { get; init; } = Array.Empty<DrawingSetRecord>();
|
||||
@@ -12,4 +12,6 @@ public sealed class DrawingSetPage
|
||||
public int Offset { get; init; }
|
||||
|
||||
public int Limit { get; init; }
|
||||
|
||||
int IBrowsePage.ItemCount => Items.Count;
|
||||
}
|
||||
@@ -1,163 +1,27 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Browse state for the Database-mode Open dialog: the requested search, sort and page,
|
||||
/// and the last page applied. Every change issues one bounded server query. A response
|
||||
/// that a later request superseded is discarded (and the earlier request is cancelled),
|
||||
/// so an older result can never replace a newer one. Call from one thread (the UI thread).
|
||||
/// and the last page applied. Paging, superseding and disposal follow
|
||||
/// <see cref="BrowseSession{TPage, TSort}"/>. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class NestBrowseSession : IDisposable
|
||||
public sealed class NestBrowseSession : BrowseSession<NestPage, NestSortField>
|
||||
{
|
||||
private readonly INestRepository _repository;
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
|
||||
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit,
|
||||
NestSortField sort = NestSortField.SavedAt, bool descending = true)
|
||||
: base(pageSize, NestQuery.MaxLimit, Enum.IsDefined(sort) ? sort : NestSortField.SavedAt, descending)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {NestQuery.MaxLimit}.");
|
||||
|
||||
_repository = repository;
|
||||
PageSize = pageSize;
|
||||
Sort = Enum.IsDefined(sort) ? sort : NestSortField.SavedAt;
|
||||
Descending = descending;
|
||||
}
|
||||
|
||||
public int PageSize { get; }
|
||||
/// <inheritdoc />
|
||||
public override string Summary =>
|
||||
PageSummary("nests", Search.Length == 0 ? "No nests on the server." : "No nests match the filter.");
|
||||
|
||||
/// <summary>Trimmed search text of the latest request.</summary>
|
||||
public string Search { get; private set; } = "";
|
||||
|
||||
public NestSortField Sort { get; private set; } = NestSortField.SavedAt;
|
||||
|
||||
public bool Descending { get; private set; } = true;
|
||||
|
||||
/// <summary>Offset of the latest request.</summary>
|
||||
public int Offset { get; private set; }
|
||||
|
||||
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
|
||||
public NestPage? Page { get; private set; }
|
||||
|
||||
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
|
||||
public bool IsLoading { get; private set; }
|
||||
|
||||
public bool CanGoPrevious => Offset > 0;
|
||||
|
||||
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
|
||||
|
||||
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Page is not { } page)
|
||||
return "";
|
||||
if (page.Total == 0)
|
||||
return Search.Length == 0 ? "No nests on the server." : "No nests match the filter.";
|
||||
if (page.Items.Count == 0)
|
||||
return $"No nests on this page ({Count(page.Total)} in total).";
|
||||
|
||||
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} nests";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies new search text and returns to the first page.</summary>
|
||||
public Task<bool> SetSearchAsync(string? search)
|
||||
{
|
||||
Search = (search ?? "").Trim();
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
|
||||
public Task<bool> SortByAsync(NestSortField field)
|
||||
{
|
||||
Descending = field == Sort && !Descending;
|
||||
Sort = field;
|
||||
Offset = 0;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> NextPageAsync()
|
||||
{
|
||||
if (!CanGoNext)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = Page!.Offset + PageSize;
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
public Task<bool> PreviousPageAsync()
|
||||
{
|
||||
if (!CanGoPrevious)
|
||||
return Task.FromResult(false);
|
||||
|
||||
Offset = System.Math.Max(0, Offset - PageSize);
|
||||
return RunAsync();
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
|
||||
public Task<bool> RefreshAsync() => RunAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Sends the current request. Returns true when its page was applied, false when a later
|
||||
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
|
||||
/// failures of superseded requests are ignored.
|
||||
/// </summary>
|
||||
private async Task<bool> RunAsync()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
// Supersede before cancelling: a request that completes synchronously when cancelled
|
||||
// must already see itself as superseded. Each source is released by its own request.
|
||||
var previous = _pending;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_pending = cancellation;
|
||||
var generation = ++_generation;
|
||||
IsLoading = true;
|
||||
|
||||
try
|
||||
{
|
||||
previous?.Cancel();
|
||||
var page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
|
||||
// A deletion or another PC can leave the requested page past the end.
|
||||
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
|
||||
{
|
||||
Offset = (page.Total - 1) / PageSize * PageSize;
|
||||
page = await QueryAsync(cancellation.Token);
|
||||
if (generation != _generation)
|
||||
return false;
|
||||
}
|
||||
|
||||
Page = page;
|
||||
return true;
|
||||
}
|
||||
catch (Exception) when (generation != _generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Page = null;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_pending, cancellation))
|
||||
_pending = null;
|
||||
if (generation == _generation)
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<NestPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
protected override Task<NestPage> QueryAsync(CancellationToken cancellationToken) =>
|
||||
_repository.QueryAsync(
|
||||
new NestQuery
|
||||
{
|
||||
@@ -168,23 +32,4 @@ public sealed class NestBrowseSession : IDisposable
|
||||
Limit = PageSize,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels any request in flight (which releases its own source when it ends);
|
||||
/// the repository is not owned and stays open.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_generation++;
|
||||
IsLoading = false;
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
pending?.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>One page of saved-nest metadata returned by <c>GET /api/nests/query</c>.</summary>
|
||||
public sealed class NestPage
|
||||
public sealed class NestPage : IBrowsePage
|
||||
{
|
||||
/// <summary>At most <see cref="Limit"/> matching records, in the requested order.</summary>
|
||||
public IReadOnlyList<NestRecord> Items { get; init; } = Array.Empty<NestRecord>();
|
||||
@@ -12,4 +12,6 @@ public sealed class NestPage
|
||||
public int Offset { get; init; }
|
||||
|
||||
public int Limit { get; init; }
|
||||
|
||||
int IBrowsePage.ItemCount => Items.Count;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Wire conventions shared by the nest server's HTTP clients.</summary>
|
||||
internal static class NestServerHttp
|
||||
{
|
||||
/// <summary>The server's JSON shape: camel-case names and enum names.</summary>
|
||||
public static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
/// <summary>Guards a write on the revision the caller last read.</summary>
|
||||
public static void AddIfMatch(HttpRequestMessage request, int expectedRevision) =>
|
||||
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||
|
||||
/// <summary>Throws an <see cref="IOException"/> naming the action, status and the start of the body.</summary>
|
||||
public static async Task EnsureSuccessAsync(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());
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,6 @@ namespace OpenNest.Data;
|
||||
/// </summary>
|
||||
public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, 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;
|
||||
@@ -61,7 +54,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
throw new ServerTooOldException();
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
throw new DrawingLibraryConflictException(await ReadRecord(response, cancellationToken));
|
||||
await EnsureSuccess(response, $"save drawing '{record.Name}' to the library", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"save drawing '{record.Name}' to the library", cancellationToken);
|
||||
var saved = await ReadRecord(response, cancellationToken);
|
||||
if (saved.Id != record.Id || saved.Revision != 1 || saved.PackageSha256 != Sha256(package))
|
||||
throw new IOException("The nest server returned a different drawing than was saved. Reload the library before saving again.");
|
||||
@@ -78,8 +71,8 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
using var response = await _httpClient.GetAsync(Url("api/drawings/query?" + BuildQueryString(query)), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
throw new ServerTooOldException();
|
||||
await EnsureSuccess(response, "browse the drawing library", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(JsonOptions, cancellationToken)
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "browse the drawing library", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(NestServerHttp.JsonOptions, cancellationToken)
|
||||
?? throw new IOException("Nest server returned an empty drawing page.");
|
||||
if (page.Items is null || page.Items.Count > query.Limit || page.Total < 0 || page.Items.Any(item => item is null))
|
||||
throw new IOException($"Nest server returned an invalid drawing page for a page limited to {query.Limit}.");
|
||||
@@ -96,7 +89,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
await ThrowIfServerTooOld(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
await EnsureSuccess(response, $"read library drawing {id}", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"read library drawing {id}", cancellationToken);
|
||||
var record = await ReadRecord(response, cancellationToken);
|
||||
return record.Id == id ? record : throw new IOException("Nest server returned a different library drawing.");
|
||||
}
|
||||
@@ -123,8 +116,8 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
await ThrowIfServerTooOld(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
await EnsureSuccess(response, $"read library drawing history {id}", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(JsonOptions, cancellationToken)
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"read library drawing history {id}", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(NestServerHttp.JsonOptions, cancellationToken)
|
||||
?? throw new IOException("Nest server returned an empty drawing history page.");
|
||||
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|
||||
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
|
||||
@@ -170,7 +163,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
if (error is not null)
|
||||
throw new ArgumentException(error, nameof(record));
|
||||
using var request = Guarded(HttpMethod.Put, $"api/drawings/{id}/metadata", expectedRevision);
|
||||
request.Content = JsonContent.Create(record, options: JsonOptions);
|
||||
request.Content = JsonContent.Create(record, options: NestServerHttp.JsonOptions);
|
||||
return await SendGuarded(request, id, expectedRevision, $"edit library drawing {id}", cancellationToken);
|
||||
}
|
||||
|
||||
@@ -195,7 +188,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
private HttpRequestMessage Guarded(HttpMethod method, string relative, int expectedRevision)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, Url(relative));
|
||||
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||
NestServerHttp.AddIfMatch(request, expectedRevision);
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -210,7 +203,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
}
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
throw new DrawingLibraryConflictException(await ReadRecord(response, cancellationToken));
|
||||
await EnsureSuccess(response, action, cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
|
||||
var saved = await ReadRecord(response, cancellationToken);
|
||||
if (saved.Id != id || saved.Revision < expectedRevision)
|
||||
throw new IOException("Nest server returned an invalid library drawing revision. Reload the library before saving again.");
|
||||
@@ -225,7 +218,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
await ThrowIfServerTooOld(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
await EnsureSuccess(response, action, cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -288,7 +281,7 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
private static MultipartFormDataContent BuildMultipart(DrawingLibraryRecord record, byte[] package)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
content.Add(new StringContent(JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json"), "metadata");
|
||||
content.Add(new StringContent(JsonSerializer.Serialize(record, NestServerHttp.JsonOptions), Encoding.UTF8, "application/json"), "metadata");
|
||||
var file = new ByteArrayContent(package);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
|
||||
content.Add(file, "package", "drawing.nest");
|
||||
@@ -297,30 +290,10 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
|
||||
|
||||
private static async Task<DrawingLibraryRecord> ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await response.Content.ReadFromJsonAsync<DrawingLibraryRecord>(JsonOptions, cancellationToken);
|
||||
var record = await response.Content.ReadFromJsonAsync<DrawingLibraryRecord>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
return record ?? throw new IOException("Nest server returned an empty library drawing 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());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -23,13 +23,6 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
|
||||
private const string DrawingReleaseFeature = "releasing library drawings on their own";
|
||||
|
||||
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;
|
||||
@@ -59,8 +52,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
using var response = await _httpClient.GetAsync(Url("api/sets/query?" + BuildQueryString(query)), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
throw new ServerTooOldException(Feature);
|
||||
await EnsureSuccess(response, "browse drawing sets", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(JsonOptions, cancellationToken)
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "browse drawing sets", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(NestServerHttp.JsonOptions, cancellationToken)
|
||||
?? throw new IOException("Nest server returned an empty drawing set page.");
|
||||
if (page.Items is null || page.Items.Count > query.Limit || page.Total < 0 || page.Items.Any(item => item is null))
|
||||
throw new IOException($"Nest server returned an invalid drawing set page for a page limited to {query.Limit}.");
|
||||
@@ -121,12 +114,12 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
ValidateId(record.Id);
|
||||
ValidateSet(record);
|
||||
using var response = await _httpClient.PostAsync(Url("api/sets"),
|
||||
JsonContent.Create(ForSave(record), options: JsonOptions), cancellationToken);
|
||||
JsonContent.Create(ForSave(record), options: NestServerHttp.JsonOptions), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
throw new ServerTooOldException(Feature);
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
throw new DrawingSetSaveConflictException(await ReadSet(response, cancellationToken));
|
||||
await EnsureSuccess(response, $"save drawing set '{record.Name}'", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"save drawing set '{record.Name}'", cancellationToken);
|
||||
var saved = await ReadSet(response, cancellationToken);
|
||||
if (saved.Id != record.Id || saved.Revision != 1 || !SameMembers(saved, record))
|
||||
throw new IOException("The nest server returned a different drawing set than was saved. Reload the set before saving again.");
|
||||
@@ -143,7 +136,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
if (record.Id != Guid.Empty && record.Id != id)
|
||||
throw new ArgumentException("The set id does not match the set being saved.", nameof(record));
|
||||
using var request = Guarded(HttpMethod.Put, $"api/sets/{id}", expectedRevision);
|
||||
request.Content = JsonContent.Create(ForSave(record), options: JsonOptions);
|
||||
request.Content = JsonContent.Create(ForSave(record), options: NestServerHttp.JsonOptions);
|
||||
var saved = await SendGuarded(request, id, expectedRevision, $"save drawing set {id}", cancellationToken);
|
||||
if (!SameMembers(saved, record))
|
||||
throw new IOException("The nest server stored different set members. Reload the set before saving again.");
|
||||
@@ -170,7 +163,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
throw new ArgumentException(error, nameof(release));
|
||||
|
||||
using var response = await _httpClient.PostAsync(Url("api/releases"),
|
||||
JsonContent.Create(ForPost(release), options: JsonOptions), cancellationToken);
|
||||
JsonContent.Create(ForPost(release), options: NestServerHttp.JsonOptions), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
throw new ServerTooOldException(ReleaseFeature);
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
@@ -178,7 +171,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
// A server without drawings on their own refuses such a release as malformed.
|
||||
if (response.StatusCode == HttpStatusCode.BadRequest && release.Drawings.Count > 0)
|
||||
await EnsureDrawingReleasesSupportedAsync(cancellationToken);
|
||||
await EnsureSuccess(response, "record the release", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "record the release", cancellationToken);
|
||||
var stored = await ReadRelease(response, cancellationToken);
|
||||
if (stored.Id != release.Id
|
||||
|| !stored.Sets.Select(s => (s?.SetId, s?.SetRevision, s?.Quantity))
|
||||
@@ -200,7 +193,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
await ThrowIfReleasesUnsupported(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
await EnsureSuccess(response, $"read release {id}", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"read release {id}", cancellationToken);
|
||||
var release = await ReadRelease(response, cancellationToken);
|
||||
if (release.Id != id)
|
||||
throw new IOException("Nest server returned a different release.");
|
||||
@@ -224,8 +217,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
// A server without drawings on their own refuses the unknown drawingId parameter.
|
||||
if (response.StatusCode == HttpStatusCode.BadRequest && drawingId is not null)
|
||||
throw new ServerTooOldException(DrawingReleaseFeature);
|
||||
await EnsureSuccess(response, "read releases", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<SetReleasePage>(JsonOptions, cancellationToken)
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "read releases", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<SetReleasePage>(NestServerHttp.JsonOptions, cancellationToken)
|
||||
?? throw new IOException("Nest server returned an empty release page.");
|
||||
if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|
||||
|| page.Items.Any(item => item is null || item.Sets is null || item.Drawings is null || item.Nests is null
|
||||
@@ -245,7 +238,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
using var probe = await _httpClient.GetAsync(Url($"api/releases/query?drawingId={Guid.NewGuid()}&limit=1"), cancellationToken);
|
||||
if (probe.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest)
|
||||
throw new ServerTooOldException(DrawingReleaseFeature);
|
||||
await EnsureSuccess(probe, "check the release support", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(probe, "check the release support", cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -262,7 +255,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
throw new ArgumentException(error, nameof(request));
|
||||
|
||||
using var response = await _httpClient.PostAsync(Url($"api/releases/{id}/undo"),
|
||||
JsonContent.Create(request, options: JsonOptions), cancellationToken);
|
||||
JsonContent.Create(request, options: NestServerHttp.JsonOptions), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
await ThrowIfReleasesUnsupported(cancellationToken);
|
||||
@@ -270,12 +263,12 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
}
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
var conflict = await response.Content.ReadFromJsonAsync<SetReleaseUndoConflict>(JsonOptions, cancellationToken);
|
||||
var conflict = await response.Content.ReadFromJsonAsync<SetReleaseUndoConflict>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
if (conflict?.Release is null || conflict.ChangedNests is null)
|
||||
throw new IOException("Nest server refused the undo without saying why.");
|
||||
throw new SetReleaseUndoConflictException(conflict);
|
||||
}
|
||||
await EnsureSuccess(response, $"undo release {id}", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"undo release {id}", cancellationToken);
|
||||
var undone = await ReadRelease(response, cancellationToken);
|
||||
if (undone.Id != id || !undone.IsUndone)
|
||||
throw new IOException("Nest server did not report the release as undone.");
|
||||
@@ -297,7 +290,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
private HttpRequestMessage Guarded(HttpMethod method, string relative, int expectedRevision)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, Url(relative));
|
||||
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||
NestServerHttp.AddIfMatch(request, expectedRevision);
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -312,7 +305,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
}
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
throw new DrawingSetSaveConflictException(await ReadSet(response, cancellationToken));
|
||||
await EnsureSuccess(response, action, cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
|
||||
var saved = await ReadSet(response, cancellationToken);
|
||||
if (saved.Id != id || saved.Revision < expectedRevision)
|
||||
throw new IOException("Nest server returned an invalid drawing set revision. Reload the set before saving again.");
|
||||
@@ -327,7 +320,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
await ThrowIfServerTooOld(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
await EnsureSuccess(response, action, cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
|
||||
return await ReadSet(response, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -341,8 +334,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
await ThrowIfServerTooOld(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
await EnsureSuccess(response, action, cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(JsonOptions, cancellationToken)
|
||||
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(NestServerHttp.JsonOptions, cancellationToken)
|
||||
?? throw new IOException("Nest server returned an empty drawing set page.");
|
||||
if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|
||||
|| page.Items.Any(item => item is null))
|
||||
@@ -465,38 +458,18 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
|
||||
|
||||
private static async Task<DrawingSetRecord> ReadSet(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await response.Content.ReadFromJsonAsync<DrawingSetRecord>(JsonOptions, cancellationToken);
|
||||
var record = await response.Content.ReadFromJsonAsync<DrawingSetRecord>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
return record ?? throw new IOException("Nest server returned an empty drawing set record.");
|
||||
}
|
||||
|
||||
private static async Task<SetReleaseRecord> ReadRelease(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var release = await response.Content.ReadFromJsonAsync<SetReleaseRecord>(JsonOptions, cancellationToken);
|
||||
var release = await response.Content.ReadFromJsonAsync<SetReleaseRecord>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
if (release is null || release.Sets is null || release.Drawings is null || release.Nests is null)
|
||||
throw new IOException("Nest server returned an empty set release record.");
|
||||
return release;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -16,13 +16,6 @@ namespace OpenNest.Data;
|
||||
/// </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;
|
||||
@@ -46,9 +39,9 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
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);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "list nests", cancellationToken);
|
||||
var items = await response.Content
|
||||
.ReadFromJsonAsync<List<NestRecord>>(JsonOptions, cancellationToken);
|
||||
.ReadFromJsonAsync<List<NestRecord>>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
return items ?? new List<NestRecord>();
|
||||
}
|
||||
|
||||
@@ -73,8 +66,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
"This nest server does not support filtered browsing (HTTP 404). Update the nest server to this OpenNest version.");
|
||||
}
|
||||
|
||||
await EnsureSuccess(response, "query nests", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<NestPage>(JsonOptions, cancellationToken)
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "query nests", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<NestPage>(NestServerHttp.JsonOptions, cancellationToken)
|
||||
?? throw new IOException("Nest server returned an empty page.");
|
||||
if (page.Items.Count > query.Limit)
|
||||
{
|
||||
@@ -115,8 +108,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
|
||||
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"get nest {id}", cancellationToken);
|
||||
return await response.Content.ReadFromJsonAsync<NestRecord>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||
@@ -130,7 +123,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/file"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"download nest {id}", cancellationToken);
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -139,7 +132,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
{
|
||||
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);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"upload nest '{record.Name}'", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -151,8 +144,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
Url("api/nest-names/next?year=" + year.ToString(CultureInfo.InvariantCulture)), null, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
throw new NotSupportedException("This nest server does not allocate nest names (HTTP 404).");
|
||||
await EnsureSuccess(response, "allocate a nest name", cancellationToken);
|
||||
var allocation = await response.Content.ReadFromJsonAsync<NestNameAllocation>(JsonOptions, cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, "allocate a nest name", cancellationToken);
|
||||
var allocation = await response.Content.ReadFromJsonAsync<NestNameAllocation>(NestServerHttp.JsonOptions, cancellationToken);
|
||||
if (allocation is null || allocation.Year != year || allocation.Number < 1)
|
||||
throw new IOException("Nest server returned an invalid nest name allocation.");
|
||||
return allocation.Number;
|
||||
@@ -192,8 +185,8 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload.");
|
||||
await EnsureSuccess(response, $"read nest history {id}", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(JsonOptions, cancellationToken)
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"read nest history {id}", cancellationToken);
|
||||
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(NestServerHttp.JsonOptions, cancellationToken)
|
||||
?? throw new IOException("Nest server returned an empty history page.");
|
||||
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|
||||
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
|
||||
@@ -216,7 +209,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
ValidateRevision(revisionNumber);
|
||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound) return null;
|
||||
await EnsureSuccess(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -232,10 +225,10 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
if (history.Items.Count == 0)
|
||||
throw new IOException("Nest server returned no revision history. Upgrade the nest server and reload before saving.");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/file"));
|
||||
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||
NestServerHttp.AddIfMatch(request, expectedRevision);
|
||||
request.Content = BuildMultipart(nestFile, record);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
await EnsureSuccess(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken);
|
||||
var saved = await ReadRecord(response, cancellationToken);
|
||||
if (saved.Id != id || saved.Revision < expectedRevision)
|
||||
throw new IOException("Nest server returned an invalid saved revision. Reload to reconcile before saving again.");
|
||||
@@ -249,10 +242,10 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
ValidateRevision(expectedRevision);
|
||||
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/metadata"));
|
||||
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||
request.Content = JsonContent.Create(record, options: JsonOptions);
|
||||
NestServerHttp.AddIfMatch(request, expectedRevision);
|
||||
request.Content = JsonContent.Create(record, options: NestServerHttp.JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
await EnsureSuccess(response, $"edit nest {id}; reload after a conflict", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"edit nest {id}; reload after a conflict", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -265,9 +258,9 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post,
|
||||
Url($"api/nests/{id}/revisions/{sourceRevision.ToString(CultureInfo.InvariantCulture)}/restore"));
|
||||
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||
NestServerHttp.AddIfMatch(request, expectedRevision);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
await EnsureSuccess(response, $"restore nest {id}; reload after a conflict", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"restore nest {id}; reload after a conflict", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -286,9 +279,9 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
await GetHistoryAsync(id, 0, 1, !deleted, cancellationToken);
|
||||
using var request = new HttpRequestMessage(deleted ? HttpMethod.Delete : HttpMethod.Post,
|
||||
Url(deleted ? $"api/nests/{id}" : $"api/nests/deleted/{id}/recover"));
|
||||
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||
NestServerHttp.AddIfMatch(request, expectedRevision);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
await EnsureSuccess(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
|
||||
await NestServerHttp.EnsureSuccessAsync(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -312,7 +305,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
var metadata = new StringContent(
|
||||
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
|
||||
JsonSerializer.Serialize(record, NestServerHttp.JsonOptions), Encoding.UTF8, "application/json");
|
||||
content.Add(metadata, "metadata");
|
||||
var file = new ByteArrayContent(nestFile);
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
|
||||
@@ -323,29 +316,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
private static async Task<NestRecord> ReadRecord(
|
||||
HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||
var record = await response.Content.ReadFromJsonAsync<NestRecord>(NestServerHttp.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());
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public sealed class SupportGridSettings
|
||||
}
|
||||
|
||||
/// <summary>The grid these settings describe in <paramref name="units"/>, or null when it is invalid.</summary>
|
||||
public SupportGrid GridFor(Units units)
|
||||
public SupportGrid? GridFor(Units units)
|
||||
{
|
||||
var converted = ConvertedTo(units);
|
||||
try
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
@@ -209,9 +209,9 @@ public class StockLadderTests
|
||||
new[] { stock },
|
||||
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: min)
|
||||
);
|
||||
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 0), sheet));
|
||||
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 7), sheet));
|
||||
Assert.Equal(70, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 5), sheet), 6);
|
||||
Assert.Equal(100, NestJobCost.NetSheetArea(Job(0.5, 0), sheet));
|
||||
Assert.Equal(100, NestJobCost.NetSheetArea(Job(0.5, 7), sheet));
|
||||
Assert.Equal(70, NestJobCost.NetSheetArea(Job(0.5, 5), sheet), 6);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new NestJobOptions(salvageRate: double.NaN)
|
||||
);
|
||||
|
||||
@@ -276,7 +276,7 @@ public static class CuttingPlanService
|
||||
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
|
||||
var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
|
||||
placement.SourceOrdinal + 1, placement.IsCutOff, token);
|
||||
fixedFindings.AddRange(Map(snapshot, findings));
|
||||
fixedFindings.AddRange(JointCuttingPlanSearch.Map(snapshot, findings));
|
||||
}
|
||||
if (fixedFindings.Count != 0)
|
||||
return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
@@ -293,7 +293,7 @@ public static class CuttingPlanService
|
||||
}
|
||||
var search = FixedProgramSearch.Run(snapshot, token);
|
||||
if (search.Status != CuttingPlanStatus.Ready)
|
||||
return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions);
|
||||
return new(search.Status, findings: JointCuttingPlanSearch.Map(snapshot, search.Findings), expansions: search.Expansions);
|
||||
return Replay(snapshot, search.Order, search.Expansions, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -330,7 +330,7 @@ public static class CuttingPlanService
|
||||
if (findings.Count != 0)
|
||||
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
|
||||
findings: Map(snapshot, findings), expansions: expansions);
|
||||
findings: JointCuttingPlanSearch.Map(snapshot, findings), expansions: expansions);
|
||||
return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]),
|
||||
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
|
||||
}
|
||||
@@ -489,14 +489,4 @@ public static class CuttingPlanService
|
||||
|
||||
private static bool SameBits(double source, double proposed) =>
|
||||
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
|
||||
|
||||
private static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot,
|
||||
IEnumerable<PostVerificationFinding> findings) => findings.Select(finding =>
|
||||
{
|
||||
var source = finding.PartNumber is { } part ? snapshot.Placements[part - 1] : null;
|
||||
var other = finding.OtherPartNumber is { } otherPart ? snapshot.Placements[otherPart - 1] : null;
|
||||
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
|
||||
other?.SourceOrdinal, other?.SourcePart, finding.Kind, finding.Message)
|
||||
{ Witness = finding };
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement.Fillers;
|
||||
namespace OpenNest.Engine.Jobs.Placement;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in remnant placement strategy for the whole-job runner. Fills one candidate per trial with
|
||||
/// a <see cref="RemnantPlateFiller"/> for the vertical or horizontal remnant policy, on a fresh
|
||||
/// private plate, using the same run-scoped identity, progress, and accounting boundaries as
|
||||
/// <see cref="DefaultPlateNester"/>.
|
||||
/// a <see cref="RemnantPlateFiller"/> for the vertical or horizontal remnant policy. Placement is
|
||||
/// <see cref="DefaultPlateNester"/>'s with that filler: the same fresh private plate per trial and
|
||||
/// the same run-scoped identity, progress, and accounting boundaries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The remnant fillers share the Fill pipeline's automatic-rotation limitation, so non-automatic
|
||||
@@ -18,14 +16,12 @@ namespace OpenNest.Engine.Jobs.Placement;
|
||||
/// </remarks>
|
||||
public sealed class RemnantPlateNester : IPlateNester
|
||||
{
|
||||
private readonly Func<Plate, RemnantPlateFiller> fillerFactory;
|
||||
private readonly OrderedPlateNester restrictedRotationNester = new();
|
||||
private readonly CandidatePlacementContext context = new();
|
||||
private readonly DefaultPlateNester nester;
|
||||
|
||||
internal RemnantPlateNester(Func<Plate, RemnantPlateFiller> fillerFactory)
|
||||
{
|
||||
this.fillerFactory =
|
||||
fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory));
|
||||
nester = new DefaultPlateNester(
|
||||
fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory)));
|
||||
}
|
||||
|
||||
/// <summary>Vertical-remnant policy: minimize X-extent, prefer horizontal placement.</summary>
|
||||
@@ -40,29 +36,5 @@ public sealed class RemnantPlateNester : IPlateNester
|
||||
PlatePlacementRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Same safety rule as DefaultPlateNester: the remnant fillers inherit the Fill pipeline's
|
||||
// automatic-rotation limitation, so any non-automatic requirement goes to the policy-aware
|
||||
// ordered nester.
|
||||
if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic))
|
||||
return restrictedRotationNester.Place(request, progress, token);
|
||||
|
||||
var plate = DrawingJobMapper.CreatePlate(request.Stock);
|
||||
var items = context.CreateItems(request.Parts);
|
||||
|
||||
var filler =
|
||||
fillerFactory(plate)
|
||||
?? throw new InvalidOperationException("Filler factory returned null.");
|
||||
var candidateProgress = CandidateProgressBridge.Create(progress, request.Stock.Id);
|
||||
var parts = filler.Nest(items, candidateProgress, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (parts == null)
|
||||
throw new InvalidOperationException("Filler returned null placements.");
|
||||
|
||||
return new PlateCandidate(context.MapPlacements(parts));
|
||||
}
|
||||
) => nester.Place(request, progress, token);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using OpenNest.Breakoffs;
|
||||
using static OpenNest.IO.Tests.NestArchiveJson;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
@@ -81,31 +82,4 @@ public class BreakoffPersistenceTests
|
||||
Assert.True(new NestWriter(nest).Write(stream));
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
|
||||
{
|
||||
var reader = new NestReader(new MemoryStream(archive));
|
||||
return (reader.Read(), reader.Warnings);
|
||||
}
|
||||
|
||||
private static JsonObject NestJson(byte[] archive)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
|
||||
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
|
||||
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
|
||||
}
|
||||
|
||||
private static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
output.Write(archive);
|
||||
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
|
||||
{
|
||||
zip.GetEntry("nest.json")!.Delete();
|
||||
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
|
||||
writer.Write(json.ToJsonString());
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.IO.Tests.NestArchiveJson;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
@@ -518,42 +519,4 @@ public class ClampRailPersistenceTests
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadNestJson(MemoryStream zipBytes)
|
||||
{
|
||||
zipBytes.Position = 0;
|
||||
using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entry = archive.GetEntry("nest.json");
|
||||
Assert.NotNull(entry);
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func<string, string> rewrite)
|
||||
{
|
||||
zipBytes.Position = 0;
|
||||
var output = new MemoryStream();
|
||||
using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
|
||||
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in source.Entries)
|
||||
{
|
||||
var newEntry = target.CreateEntry(entry.FullName);
|
||||
using var input = entry.Open();
|
||||
using var outStream = newEntry.Open();
|
||||
if (entry.FullName == "nest.json")
|
||||
{
|
||||
using var text = new StreamReader(input, Encoding.UTF8);
|
||||
using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
|
||||
writer.Write(rewrite(text.ReadToEnd()));
|
||||
}
|
||||
else
|
||||
{
|
||||
input.CopyTo(outStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.IO.Tests.NestArchiveJson;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
@@ -135,42 +136,4 @@ public class ClampZonePersistenceTests
|
||||
Assert.Contains("\"length\": 1.5", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"width\": 0.625", json, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ReadNestJson(MemoryStream zipBytes)
|
||||
{
|
||||
zipBytes.Position = 0;
|
||||
using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entry = archive.GetEntry("nest.json");
|
||||
Assert.NotNull(entry);
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func<string, string> rewrite)
|
||||
{
|
||||
zipBytes.Position = 0;
|
||||
var output = new MemoryStream();
|
||||
using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
|
||||
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in source.Entries)
|
||||
{
|
||||
var newEntry = target.CreateEntry(entry.FullName);
|
||||
using var input = entry.Open();
|
||||
using var outStream = newEntry.Open();
|
||||
if (entry.FullName == "nest.json")
|
||||
{
|
||||
using var text = new StreamReader(input, Encoding.UTF8);
|
||||
using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
|
||||
writer.Write(rewrite(text.ReadToEnd()));
|
||||
}
|
||||
else
|
||||
{
|
||||
input.CopyTo(outStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.IO.Tests.NestArchiveJson;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
@@ -193,37 +194,10 @@ public class CutPinPersistenceTests
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
|
||||
{
|
||||
var reader = new NestReader(new MemoryStream(archive));
|
||||
return (reader.Read(), reader.Warnings);
|
||||
}
|
||||
|
||||
private static JsonObject NestJson(byte[] archive)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
|
||||
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
|
||||
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
|
||||
}
|
||||
|
||||
private static byte[] ReplaceCutPins(byte[] archive, JsonNode? cutPins)
|
||||
{
|
||||
var json = NestJson(archive);
|
||||
json["drawings"]![0]!["cutPins"] = cutPins;
|
||||
return ReplaceNestJson(archive, json);
|
||||
}
|
||||
|
||||
private static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
output.Write(archive);
|
||||
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
|
||||
{
|
||||
zip.GetEntry("nest.json")!.Delete();
|
||||
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
|
||||
writer.Write(json.ToJsonString());
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and rewrites the <c>nest.json</c> entry of a .nest archive, for tests that check or
|
||||
/// corrupt the stored shape. Import with <c>using static</c>.
|
||||
/// </summary>
|
||||
internal static class NestArchiveJson
|
||||
{
|
||||
public static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
|
||||
{
|
||||
var reader = new NestReader(new MemoryStream(archive));
|
||||
return (reader.Read(), reader.Warnings);
|
||||
}
|
||||
|
||||
public static JsonObject NestJson(byte[] archive)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
|
||||
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
|
||||
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
|
||||
}
|
||||
|
||||
public static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
output.Write(archive);
|
||||
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
|
||||
{
|
||||
zip.GetEntry("nest.json")!.Delete();
|
||||
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
|
||||
writer.Write(json.ToJsonString());
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
public static string ReadNestJson(MemoryStream zipBytes)
|
||||
{
|
||||
zipBytes.Position = 0;
|
||||
using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entry = archive.GetEntry("nest.json");
|
||||
Assert.NotNull(entry);
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
/// <summary>A copy of the archive whose nest.json text passed through <paramref name="rewrite"/>.</summary>
|
||||
public static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func<string, string> rewrite)
|
||||
{
|
||||
zipBytes.Position = 0;
|
||||
var output = new MemoryStream();
|
||||
using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
|
||||
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in source.Entries)
|
||||
{
|
||||
var newEntry = target.CreateEntry(entry.FullName);
|
||||
using var input = entry.Open();
|
||||
using var outStream = newEntry.Open();
|
||||
if (entry.FullName == "nest.json")
|
||||
{
|
||||
using var text = new StreamReader(input, Encoding.UTF8);
|
||||
using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
|
||||
writer.Write(rewrite(text.ReadToEnd()));
|
||||
}
|
||||
else
|
||||
{
|
||||
input.CopyTo(outStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.Position = 0;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using OpenNest.Bending;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.TabbedCutouts;
|
||||
using static OpenNest.IO.Tests.NestArchiveJson;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
@@ -106,31 +107,4 @@ public class TabbedCutoutPersistenceTests
|
||||
Assert.True(new NestWriter(nest).Write(stream));
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
|
||||
{
|
||||
var reader = new NestReader(new MemoryStream(archive));
|
||||
return (reader.Read(), reader.Warnings);
|
||||
}
|
||||
|
||||
private static JsonObject NestJson(byte[] archive)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
|
||||
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
|
||||
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
|
||||
}
|
||||
|
||||
private static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
output.Write(archive);
|
||||
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
|
||||
{
|
||||
zip.GetEntry("nest.json")!.Delete();
|
||||
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
|
||||
writer.Write(json.ToJsonString());
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
+32
-60
@@ -8,14 +8,13 @@ using CSMath;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using AcadArc = ACadSharp.Entities.Arc;
|
||||
using AcadCircle = ACadSharp.Entities.Circle;
|
||||
using AcadLine = ACadSharp.Entities.Line;
|
||||
using Layer = ACadSharp.Tables.Layer;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
using AcadArc = ACadSharp.Entities.Arc;
|
||||
using AcadCircle = ACadSharp.Entities.Circle;
|
||||
using AcadLine = ACadSharp.Entities.Line;
|
||||
using Layer = ACadSharp.Tables.Layer;
|
||||
|
||||
public static class Dxf
|
||||
{
|
||||
#region Import
|
||||
@@ -43,64 +42,17 @@ namespace OpenNest.IO
|
||||
return new DxfImportResult { Entities = entities, Document = doc };
|
||||
}
|
||||
|
||||
public static List<Entity> GetGeometry(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = ReadDocument(path);
|
||||
return ConvertEntities(doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
return new List<Entity>();
|
||||
}
|
||||
}
|
||||
public static List<Entity> GetGeometry(string path) =>
|
||||
ReadGeometry(() => ReadDocument(path), null);
|
||||
|
||||
public static List<Entity> GetGeometry(Stream stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new DxfReader(stream);
|
||||
var doc = reader.Read();
|
||||
return ConvertEntities(doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
return new List<Entity>();
|
||||
}
|
||||
}
|
||||
public static List<Entity> GetGeometry(Stream stream) =>
|
||||
GetGeometry(stream, null);
|
||||
|
||||
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new DxfReader(path);
|
||||
var doc = reader.Read();
|
||||
return ConvertEntities(doc, layerFilter);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
return new List<Entity>();
|
||||
}
|
||||
}
|
||||
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter) =>
|
||||
ReadGeometry(() => ReadDxf(new DxfReader(path)), layerFilter);
|
||||
|
||||
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new DxfReader(stream);
|
||||
var doc = reader.Read();
|
||||
return ConvertEntities(doc, layerFilter);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
return new List<Entity>();
|
||||
}
|
||||
}
|
||||
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter) =>
|
||||
ReadGeometry(() => ReadDxf(new DxfReader(stream)), layerFilter);
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -151,6 +103,26 @@ namespace OpenNest.IO
|
||||
private static bool IsDwg(string path) =>
|
||||
Path.GetExtension(path).Equals(".dwg", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// An unreadable drawing yields no geometry rather than an exception.
|
||||
private static List<Entity> ReadGeometry(Func<CadDocument> read, Func<string, bool> layerFilter)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ConvertEntities(read(), layerFilter);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(ex.Message);
|
||||
return new List<Entity>();
|
||||
}
|
||||
}
|
||||
|
||||
private static CadDocument ReadDxf(DxfReader reader)
|
||||
{
|
||||
using (reader)
|
||||
return reader.Read();
|
||||
}
|
||||
|
||||
private static CadDocument ReadDocument(string path)
|
||||
{
|
||||
if (IsDwg(path))
|
||||
|
||||
@@ -249,15 +249,10 @@ public sealed partial class NestDatabase
|
||||
lock (_sync)
|
||||
{
|
||||
if (GetDrawing(id) is null) return null;
|
||||
using var count = _connection.CreateCommand();
|
||||
count.CommandText = "SELECT COUNT(*) FROM drawing_revisions WHERE id=$id";
|
||||
count.Parameters.AddWithValue("$id", id.ToString());
|
||||
var total = checked((int)(long)count.ExecuteScalar()!);
|
||||
var total = CountRevisions("drawing_revisions", id);
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = $"SELECT {DrawingColumns} FROM drawing_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset";
|
||||
command.Parameters.AddWithValue("$id", id.ToString());
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
command.Parameters.AddWithValue("$offset", offset);
|
||||
BindRevisionPage(command, id, offset, limit);
|
||||
var records = new List<DrawingLibraryRecord>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read()) records.Add(ReadDrawing(reader));
|
||||
|
||||
@@ -186,18 +186,13 @@ public sealed partial class NestDatabase
|
||||
lock (_sync)
|
||||
{
|
||||
if (GetDrawingSet(id) is null) return null;
|
||||
using var count = _connection.CreateCommand();
|
||||
count.CommandText = "SELECT COUNT(*) FROM drawing_set_revisions WHERE id=$id";
|
||||
count.Parameters.AddWithValue("$id", id.ToString());
|
||||
var total = checked((int)(long)count.ExecuteScalar()!);
|
||||
var total = CountRevisions("drawing_set_revisions", id);
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
SELECT {SetColumns}, {SetMemberCount} FROM drawing_set_revisions s WHERE s.id=$id
|
||||
ORDER BY s.revision DESC LIMIT $limit OFFSET $offset
|
||||
""";
|
||||
command.Parameters.AddWithValue("$id", id.ToString());
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
command.Parameters.AddWithValue("$offset", offset);
|
||||
BindRevisionPage(command, id, offset, limit);
|
||||
return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = offset, Limit = limit };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,15 +293,10 @@ public sealed partial class NestDatabase : IDisposable
|
||||
lock (_sync)
|
||||
{
|
||||
if (Get(id, deleted) is null) return null;
|
||||
using var count = _connection.CreateCommand();
|
||||
count.CommandText = "SELECT COUNT(*) FROM nest_revisions WHERE id=$id";
|
||||
count.Parameters.AddWithValue("$id", id.ToString());
|
||||
var total = checked((int)(long)count.ExecuteScalar()!);
|
||||
var total = CountRevisions("nest_revisions", id);
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset";
|
||||
command.Parameters.AddWithValue("$id", id.ToString());
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
command.Parameters.AddWithValue("$offset", offset);
|
||||
BindRevisionPage(command, id, offset, limit);
|
||||
var records = new List<NestRecord>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read()) records.Add(ReadRecord(reader));
|
||||
@@ -513,6 +508,23 @@ public sealed partial class NestDatabase : IDisposable
|
||||
+ (query.MaterialGrade is null ? "" : " AND shared_key(materialGrade)=$gradeKey")
|
||||
+ (query.Thickness is null ? "" : " AND ABS(ROUND(thickness, 4) - $thickness) < 0.000001");
|
||||
|
||||
// Revisions of one id in a *_revisions table; call under _sync.
|
||||
private int CountRevisions(string table, Guid id)
|
||||
{
|
||||
using var count = _connection.CreateCommand();
|
||||
count.CommandText = $"SELECT COUNT(*) FROM {table} WHERE id=$id";
|
||||
count.Parameters.AddWithValue("$id", id.ToString());
|
||||
return checked((int)(long)count.ExecuteScalar()!);
|
||||
}
|
||||
|
||||
// Binds a newest-first revision page query's $id, $limit and $offset.
|
||||
private static void BindRevisionPage(SqliteCommand command, Guid id, int offset, int limit)
|
||||
{
|
||||
command.Parameters.AddWithValue("$id", id.ToString());
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
command.Parameters.AddWithValue("$offset", offset);
|
||||
}
|
||||
|
||||
private static void AddFilterParameters(SqliteCommand command, NestQuery query)
|
||||
{
|
||||
if (query.Status is { } status)
|
||||
|
||||
@@ -35,9 +35,9 @@ internal static class NestQueryRequest
|
||||
|
||||
var sort = NestSortField.SavedAt;
|
||||
if (values.TryGetValue("sort", out var sortText)
|
||||
&& !TrySortField(sortText.ToString(), out sort))
|
||||
&& !DrawingLibraryRequest.TryEnumName(sortText.ToString(), out sort))
|
||||
{
|
||||
error = $"sort must be one of: {string.Join(", ", Enum.GetNames<NestSortField>().Select(CamelCase))}.";
|
||||
error = $"sort must be one of: {string.Join(", ", Enum.GetNames<NestSortField>().Select(DrawingLibraryRequest.CamelCase))}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ internal static class NestQueryRequest
|
||||
|
||||
var offset = 0;
|
||||
var limit = NestQuery.DefaultLimit;
|
||||
if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error))
|
||||
if (!DrawingLibraryRequest.TryInteger(values, "offset", ref offset, out error)
|
||||
|| !DrawingLibraryRequest.TryInteger(values, "limit", ref limit, out error))
|
||||
return false;
|
||||
|
||||
query = new NestQuery
|
||||
@@ -99,29 +100,4 @@ internal static class NestQueryRequest
|
||||
error = query.GetValidationError() ?? "";
|
||||
return error.Length == 0;
|
||||
}
|
||||
|
||||
// Names only: Enum.TryParse would also accept numbers and undefined values.
|
||||
private static bool TrySortField(string text, out NestSortField field)
|
||||
{
|
||||
field = NestSortField.SavedAt;
|
||||
return text.Length > 0
|
||||
&& text.All(char.IsAsciiLetter)
|
||||
&& Enum.TryParse(text, ignoreCase: true, out field)
|
||||
&& Enum.IsDefined(field);
|
||||
}
|
||||
|
||||
private static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..];
|
||||
|
||||
private static bool TryInteger(IQueryCollection values, string key, ref int value, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (!values.TryGetValue(key, out var text))
|
||||
return true;
|
||||
|
||||
if (int.TryParse(text.ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out value))
|
||||
return true;
|
||||
|
||||
error = $"{key} must be a non-negative integer.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives a grid that pages through a <see cref="BrowseSession{TPage, TSort}"/>: the summary
|
||||
/// line, Previous/Next, sort glyphs and the failure text. The owner fills the rows
|
||||
/// (<see cref="Populate"/>) and enables its own commands (<see cref="Settled"/>).
|
||||
/// </summary>
|
||||
internal sealed class BrowsePager<TPage, TSort>
|
||||
where TPage : class, IBrowsePage
|
||||
where TSort : struct, Enum
|
||||
{
|
||||
private const string LoadingText = "Loading...";
|
||||
|
||||
private readonly BrowseSession<TPage, TSort> session;
|
||||
private readonly DataGridView grid;
|
||||
private readonly Label summary;
|
||||
private readonly Button previous;
|
||||
private readonly Button next;
|
||||
private readonly string listName;
|
||||
|
||||
/// <param name="listName">What failed to load, as in "Could not load the drawing sets".</param>
|
||||
public BrowsePager(BrowseSession<TPage, TSort> session, DataGridView grid, Label summary,
|
||||
Button previous, Button next, string listName)
|
||||
{
|
||||
this.session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
this.grid = grid;
|
||||
this.summary = summary;
|
||||
this.previous = previous;
|
||||
this.next = next;
|
||||
this.listName = listName;
|
||||
}
|
||||
|
||||
/// <summary>Fills the grid from an applied page.</summary>
|
||||
public Action<TPage> Populate { get; init; }
|
||||
|
||||
/// <summary>Runs after every request that leaves the owner open, to enable its commands.</summary>
|
||||
public Action Settled { get; init; }
|
||||
|
||||
/// <summary>Runs after a failed request has cleared the grid.</summary>
|
||||
public Action Failed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts a session request and renders the page it applies. A failure clears the grid
|
||||
/// and shows why in the summary line; a request that was declined or superseded renders
|
||||
/// nothing of its own. Nothing is touched once the owner is disposed.
|
||||
/// </summary>
|
||||
public async Task RunAsync(Func<Task<bool>> request)
|
||||
{
|
||||
summary.Text = LoadingText;
|
||||
try
|
||||
{
|
||||
var started = request();
|
||||
UpdateNavigation();
|
||||
if (await started && !grid.IsDisposed)
|
||||
Populate?.Invoke(session.Page);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!grid.IsDisposed)
|
||||
{
|
||||
grid.Rows.Clear();
|
||||
summary.Text = ex is ServerTooOldException
|
||||
? ex.Message
|
||||
: $"Could not load {listName}: {ex.Message}";
|
||||
Failed?.Invoke();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!grid.IsDisposed)
|
||||
{
|
||||
UpdateNavigation();
|
||||
if (!session.IsLoading && summary.Text == LoadingText)
|
||||
summary.Text = session.Summary;
|
||||
Settled?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateNavigation()
|
||||
{
|
||||
previous.Enabled = !session.IsLoading && session.CanGoPrevious;
|
||||
next.Enabled = !session.IsLoading && session.CanGoNext;
|
||||
}
|
||||
|
||||
/// <summary>Marks the column whose <see cref="DataGridViewColumn.Tag"/> is the session's sort field.</summary>
|
||||
public void ShowSortGlyphs()
|
||||
{
|
||||
foreach (DataGridViewColumn column in grid.Columns)
|
||||
{
|
||||
column.HeaderCell.SortGlyphDirection = column.Tag is TSort field && EqualityComparer<TSort>.Default.Equals(field, session.Sort)
|
||||
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
|
||||
: SortOrder.None;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every selected row's record in list order; the current row's alone when nothing is selected.</summary>
|
||||
public IReadOnlyList<TRecord> SelectedRecords<TRecord>()
|
||||
where TRecord : class
|
||||
{
|
||||
var picked = grid.SelectedRows.Cast<DataGridViewRow>()
|
||||
.OrderBy(row => row.Index)
|
||||
.Select(row => row.Tag as TRecord)
|
||||
.Where(record => record != null)
|
||||
.ToList();
|
||||
if (picked.Count == 0 && grid.CurrentRow?.Tag is TRecord current)
|
||||
picked.Add(current);
|
||||
return picked;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,6 @@ namespace OpenNest.Controls
|
||||
/// </summary>
|
||||
public partial class DrawingSetBrowser : UserControl
|
||||
{
|
||||
private const string LoadingText = "Loading...";
|
||||
|
||||
private IDrawingSetStore sets;
|
||||
private IDrawingLibraryStore library;
|
||||
private string serverUrl;
|
||||
@@ -27,6 +25,7 @@ namespace OpenNest.Controls
|
||||
private Action<Nest, NestRecord> openNest;
|
||||
private Func<Guid, bool> isNestOpen = _ => false;
|
||||
private DrawingSetBrowseSession session;
|
||||
private BrowsePager<DrawingSetPage, DrawingSetSortField> pager;
|
||||
private bool pick;
|
||||
private bool busy;
|
||||
|
||||
@@ -84,6 +83,13 @@ namespace OpenNest.Controls
|
||||
this.serverUrl = serverUrl ?? "";
|
||||
this.pick = pick;
|
||||
session = new DrawingSetBrowseSession(sets);
|
||||
Disposed += (_, _) => session.Dispose();
|
||||
pager = new BrowsePager<DrawingSetPage, DrawingSetSortField>(
|
||||
session, setGrid, summaryLabel, previousButton, nextButton, "the drawing sets")
|
||||
{
|
||||
Populate = Populate,
|
||||
Settled = UpdateCommands,
|
||||
};
|
||||
ShowMessage = (text, buttons, icon) => MessageBox.Show(FindForm(), text, FindForm()?.Text ?? "Drawing Sets", buttons, icon);
|
||||
EditSet = edit =>
|
||||
{
|
||||
@@ -116,7 +122,7 @@ namespace OpenNest.Controls
|
||||
releasesButton.Visible = false;
|
||||
}
|
||||
|
||||
UpdateNavigation();
|
||||
pager.UpdateNavigation();
|
||||
UpdateCommands();
|
||||
}
|
||||
|
||||
@@ -396,38 +402,7 @@ namespace OpenNest.Controls
|
||||
ShowMessage(message, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
|
||||
private async Task RunAsync(Func<Task<bool>> request)
|
||||
{
|
||||
summaryLabel.Text = LoadingText;
|
||||
try
|
||||
{
|
||||
var started = request();
|
||||
UpdateNavigation();
|
||||
if (await started && !IsDisposed)
|
||||
Populate(session.Page);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
setGrid.Rows.Clear();
|
||||
summaryLabel.Text = ex is ServerTooOldException
|
||||
? ex.Message
|
||||
: $"Could not load the drawing sets: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
UpdateNavigation();
|
||||
// A request that was declined or superseded renders nothing of its own.
|
||||
if (!session.IsLoading && summaryLabel.Text == LoadingText)
|
||||
summaryLabel.Text = session.Summary;
|
||||
UpdateCommands();
|
||||
}
|
||||
}
|
||||
}
|
||||
private Task RunAsync(Func<Task<bool>> request) => pager.RunAsync(request);
|
||||
|
||||
private void Populate(DrawingSetPage page)
|
||||
{
|
||||
@@ -452,22 +427,10 @@ namespace OpenNest.Controls
|
||||
if (setGrid.CurrentCell == null && setGrid.Rows.Count > 0)
|
||||
setGrid.CurrentCell = setGrid.Rows[0].Cells[0];
|
||||
|
||||
foreach (DataGridViewColumn column in setGrid.Columns)
|
||||
{
|
||||
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSetSortField field && field == session.Sort
|
||||
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
|
||||
: SortOrder.None;
|
||||
}
|
||||
|
||||
pager.ShowSortGlyphs();
|
||||
notesBox.Text = SelectedSet?.Notes ?? "";
|
||||
}
|
||||
|
||||
private void UpdateNavigation()
|
||||
{
|
||||
previousButton.Enabled = session is { IsLoading: false, CanGoPrevious: true };
|
||||
nextButton.Enabled = session is { IsLoading: false, CanGoNext: true };
|
||||
}
|
||||
|
||||
private void UpdateCommands()
|
||||
{
|
||||
var selected = SelectedSet;
|
||||
|
||||
@@ -19,9 +19,8 @@ namespace OpenNest.Forms
|
||||
/// </summary>
|
||||
public partial class AddReleaseDrawingsForm : Form
|
||||
{
|
||||
private const string LoadingText = "Loading...";
|
||||
|
||||
private readonly DrawingLibraryBrowseSession librarySession;
|
||||
private readonly BrowsePager<DrawingLibraryPage, DrawingSortField> libraryPager;
|
||||
private readonly Func<IReadOnlyList<DrawingLibraryRecord>, int, string> add;
|
||||
private readonly Func<Guid, bool> isListed;
|
||||
|
||||
@@ -36,6 +35,13 @@ namespace OpenNest.Forms
|
||||
this.isListed = isListed ?? throw new ArgumentNullException(nameof(isListed));
|
||||
// A release mixes materials and thicknesses, so the library is never filtered to a nest.
|
||||
librarySession = new DrawingLibraryBrowseSession(library, new Nest());
|
||||
Disposed += (_, _) => librarySession.Dispose();
|
||||
libraryPager = new BrowsePager<DrawingLibraryPage, DrawingSortField>(
|
||||
librarySession, libraryGrid, librarySummaryLabel, previousButton, nextButton, "the drawing library")
|
||||
{
|
||||
Populate = PopulateLibrary,
|
||||
Settled = UpdateAddCommand,
|
||||
};
|
||||
|
||||
GridStyle.Apply(libraryGrid);
|
||||
libraryNameColumn.Tag = DrawingSortField.Name;
|
||||
@@ -128,51 +134,10 @@ namespace OpenNest.Forms
|
||||
/// <summary>The status line, for tests.</summary>
|
||||
internal string Status => statusLabel.Text;
|
||||
|
||||
// Every selected row in list order; the current row alone when nothing is selected.
|
||||
private IReadOnlyList<DrawingLibraryRecord> SelectedDrawings()
|
||||
{
|
||||
var picked = libraryGrid.SelectedRows.Cast<DataGridViewRow>()
|
||||
.OrderBy(row => row.Index)
|
||||
.Select(row => row.Tag as DrawingLibraryRecord)
|
||||
.Where(record => record != null)
|
||||
.ToList();
|
||||
if (picked.Count == 0 && libraryGrid.CurrentRow?.Tag is DrawingLibraryRecord current)
|
||||
picked.Add(current);
|
||||
return picked;
|
||||
}
|
||||
private IReadOnlyList<DrawingLibraryRecord> SelectedDrawings() =>
|
||||
libraryPager.SelectedRecords<DrawingLibraryRecord>();
|
||||
|
||||
private async Task RunLibraryAsync(Func<Task<bool>> request)
|
||||
{
|
||||
librarySummaryLabel.Text = LoadingText;
|
||||
try
|
||||
{
|
||||
var started = request();
|
||||
UpdateLibraryNavigation();
|
||||
if (await started && !IsDisposed)
|
||||
PopulateLibrary(librarySession.Page);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
libraryGrid.Rows.Clear();
|
||||
librarySummaryLabel.Text = ex is ServerTooOldException
|
||||
? ex.Message
|
||||
: $"Could not load the drawing library: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
UpdateLibraryNavigation();
|
||||
// A request that was declined or superseded renders nothing of its own.
|
||||
if (!librarySession.IsLoading && librarySummaryLabel.Text == LoadingText)
|
||||
librarySummaryLabel.Text = librarySession.Summary;
|
||||
UpdateAddCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
private Task RunLibraryAsync(Func<Task<bool>> request) => libraryPager.RunAsync(request);
|
||||
|
||||
private void PopulateLibrary(DrawingLibraryPage page)
|
||||
{
|
||||
@@ -189,13 +154,7 @@ namespace OpenNest.Forms
|
||||
libraryGrid.Rows[index].Tag = record;
|
||||
}
|
||||
|
||||
foreach (DataGridViewColumn column in libraryGrid.Columns)
|
||||
{
|
||||
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSortField field && field == librarySession.Sort
|
||||
? librarySession.Descending ? SortOrder.Descending : SortOrder.Ascending
|
||||
: SortOrder.None;
|
||||
}
|
||||
|
||||
libraryPager.ShowSortGlyphs();
|
||||
MarkListed();
|
||||
UpdateAddCommand();
|
||||
}
|
||||
@@ -211,12 +170,6 @@ namespace OpenNest.Forms
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLibraryNavigation()
|
||||
{
|
||||
previousButton.Enabled = !librarySession.IsLoading && librarySession.CanGoPrevious;
|
||||
nextButton.Enabled = !librarySession.IsLoading && librarySession.CanGoNext;
|
||||
}
|
||||
|
||||
private void UpdateAddCommand() => addButton.Enabled = SelectedDrawings().Count > 0;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ namespace OpenNest.Forms
|
||||
DropDownStyle = ComboBoxStyle.DropDownList;
|
||||
Width = 200;
|
||||
|
||||
var machines = Sorted(MachineSettings.LoadMachines());
|
||||
var machines = SortedByName(MachineSettings.LoadMachines());
|
||||
Items.Add(new Choice(null));
|
||||
foreach (var machine in machines)
|
||||
Items.Add(new Choice(machine));
|
||||
@@ -36,13 +36,13 @@ namespace OpenNest.Forms
|
||||
SelectedItem = Items.Cast<Choice>().First(c => c.Machine == saved);
|
||||
}
|
||||
|
||||
private static List<MachineConfig> Sorted(IEnumerable<MachineConfig> machines) =>
|
||||
private static List<MachineConfig> SortedByName(IEnumerable<MachineConfig> machines) =>
|
||||
machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
|
||||
|
||||
/// <summary>The remembered machine, the first one until a choice is saved, or null for full speed.</summary>
|
||||
public static MachineConfig SavedMachine(IEnumerable<MachineConfig> machines)
|
||||
{
|
||||
var sorted = Sorted(machines);
|
||||
var sorted = SortedByName(machines);
|
||||
var saved = Properties.Settings.Default.CutTimeMachine;
|
||||
return saved == ConstantSpeed
|
||||
? null
|
||||
|
||||
@@ -30,7 +30,6 @@ namespace OpenNest.Forms
|
||||
/// </summary>
|
||||
public partial class DrawingLibraryForm : Form
|
||||
{
|
||||
private const string LoadingText = "Loading...";
|
||||
private const int DetailsWidth = 380;
|
||||
private const int PreviewCacheLimit = 64;
|
||||
private const int MaxQuantity = 99999;
|
||||
@@ -40,6 +39,7 @@ namespace OpenNest.Forms
|
||||
private readonly Nest nest;
|
||||
private readonly IDrawingSetStore sets;
|
||||
private readonly DrawingLibraryBrowseSession session;
|
||||
private readonly BrowsePager<DrawingLibraryPage, DrawingSortField> pager;
|
||||
private readonly Dictionary<(Guid Id, int Revision), Drawing> previews = new();
|
||||
private CancellationTokenSource previewCancel;
|
||||
private (Guid Id, int Revision) previewKey;
|
||||
@@ -103,6 +103,13 @@ namespace OpenNest.Forms
|
||||
this.nest = nest ?? throw new ArgumentNullException(nameof(nest));
|
||||
this.sets = sets;
|
||||
session = new DrawingLibraryBrowseSession(library, nest);
|
||||
Disposed += (_, _) => session.Dispose();
|
||||
pager = new BrowsePager<DrawingLibraryPage, DrawingSortField>(
|
||||
session, drawingGrid, summaryLabel, previousButton, nextButton, "the drawing library")
|
||||
{
|
||||
Populate = Populate,
|
||||
Settled = UpdateCommands,
|
||||
};
|
||||
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
|
||||
EditDrawing = edit =>
|
||||
{
|
||||
@@ -147,7 +154,7 @@ namespace OpenNest.Forms
|
||||
drawingGrid.ContextMenuStrip = drawingMenu;
|
||||
showAllCheckBox.Enabled = session.IsFilteredToNest;
|
||||
UpdateFilterLabel();
|
||||
UpdateNavigation();
|
||||
pager.UpdateNavigation();
|
||||
ShowDetails();
|
||||
RefreshNestTab();
|
||||
}
|
||||
@@ -670,38 +677,7 @@ namespace OpenNest.Forms
|
||||
ShowMessage(message, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
|
||||
private async Task RunAsync(Func<Task<bool>> request)
|
||||
{
|
||||
summaryLabel.Text = LoadingText;
|
||||
try
|
||||
{
|
||||
var started = request();
|
||||
UpdateNavigation();
|
||||
if (await started && !IsDisposed)
|
||||
Populate(session.Page);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
drawingGrid.Rows.Clear();
|
||||
summaryLabel.Text = ex is ServerTooOldException
|
||||
? ex.Message
|
||||
: $"Could not load the drawing library: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
UpdateNavigation();
|
||||
// A request that was declined or superseded renders nothing of its own.
|
||||
if (!session.IsLoading && summaryLabel.Text == LoadingText)
|
||||
summaryLabel.Text = session.Summary;
|
||||
UpdateCommands();
|
||||
}
|
||||
}
|
||||
}
|
||||
private Task RunAsync(Func<Task<bool>> request) => pager.RunAsync(request);
|
||||
|
||||
private void Populate(DrawingLibraryPage page)
|
||||
{
|
||||
@@ -721,25 +697,13 @@ namespace OpenNest.Forms
|
||||
drawingGrid.Rows[index].Tag = record;
|
||||
}
|
||||
|
||||
foreach (DataGridViewColumn column in drawingGrid.Columns)
|
||||
{
|
||||
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSortField field && field == session.Sort
|
||||
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
|
||||
: SortOrder.None;
|
||||
}
|
||||
|
||||
pager.ShowSortGlyphs();
|
||||
ShowDetails();
|
||||
}
|
||||
|
||||
private DrawingLibraryRecord SelectedRecord() =>
|
||||
drawingGrid.CurrentRow?.Tag as DrawingLibraryRecord;
|
||||
|
||||
private void UpdateNavigation()
|
||||
{
|
||||
previousButton.Enabled = !session.IsLoading && session.CanGoPrevious;
|
||||
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
|
||||
}
|
||||
|
||||
private void UpdateCommands()
|
||||
{
|
||||
addButton.Enabled = !adding && SelectedRecord() != null;
|
||||
|
||||
@@ -23,10 +23,9 @@ namespace OpenNest.Forms
|
||||
/// </summary>
|
||||
public partial class EditDrawingSetForm : Form
|
||||
{
|
||||
private const string LoadingText = "Loading...";
|
||||
|
||||
private readonly DrawingSetEditSession session;
|
||||
private readonly DrawingLibraryBrowseSession librarySession;
|
||||
private readonly BrowsePager<DrawingLibraryPage, DrawingSortField> libraryPager;
|
||||
private bool loading;
|
||||
private bool saving;
|
||||
|
||||
@@ -44,6 +43,12 @@ namespace OpenNest.Forms
|
||||
// A set mixes materials and thicknesses, so the library is never filtered to a nest.
|
||||
librarySession = new DrawingLibraryBrowseSession(library, new Nest());
|
||||
Disposed += (_, _) => librarySession.Dispose();
|
||||
libraryPager = new BrowsePager<DrawingLibraryPage, DrawingSortField>(
|
||||
librarySession, libraryGrid, librarySummaryLabel, previousButton, nextButton, "the drawing library")
|
||||
{
|
||||
Populate = PopulateLibrary,
|
||||
Settled = UpdateAddCommand,
|
||||
};
|
||||
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
|
||||
|
||||
GridStyle.Apply(memberGrid);
|
||||
@@ -217,51 +222,10 @@ namespace OpenNest.Forms
|
||||
: $"{added} Already in the set: {string.Join(", ", result.AlreadyMembers.Select(d => $"'{d.Name}'"))}.";
|
||||
}
|
||||
|
||||
// Every selected row in list order; the current row alone when nothing is selected.
|
||||
private IReadOnlyList<DrawingLibraryRecord> SelectedLibraryDrawings()
|
||||
{
|
||||
var picked = libraryGrid.SelectedRows.Cast<DataGridViewRow>()
|
||||
.OrderBy(row => row.Index)
|
||||
.Select(row => row.Tag as DrawingLibraryRecord)
|
||||
.Where(record => record != null)
|
||||
.ToList();
|
||||
if (picked.Count == 0 && libraryGrid.CurrentRow?.Tag is DrawingLibraryRecord current)
|
||||
picked.Add(current);
|
||||
return picked;
|
||||
}
|
||||
private IReadOnlyList<DrawingLibraryRecord> SelectedLibraryDrawings() =>
|
||||
libraryPager.SelectedRecords<DrawingLibraryRecord>();
|
||||
|
||||
private async Task RunLibraryAsync(Func<Task<bool>> request)
|
||||
{
|
||||
librarySummaryLabel.Text = LoadingText;
|
||||
try
|
||||
{
|
||||
var started = request();
|
||||
UpdateLibraryNavigation();
|
||||
if (await started && !IsDisposed)
|
||||
PopulateLibrary(librarySession.Page);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
libraryGrid.Rows.Clear();
|
||||
librarySummaryLabel.Text = ex is ServerTooOldException
|
||||
? ex.Message
|
||||
: $"Could not load the drawing library: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
UpdateLibraryNavigation();
|
||||
// A request that was declined or superseded renders nothing of its own.
|
||||
if (!librarySession.IsLoading && librarySummaryLabel.Text == LoadingText)
|
||||
librarySummaryLabel.Text = librarySession.Summary;
|
||||
UpdateAddCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
private Task RunLibraryAsync(Func<Task<bool>> request) => libraryPager.RunAsync(request);
|
||||
|
||||
private void PopulateLibrary(DrawingLibraryPage page)
|
||||
{
|
||||
@@ -277,13 +241,7 @@ namespace OpenNest.Forms
|
||||
libraryGrid.Rows[index].Tag = record;
|
||||
}
|
||||
|
||||
foreach (DataGridViewColumn column in libraryGrid.Columns)
|
||||
{
|
||||
column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSortField field && field == librarySession.Sort
|
||||
? librarySession.Descending ? SortOrder.Descending : SortOrder.Ascending
|
||||
: SortOrder.None;
|
||||
}
|
||||
|
||||
libraryPager.ShowSortGlyphs();
|
||||
MarkMembersInLibrary();
|
||||
UpdateAddCommand();
|
||||
}
|
||||
@@ -300,12 +258,6 @@ namespace OpenNest.Forms
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLibraryNavigation()
|
||||
{
|
||||
previousButton.Enabled = !librarySession.IsLoading && librarySession.CanGoPrevious;
|
||||
nextButton.Enabled = !librarySession.IsLoading && librarySession.CanGoNext;
|
||||
}
|
||||
|
||||
private void UpdateAddCommand() =>
|
||||
addButton.Enabled = !saving && SelectedLibraryDrawings().Count > 0;
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ public sealed class SavedNestsForm : Form
|
||||
private const string IdColumn = "Id";
|
||||
private const string PlateSizeColumn = "PlateSize";
|
||||
private const string AreaColumn = "Area";
|
||||
private const string LoadingText = "Loading...";
|
||||
private const string LoadingDetailsText = "Loading details...";
|
||||
private const string WindowTitle = "Open Nest — Database";
|
||||
|
||||
@@ -40,6 +39,7 @@ public sealed class SavedNestsForm : Form
|
||||
private readonly INestRepository repository;
|
||||
private readonly string serverUrl;
|
||||
private readonly NestBrowseSession session;
|
||||
private readonly BrowsePager<NestPage, NestSortField> pager;
|
||||
private NestDetailsSession details;
|
||||
private readonly SplitContainer split;
|
||||
private readonly DataGridView nestGrid;
|
||||
@@ -289,6 +289,12 @@ public sealed class SavedNestsForm : Form
|
||||
nextButton = new Button { Text = "\u25B6", Width = 32, Enabled = false };
|
||||
nextButton.Click += async (_, _) => await RunAsync(session.NextPageAsync);
|
||||
summaryLabel = new Label { AutoSize = true, Margin = new Padding(12, 8, 3, 0) };
|
||||
pager = new BrowsePager<NestPage, NestSortField>(
|
||||
session, nestGrid, summaryLabel, previousButton, nextButton, "nests from the server")
|
||||
{
|
||||
Populate = Populate,
|
||||
Failed = () => UpdateTitle(""),
|
||||
};
|
||||
|
||||
var findBar = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = false };
|
||||
findBar.Controls.Add(new Label { Text = "Find:", AutoSize = true, Margin = new Padding(3, 8, 0, 0) });
|
||||
@@ -429,42 +435,7 @@ public sealed class SavedNestsForm : Form
|
||||
/// renders nothing; a failure clears the rows and shows the error in the summary line.
|
||||
/// Navigation follows only the latest request (<see cref="NestBrowseSession.IsLoading"/>).
|
||||
/// </summary>
|
||||
private async Task RunAsync(Func<Task<bool>> request)
|
||||
{
|
||||
summaryLabel.Text = LoadingText;
|
||||
try
|
||||
{
|
||||
var started = request();
|
||||
UpdateNavigation();
|
||||
if (await started && !IsDisposed)
|
||||
Populate(session.Page);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
nestGrid.Rows.Clear();
|
||||
summaryLabel.Text = $"Could not load nests from the server: {ex.Message}";
|
||||
UpdateTitle("");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
UpdateNavigation();
|
||||
// A request that was declined or superseded renders nothing of its own.
|
||||
if (!session.IsLoading && summaryLabel.Text == LoadingText)
|
||||
summaryLabel.Text = session.Summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNavigation()
|
||||
{
|
||||
previousButton.Enabled = !session.IsLoading && session.CanGoPrevious;
|
||||
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
|
||||
}
|
||||
private Task RunAsync(Func<Task<bool>> request) => pager.RunAsync(request);
|
||||
|
||||
private void UpdateCommands()
|
||||
{
|
||||
@@ -509,12 +480,7 @@ public sealed class SavedNestsForm : Form
|
||||
nestGrid.Rows[index].HeaderCell.Value = (page.Offset + i + 1).ToString("N0", CultureInfo.CurrentCulture);
|
||||
}
|
||||
|
||||
foreach (DataGridViewColumn column in nestGrid.Columns)
|
||||
{
|
||||
column.HeaderCell.SortGlyphDirection = column.Tag is NestSortField field && field == session.Sort
|
||||
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
|
||||
: SortOrder.None;
|
||||
}
|
||||
pager.ShowSortGlyphs();
|
||||
|
||||
if (nestGrid.Rows.Count > 0 && nestGrid.CurrentCell == null)
|
||||
nestGrid.CurrentCell = nestGrid.Rows[0].Cells[0];
|
||||
|
||||
Reference in new issue
Block a user