mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 03:25:07 -05:00
Finding a part on the nest server meant scrolling 40k drawings: the library could only match an exact thickness or customer and knew nothing about a drawing's size or holes. The library browser gets a Filter dialog with inclusive ranges, and the query API takes them. The server never parses packages and stays off IO, so the server worker now measures each package beside its nest jobs (bounding sides, net area, closed cutouts inside the outline) and stores the summary keyed by package hash (schema v19). Identical geometry is measured once, an unreadable package is recorded once instead of retried forever, and size or hole limits match only measured drawings. Thickness ranges compare in inches across units and keep the library's rule that a drawing saved without a thickness suits any. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
12 KiB
C#
339 lines
12 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using OpenNest;
|
|
using OpenNest.Data;
|
|
|
|
namespace OpenNest.Server;
|
|
|
|
/// <summary>
|
|
/// Parses drawing library requests. Unknown, repeated or out-of-range values are
|
|
/// rejected rather than ignored or clamped, and enum fields accept names only.
|
|
/// </summary>
|
|
internal static class DrawingLibraryRequest
|
|
{
|
|
private static readonly string[] QueryKeys =
|
|
{
|
|
"search", "customer", "materialName", "materialGrade", "thickness", "cutType", "cutFirst",
|
|
"includeArchived", "sort", "order", "offset", "limit",
|
|
"minThickness", "maxThickness", "includeBlankThickness", "minLength", "maxLength", "minWidth", "maxWidth",
|
|
"minHoles", "maxHoles", "savedFrom", "savedTo",
|
|
};
|
|
|
|
// Integer enum values are refused; names are additionally checked before deserializing.
|
|
private static readonly JsonSerializerOptions MetadataOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) },
|
|
};
|
|
|
|
public static bool TryParseQuery(IQueryCollection values, out DrawingLibraryQuery query, out string error)
|
|
{
|
|
query = new DrawingLibraryQuery();
|
|
foreach (var key in values.Keys)
|
|
{
|
|
if (!QueryKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
error = $"Unknown query parameter '{key}'.";
|
|
return false;
|
|
}
|
|
|
|
if (values[key].Count > 1)
|
|
{
|
|
error = $"Query parameter '{key}' was given more than once.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var sort = DrawingSortField.SavedAt;
|
|
if (values.TryGetValue("sort", out var sortText) && !TryEnumName(sortText.ToString(), out sort))
|
|
{
|
|
error = $"sort must be one of: {string.Join(", ", Enum.GetNames<DrawingSortField>().Select(CamelCase))}.";
|
|
return false;
|
|
}
|
|
|
|
var descending = true;
|
|
if (values.TryGetValue("order", out var orderText))
|
|
{
|
|
if (string.Equals(orderText, "asc", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
descending = false;
|
|
}
|
|
else if (!string.Equals(orderText, "desc", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
error = "order must be asc or desc.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
CutType? cutType = null;
|
|
if (values.TryGetValue("cutType", out var cutTypeText))
|
|
{
|
|
if (!TryEnumName<CutType>(cutTypeText.ToString(), out var parsed))
|
|
{
|
|
error = "cutType must be Laser, Waterjet or Plasma.";
|
|
return false;
|
|
}
|
|
|
|
cutType = parsed;
|
|
}
|
|
|
|
double? thickness = null;
|
|
if (values.TryGetValue("thickness", out var thicknessText))
|
|
{
|
|
if (!double.TryParse(thicknessText.ToString(), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var parsed)
|
|
|| !double.IsFinite(parsed))
|
|
{
|
|
error = "thickness must be a finite number, zero or greater.";
|
|
return false;
|
|
}
|
|
|
|
thickness = parsed;
|
|
}
|
|
|
|
var includeArchived = false;
|
|
if (values.TryGetValue("includeArchived", out var archivedText) && !bool.TryParse(archivedText.ToString(), out includeArchived))
|
|
{
|
|
error = "includeArchived must be true or false.";
|
|
return false;
|
|
}
|
|
|
|
bool? cutFirst = null;
|
|
if (values.TryGetValue("cutFirst", out var cutFirstText))
|
|
{
|
|
if (!bool.TryParse(cutFirstText.ToString(), out var parsed))
|
|
{
|
|
error = "cutFirst must be true or false.";
|
|
return false;
|
|
}
|
|
|
|
cutFirst = parsed;
|
|
}
|
|
|
|
var offset = 0;
|
|
var limit = DrawingLibraryQuery.DefaultLimit;
|
|
if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error))
|
|
return false;
|
|
|
|
if (!TryReadFilter(values, out var filter, out error))
|
|
return false;
|
|
|
|
query = new DrawingLibraryQuery
|
|
{
|
|
Search = values["search"].ToString(),
|
|
Customer = values["customer"].ToString(),
|
|
MaterialName = values["materialName"].ToString(),
|
|
MaterialGrade = values["materialGrade"].ToString(),
|
|
Thickness = thickness,
|
|
CutType = cutType,
|
|
CutFirst = cutFirst,
|
|
IncludeArchived = includeArchived,
|
|
Sort = sort,
|
|
Descending = descending,
|
|
Offset = offset,
|
|
Limit = limit,
|
|
Filter = filter,
|
|
};
|
|
error = query.GetValidationError() ?? "";
|
|
return error.Length == 0;
|
|
}
|
|
|
|
// Thickness and sizes are inches; holes whole numbers; dates yyyy-MM-dd. The customer stays on the query.
|
|
private static bool TryReadFilter(IQueryCollection values, out DrawingLibraryFilter filter, out string error)
|
|
{
|
|
filter = DrawingLibraryFilter.None;
|
|
double? minThickness = null, maxThickness = null, minLength = null, maxLength = null, minWidth = null, maxWidth = null;
|
|
int? minHoles = null, maxHoles = null;
|
|
DateOnly? savedFrom = null, savedTo = null;
|
|
var includeBlank = true;
|
|
if (!TryNumber(values, "minThickness", ref minThickness, out error) || !TryNumber(values, "maxThickness", ref maxThickness, out error)
|
|
|| !TryNumber(values, "minLength", ref minLength, out error) || !TryNumber(values, "maxLength", ref maxLength, out error)
|
|
|| !TryNumber(values, "minWidth", ref minWidth, out error) || !TryNumber(values, "maxWidth", ref maxWidth, out error)
|
|
|| !TryCount(values, "minHoles", ref minHoles, out error) || !TryCount(values, "maxHoles", ref maxHoles, out error)
|
|
|| !TryDate(values, "savedFrom", ref savedFrom, out error) || !TryDate(values, "savedTo", ref savedTo, out error))
|
|
return false;
|
|
|
|
if (values.TryGetValue("includeBlankThickness", out var blankText) && !bool.TryParse(blankText.ToString(), out includeBlank))
|
|
{
|
|
error = "includeBlankThickness must be true or false.";
|
|
return false;
|
|
}
|
|
|
|
filter = new DrawingLibraryFilter
|
|
{
|
|
MinThickness = minThickness,
|
|
MaxThickness = maxThickness,
|
|
IncludeBlankThickness = includeBlank,
|
|
MinLength = minLength,
|
|
MaxLength = maxLength,
|
|
MinWidth = minWidth,
|
|
MaxWidth = maxWidth,
|
|
MinHoles = minHoles,
|
|
MaxHoles = maxHoles,
|
|
SavedFrom = savedFrom,
|
|
SavedTo = savedTo,
|
|
};
|
|
return true;
|
|
}
|
|
|
|
private static bool TryNumber(IQueryCollection values, string key, ref double? value, out string error)
|
|
{
|
|
error = "";
|
|
if (!values.TryGetValue(key, out var text))
|
|
return true;
|
|
if (double.TryParse(text.ToString(), NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var parsed)
|
|
&& double.IsFinite(parsed))
|
|
{
|
|
value = parsed;
|
|
return true;
|
|
}
|
|
|
|
error = $"{key} must be a finite number, zero or greater.";
|
|
return false;
|
|
}
|
|
|
|
private static bool TryCount(IQueryCollection values, string key, ref int? value, out string error)
|
|
{
|
|
var parsed = 0;
|
|
if (!values.ContainsKey(key))
|
|
{
|
|
error = "";
|
|
return true;
|
|
}
|
|
if (!TryInteger(values, key, ref parsed, out error))
|
|
return false;
|
|
value = parsed;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryDate(IQueryCollection values, string key, ref DateOnly? value, out string error)
|
|
{
|
|
error = "";
|
|
if (!values.TryGetValue(key, out var text))
|
|
return true;
|
|
if (DateOnly.TryParseExact(text.ToString(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
|
{
|
|
value = parsed;
|
|
return true;
|
|
}
|
|
|
|
error = $"{key} must be a date written yyyy-MM-dd.";
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Reads and validates one metadata JSON object.</summary>
|
|
public static bool TryReadMetadata(string? json, out DrawingLibraryRecord record, out string error)
|
|
{
|
|
record = new DrawingLibraryRecord();
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
error = "Missing metadata.";
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(json);
|
|
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
|
{
|
|
error = "Metadata must be a JSON object.";
|
|
return false;
|
|
}
|
|
|
|
// Enum converters also accept comma-separated combinations; allow single names only.
|
|
foreach (var property in document.RootElement.EnumerateObject())
|
|
{
|
|
if (string.Equals(property.Name, "cutType", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (property.Value.ValueKind != JsonValueKind.String || !TryEnumName<CutType>(property.Value.GetString()!, out _))
|
|
{
|
|
error = "cutType must be Laser, Waterjet or Plasma.";
|
|
return false;
|
|
}
|
|
}
|
|
else if (string.Equals(property.Name, "units", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (property.Value.ValueKind != JsonValueKind.String || !TryEnumName<Units>(property.Value.GetString()!, out _))
|
|
{
|
|
error = "units must be a supported unit name.";
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
record = document.RootElement.Deserialize<DrawingLibraryRecord>(MetadataOptions) ?? record;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
error = "Invalid metadata JSON.";
|
|
return false;
|
|
}
|
|
|
|
error = DrawingLibraryRules.GetMetadataError(record) ?? "";
|
|
return error.Length == 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads history-style paging: only <c>offset</c> (zero or greater) and <c>limit</c>
|
|
/// (1 to <see cref="DrawingLibraryRules.MaxRevisionLimit"/>), each at most once.
|
|
/// </summary>
|
|
public static bool TryReadPaging(IQueryCollection values, out int offset, out int limit, out string error)
|
|
{
|
|
offset = 0;
|
|
limit = DrawingLibraryRules.DefaultRevisionLimit;
|
|
error = "";
|
|
foreach (var parameter in values)
|
|
{
|
|
if (parameter.Value.Count != 1
|
|
|| !int.TryParse(parameter.Value[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value))
|
|
{
|
|
error = "Invalid history pagination.";
|
|
return false;
|
|
}
|
|
|
|
switch (parameter.Key)
|
|
{
|
|
case "offset": offset = value; break;
|
|
case "limit": limit = value; break;
|
|
default:
|
|
error = "Unknown history parameter.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
|
|
{
|
|
error = "Invalid history pagination.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Names only: Enum.TryParse would also accept numbers, combinations and undefined values.
|
|
internal static bool TryEnumName<TEnum>(string text, out TEnum value)
|
|
where TEnum : struct, Enum
|
|
{
|
|
value = default;
|
|
return text.Length > 0
|
|
&& text.All(char.IsAsciiLetter)
|
|
&& Enum.TryParse(text, ignoreCase: true, out value)
|
|
&& Enum.IsDefined(value);
|
|
}
|
|
|
|
internal static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..];
|
|
|
|
internal 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;
|
|
}
|
|
}
|