using OpenNest;
namespace OpenNest.Data;
public enum SetReleaseGroupOutcome
{
/// The group's nest is on the server; is its record.
Created,
/// The group could not be built; nothing was uploaded for any group.
Refused,
/// Building or uploading the group failed; says how.
Failed,
/// An earlier group stopped the release, or it was canceled, before this group was reached.
NotStarted,
}
/// What happened to one chosen group of a release.
public sealed class SetReleaseGroupResult
{
internal SetReleaseGroupResult(SetReleaseGroup group)
{
Group = group;
}
public SetReleaseGroup Group { get; }
public SetReleaseGroupOutcome Outcome { get; internal set; } = SetReleaseGroupOutcome.NotStarted;
///
/// The nest as built, held so a created one can be opened without downloading it again.
/// Set for a created nest and for one whose upload failed; null otherwise.
///
public Nest? Nest { get; internal set; }
/// The server record of a created nest: its id and revision.
public NestRecord? Record { get; internal set; }
/// Why the group was refused or failed; empty otherwise.
public string Error { get; internal set; } = "";
/// The plan's warnings plus any the library inserts raised.
public IReadOnlyList Warnings { get; internal set; } = Array.Empty();
}
public enum SetReleaseStage
{
/// Downloading the group's drawings and building its nest; nothing is uploaded yet.
Preparing,
/// Uploading the group's nest.
Uploading,
/// Every nest is uploaded; the release record is being posted.
Recording,
}
/// Where a release has got to. is null while recording.
public sealed class SetReleaseProgress
{
public SetReleaseProgress(SetReleaseStage stage, int index, int count, SetReleaseGroup? group)
{
Stage = stage;
Index = index;
Count = count;
Group = group;
}
public SetReleaseStage Stage { get; }
/// Zero-based position of among the chosen groups.
public int Index { get; }
public int Count { get; }
public SetReleaseGroup? Group { get; }
}
/// Every chosen group's outcome, and the release record when all of them were created.
public sealed class SetReleaseResult
{
internal SetReleaseResult(IReadOnlyList groups, bool canceled,
SetReleaseRecord? release, string releaseError)
{
Groups = groups;
Canceled = canceled;
Release = release;
ReleaseError = releaseError;
}
/// The chosen groups in plan order.
public IReadOnlyList Groups { get; }
/// The nests now on the server, whether or not the release was recorded.
public IReadOnlyList Created =>
Groups.Where(g => g.Outcome == SetReleaseGroupOutcome.Created).ToList();
/// Cancellation stopped the release between groups.
public bool Canceled { get; }
/// The stored release; null unless every chosen group was created and the post succeeded.
public SetReleaseRecord? Release { get; }
///
/// Why posting the release failed after every nest was created; empty otherwise.
/// posts the same release again.
///
public string ReleaseError { get; }
/// This result once has recorded .
public SetReleaseResult WithRelease(SetReleaseRecord release) =>
new(Groups, Canceled, release ?? throw new ArgumentNullException(nameof(release)), "");
}
///
/// Releases a drawing set: one new nest per chosen group of a ,
/// each built through from the members' exact library
/// revisions, uploaded as a , and only then recorded as one
/// release (S7).
///
/// Every group is built before any is uploaded, so a refusal (a package that no longer
/// matches its record, units the new nest does not use, an insert refusal) creates
/// nothing. Uploads then run in order; the first failure stops them, and cancellation is
/// honoured between uploads, never during one. Nests already uploaded stay as ordinary
/// nests and are listed in the result; the release is posted only when every chosen group
/// was created. Nothing is rolled back.
///
///
public sealed class SetReleaseSession
{
private readonly IDrawingSetStore _sets;
private readonly IDrawingLibrary _library;
private readonly INestRepository _nests;
private readonly string _serverUrl;
private readonly Func _readPackage;
private readonly Func _writeNest;
private readonly Func> _createNest;
private bool _started;
private SetReleaseRecord? _pendingRelease;
/// Reads a downloaded library package (the desktop uses DrawingPackage.Read).
/// Serializes a nest archive for upload (the desktop uses NestWriter).
///
/// Makes an empty nest for a group, named and set up as File > New would, in the group's
/// units. The session then sets its material, grade, thickness, customer and status.
///
public SetReleaseSession(IDrawingSetStore sets, IDrawingLibrary library, INestRepository nests, string serverUrl,
Func readPackage, Func writeNest,
Func> createNest)
{
_sets = sets ?? throw new ArgumentNullException(nameof(sets));
_library = library ?? throw new ArgumentNullException(nameof(library));
_nests = nests ?? throw new ArgumentNullException(nameof(nests));
_readPackage = readPackage ?? throw new ArgumentNullException(nameof(readPackage));
_writeNest = writeNest ?? throw new ArgumentNullException(nameof(writeNest));
_createNest = createNest ?? throw new ArgumentNullException(nameof(createNest));
if (ServerUrl.Key(serverUrl).Length == 0)
throw new ArgumentException("A set release needs the nest server URL.", nameof(serverUrl));
_serverUrl = serverUrl;
}
/// Every nest was created but the release was not recorded; may post it.
public bool HasPendingRelease => _pendingRelease != null;
/// Releases , all from . Call once per session.
public async Task RunAsync(SetReleasePlan plan, IReadOnlyCollection groups,
string releasedBy, string notes, IProgress? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(groups);
if (groups.Count == 0)
throw new ArgumentException("Choose at least one group to release.", nameof(groups));
if (groups.Any(g => g is null || !plan.Groups.Contains(g)) || groups.Distinct().Count() != groups.Count)
throw new ArgumentException("Every group must come from the plan, once.", nameof(groups));
if (groups.FirstOrDefault(g => !g.CanRelease) is { } refused)
throw new ArgumentException(refused.Refusal, nameof(groups));
var textError = DrawingLibraryRules.TextError("releasedBy", releasedBy ?? "", DrawingLibraryRules.MaxTextLength)
?? DrawingLibraryRules.TextError("notes", notes ?? "", DrawingLibraryRules.MaxNotesLength);
if (textError is not null)
throw new ArgumentException(textError);
if (_started)
throw new InvalidOperationException("This release session has already run.");
_started = true;
var results = plan.Groups.Where(groups.Contains).Select(g => new SetReleaseGroupResult(g)).ToList();
// Build every nest first: a refusal here leaves nothing on the server.
for (var i = 0; i < results.Count; i++)
{
var result = results[i];
progress?.Report(new SetReleaseProgress(SetReleaseStage.Preparing, i, results.Count, result.Group));
bool ready;
try
{
ready = await PrepareAsync(plan, result, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
DiscardBuilt(results);
return new SetReleaseResult(results, canceled: true, null, "");
}
catch (Exception ex) when (ex is IOException or HttpRequestException or InvalidDataException
or System.Text.Json.JsonException or TimeoutException)
{
result.Outcome = SetReleaseGroupOutcome.Failed;
result.Error = $"Could not prepare the {Describe(result.Group)} nest: {ex.Message}";
ready = false;
}
if (!ready)
{
DiscardBuilt(results);
return new SetReleaseResult(results, canceled: false, null, "");
}
}
for (var i = 0; i < results.Count; i++)
{
if (cancellationToken.IsCancellationRequested)
{
DiscardBuilt(results);
return new SetReleaseResult(results, canceled: true, null, "");
}
progress?.Report(new SetReleaseProgress(SetReleaseStage.Uploading, i, results.Count, results[i].Group));
if (!await UploadAsync(results[i]))
{
DiscardBuilt(results);
return new SetReleaseResult(results, canceled: false, null, "");
}
}
_pendingRelease = new SetReleaseRecord
{
Id = Guid.NewGuid(),
SetId = plan.Set.Id,
SetRevision = plan.Set.Revision,
Multiplier = plan.Multiplier,
Notes = notes ?? "",
ReleasedBy = releasedBy ?? "",
Nests = results.Select(r => new SetReleaseNest
{
NestId = r.Record!.Id,
MaterialName = r.Group.MaterialName,
MaterialGrade = r.Group.MaterialGrade,
Thickness = r.Group.Thickness,
Units = r.Group.Units,
CutType = r.Group.CutType,
}).ToList(),
};
progress?.Report(new SetReleaseProgress(SetReleaseStage.Recording, results.Count, results.Count, null));
try
{
return new SetReleaseResult(results, canceled: false, await PostPendingAsync(CancellationToken.None), "");
}
catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException
or OperationCanceledException)
{
return new SetReleaseResult(results, canceled: false, null,
$"Every nest was created, but the release was not recorded: {ex.Message}");
}
}
///
/// Posts the release again after , under the
/// same id, so a post that was in fact stored is not recorded twice.
///
public Task RetryReleaseAsync(CancellationToken cancellationToken = default)
{
if (_pendingRelease == null)
throw new InvalidOperationException("There is no unrecorded release to post.");
return PostPendingAsync(cancellationToken);
}
// Builds the group's nest in memory; false when the group is refused.
private async Task PrepareAsync(SetReleasePlan plan, SetReleaseGroupResult result, CancellationToken cancellationToken)
{
var group = result.Group;
var nest = await _createNest(group, cancellationToken)
?? throw new InvalidOperationException("The nest factory returned no nest.");
if (nest.Units != group.Units)
return Refuse(result, $"The new nest for {Describe(group)} is in {Name(nest.Units)}, not {Name(group.Units)}.");
var current = nest.Material;
nest.Material = current != null && SharedListNames.Key(current.Name) == SharedListNames.Key(group.MaterialName)
&& SharedListNames.Key(current.Grade) == SharedListNames.Key(group.MaterialGrade)
? new Material(group.MaterialName, group.MaterialGrade, current.Density)
: new Material(group.MaterialName, group.MaterialGrade);
nest.Thickness = group.Thickness;
nest.Status = NestStatus.Draft;
if (!string.IsNullOrWhiteSpace(plan.Set.Customer))
nest.Customer = plan.Set.Customer;
var warnings = group.Warnings.ToList();
foreach (var item in group.Items)
{
var record = item.Drawing;
var bytes = await _library.GetRevisionPackageAsync(record.Id, record.Revision, cancellationToken);
if (bytes == null)
return Refuse(result, $"'{record.Name}' revision {record.Revision} is no longer in the library. Reload the set.");
var package = await Task.Run(() => _readPackage(bytes), cancellationToken);
var insert = LibraryDrawingInsert.Plan(nest, _serverUrl, record, package, item.Quantity);
if (insert.IsRefused)
return Refuse(result, insert.Refusal);
warnings.AddRange(insert.Warnings);
insert.Apply();
}
result.Nest = nest;
result.Warnings = warnings;
return true;
}
// True when the nest is on the server. A failed upload is checked by its id, which no
// other client can hold, so a nest stored before its reply was lost is still reported.
private async Task UploadAsync(SetReleaseGroupResult result)
{
var nest = result.Nest!;
var id = Guid.NewGuid();
Exception failure;
try
{
var archive = await Task.Run(() => _writeNest(nest));
var saved = await _nests.UploadAsync(archive, NestRecordFactory.FromNest(nest, id, archive.LongLength),
CancellationToken.None);
if (saved.Id == id)
return Created(result, saved);
failure = new IOException($"The nest server stored it under a different id ({saved.Id}).");
}
catch (Exception ex)
{
failure = ex;
}
string stored;
try
{
if (await _nests.GetMetadataAsync(id, CancellationToken.None) is { } found && found.Id == id)
return Created(result, found);
stored = "It was not saved.";
}
catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException
or OperationCanceledException)
{
stored = $"It may have been saved as '{nest.Name}' (id {id}); check Open from Database before releasing again.";
}
result.Outcome = SetReleaseGroupOutcome.Failed;
result.Error = $"Could not upload the {Describe(result.Group)} nest: {failure.Message} {stored}";
return false;
}
private async Task PostPendingAsync(CancellationToken cancellationToken)
{
var release = _pendingRelease!;
SetReleaseRecord stored;
try
{
stored = await _sets.PostReleaseAsync(release.SetId, release, cancellationToken);
}
catch (SetReleasePostConflictException conflict) when (conflict.Current.Id == release.Id)
{
// An earlier post whose reply was lost was stored.
stored = conflict.Current;
}
_pendingRelease = null;
return stored;
}
private static bool Created(SetReleaseGroupResult result, NestRecord record)
{
result.Outcome = SetReleaseGroupOutcome.Created;
result.Record = record;
return true;
}
private static bool Refuse(SetReleaseGroupResult result, string reason)
{
result.Outcome = SetReleaseGroupOutcome.Refused;
result.Error = reason;
return false;
}
// Only nests on the server, or whose upload failed, are kept for the caller to show.
private static void DiscardBuilt(IEnumerable results)
{
foreach (var result in results.Where(r => r.Outcome is SetReleaseGroupOutcome.NotStarted or SetReleaseGroupOutcome.Refused))
result.Nest = null;
}
private static string Describe(SetReleaseGroup group) =>
string.IsNullOrWhiteSpace(group.MaterialGrade)
? $"{group.MaterialName} {group.Thickness:0.####} {group.CutType.ToString().ToLowerInvariant()}"
: $"{group.MaterialName} {group.MaterialGrade} {group.Thickness:0.####} {group.CutType.ToString().ToLowerInvariant()}";
private static string Name(Units units) => units.ToString().ToLowerInvariant();
}