diff --git a/Marechai.Server/Controllers/SuggestionsController.cs b/Marechai.Server/Controllers/SuggestionsController.cs index bf3d2376..eff3dd92 100644 --- a/Marechai.Server/Controllers/SuggestionsController.cs +++ b/Marechai.Server/Controllers/SuggestionsController.cs @@ -109,7 +109,8 @@ public class SuggestionsController(MarechaiContext context, SuggestionEntityType.SoundSynth, SuggestionEntityType.Person, SuggestionEntityType.Software, - SuggestionEntityType.SoftwareRelease + SuggestionEntityType.SoftwareRelease, + SuggestionEntityType.SoftwareVersion }; // ───────────────────────────── POST /suggestions ───────────────────────────── @@ -1190,6 +1191,7 @@ public class SuggestionsController(MarechaiContext context, SuggestionEntityType.Person => true, SuggestionEntityType.Software => true, SuggestionEntityType.SoftwareRelease => true, + SuggestionEntityType.SoftwareVersion => true, SuggestionEntityType.GpuPhoto => true, SuggestionEntityType.ProcessorPhoto => true, SuggestionEntityType.SoundSynthPhoto => true, @@ -1248,6 +1250,9 @@ public class SuggestionsController(MarechaiContext context, if(type == SuggestionEntityType.SoftwareRelease) return Suggestions.SoftwareReleaseSuggestionApplier.IsKnownFieldName(fieldName); + if(type == SuggestionEntityType.SoftwareVersion) + return Suggestions.SoftwareVersionSuggestionApplier.IsKnownFieldName(fieldName); + if(type == SuggestionEntityType.GpuPhoto) return Suggestions.GpuPhotoSuggestionApplier.IsKnownFieldName(fieldName); @@ -1429,6 +1434,15 @@ public class SuggestionsController(MarechaiContext context, context, entityId, suggested, accepted); return new ApplyResult(applied, missing); } + case SuggestionEntityType.SoftwareVersion: + { + // Edit-mode is intentionally stubbed (creation-only port). The applier + // returns (empty, false) so the suggestion accepts cleanly with no field + // application — admins shouldn't reach this path through the UI today. + var (applied, missing) = await Suggestions.SoftwareVersionSuggestionApplier.ApplyAsync( + context, entityId, suggested, accepted); + return new ApplyResult(applied, missing); + } case SuggestionEntityType.GpuPhoto: { var (applied, missing) = await Suggestions.GpuPhotoSuggestionApplier.ApplyAsync( @@ -1840,6 +1854,34 @@ public class SuggestionsController(MarechaiContext context, return "A new software suggestion must include a 'first_release_platform_id' field referencing the first release's platform."; return null; } + case SuggestionEntityType.SoftwareVersion: + { + // A SoftwareVersion has no useful surface without a SoftwareRelease, so a + // brand-new SoftwareVersion suggestion MUST carry its first-release in the + // same submission. Validation order: software_id (parent FK) → + // version_string → first_release_title → first_release_publisher_id → + // first_release_platform_id. Matches the dialog's per-field + // defence-in-depth in priority order. + if(!values.ContainsKey(Suggestions.SoftwareVersionSuggestionApplier.FieldSoftwareId)) + return "A new software version suggestion must include a 'software_id' field referencing the parent software."; + if(!values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldVersionString, out object vs) || + string.IsNullOrWhiteSpace(ExtractStringForValidation(vs))) + return "A new software version suggestion must include a non-empty 'version_string' field."; + string firstReleaseTitleKey = Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix + + Suggestions.SoftwareReleaseSuggestionApplier.FieldTitle; + if(!values.TryGetValue(firstReleaseTitleKey, out object frt) || + string.IsNullOrWhiteSpace(ExtractStringForValidation(frt))) + return "A new software version suggestion must include a non-empty 'first_release_title' field (a version requires its first release at creation time)."; + string firstReleasePublisherKey = Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix + + Suggestions.SoftwareReleaseSuggestionApplier.FieldPublisherId; + if(!values.ContainsKey(firstReleasePublisherKey)) + return "A new software version suggestion must include a 'first_release_publisher_id' field referencing the first release's publisher."; + string firstReleasePlatformKey = Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix + + Suggestions.SoftwareReleaseSuggestionApplier.FieldPlatformId; + if(!values.ContainsKey(firstReleasePlatformKey)) + return "A new software version suggestion must include a 'first_release_platform_id' field referencing the first release's platform."; + return null; + } default: return $"Brand-new {type} suggestions are not supported."; } @@ -1967,6 +2009,17 @@ public class SuggestionsController(MarechaiContext context, } return null; } + case SuggestionEntityType.SoftwareVersion: + { + // Display the version string in the queue. The first-release title is + // shown in the expanded admin diff via the prefixed first_release_title key. + if(values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldVersionString, out object n)) + { + string s = ExtractStringForValidation(n); + return string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + } + return null; + } default: return null; } @@ -2051,6 +2104,12 @@ public class SuggestionsController(MarechaiContext context, // rely on admin moderation + the mandatory accompanying first-release // (publisher/platform/title) to disambiguate. return false; + case SuggestionEntityType.SoftwareVersion: + // Version strings repeat constantly ("1.0", "2.0", "1.0.0") across every + // software ever written. Dedupe on version_string only would over-block; + // the per-(software, version_string) uniqueness check is implicit in the + // accepting admin's review of the parent software_id pseudo-field. + return false; default: return false; } @@ -2155,6 +2214,21 @@ public class SuggestionsController(MarechaiContext context, // ulong? to long? doesn't exist in C# (per the SoftwareRelease port lesson). return ((long?)id, applied); } + case SuggestionEntityType.SoftwareVersion: + { + // A SoftwareVersion has no useful surface without a SoftwareRelease, so + // this CreateAsync orchestrates BOTH a SoftwareVersion AND its first + // SoftwareRelease in an atomic EF transaction. Same dual-prefix wire shape + // as the Software port; both software_id (parent FK on the release) and + // software_version_id (chain from release back to the new version) are + // injected by the applier from the freshly-minted version row, not the + // wire payload. + var (id, applied) = await Suggestions.SoftwareVersionSuggestionApplier.CreateAsync( + context, suggested, accepted, creditedUserId); + // SoftwareVersion.Id is ulong; explicit cast to long? — implicit conversion + // from ulong? to long? doesn't exist in C#. + return ((long?)id, applied); + } default: throw new NotImplementedException($"Creating a new {type} from a suggestion is not implemented yet."); } @@ -2391,6 +2465,11 @@ public class SuggestionsController(MarechaiContext context, await ResolveSoftwareReleaseLabelsAsync(entityId, values, labels); break; } + case SuggestionEntityType.SoftwareVersion: + { + await ResolveSoftwareVersionLabelsAsync(entityId, values, labels); + break; + } case SuggestionEntityType.GpuPhoto: { if(values.TryGetValue(Suggestions.GpuPhotoSuggestionApplier.FieldLicenseId, out JsonElement lj)) @@ -3899,6 +3978,99 @@ public class SuggestionsController(MarechaiContext context, } } } + + /// + /// Resolve readable labels for a SoftwareVersion creation suggestion. Resolves + /// software_id to Software.Name, parent_version_id to its + /// version string, and license_id to its name. First-release-prefixed keys + /// are forwarded to via pre-strip + /// + delegate (matches ). The applier has + /// no junctions on the version side; companies stay admin-only. + /// + async Task ResolveSoftwareVersionLabelsAsync(long? entityId, + Dictionary values, + Dictionary labels) + { + _ = entityId; + + // ---- software_id → Software.Name --------------------------------------------- + if(values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldSoftwareId, out JsonElement softwareE)) + { + long? id = JsonElementToLong(softwareE); + if(id.HasValue && id.Value >= 0) + { + ulong sidU = (ulong)id.Value; + string name = await context.Softwares.AsNoTracking() + .Where(x => x.Id == sidU) + .Select(x => x.Name) + .FirstOrDefaultAsync(); + if(!string.IsNullOrEmpty(name)) + labels[Suggestions.SoftwareVersionSuggestionApplier.FieldSoftwareId] = name; + } + } + + // ---- parent_version_id → SoftwareVersion.VersionString (joined with software) ---- + if(values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldParentVersionId, out JsonElement pvE)) + { + long? id = JsonElementToLong(pvE); + if(id.HasValue && id.Value >= 0) + { + ulong pvU = (ulong)id.Value; + var row = await context.SoftwareVersions.AsNoTracking() + .Where(v => v.Id == pvU) + .Select(v => new { v.VersionString, ParentName = v.Software.Name }) + .FirstOrDefaultAsync(); + if(row is not null) + { + string vlabel = string.IsNullOrEmpty(row.ParentName) + ? row.VersionString + : $"{row.ParentName} {row.VersionString}"; + if(!string.IsNullOrEmpty(vlabel)) + labels[Suggestions.SoftwareVersionSuggestionApplier.FieldParentVersionId] = vlabel; + } + } + } + + // ---- license_id → License.Name ----------------------------------------------- + if(values.TryGetValue(Suggestions.SoftwareVersionSuggestionApplier.FieldLicenseId, out JsonElement licE)) + { + int? id = JsonElementToInt(licE); + if(id.HasValue) + { + string name = await context.Licenses.AsNoTracking() + .Where(l => l.Id == id.Value) + .Select(l => l.Name) + .FirstOrDefaultAsync(); + if(!string.IsNullOrEmpty(name)) + labels[Suggestions.SoftwareVersionSuggestionApplier.FieldLicenseId] = name; + } + } + + // ---- First-release-prefixed labels (creation mode only) ---------------------- + // Pre-strip + delegate to the release-side resolver, then re-prefix emitted + // labels back to the wire shape. Junction-op keys contain a dot, scalars don't. + var releaseSubValues = new Dictionary(StringComparer.Ordinal); + foreach(KeyValuePair kv in values) + { + if(kv.Key.StartsWith(Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseGroupPrefix, StringComparison.Ordinal)) + releaseSubValues[kv.Key.Substring(Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseGroupPrefix.Length)] = kv.Value; + else if(kv.Key.StartsWith(Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix, StringComparison.Ordinal)) + releaseSubValues[kv.Key.Substring(Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix.Length)] = kv.Value; + } + if(releaseSubValues.Count > 0) + { + var releaseLabels = new Dictionary(StringComparer.Ordinal); + await ResolveSoftwareReleaseLabelsAsync(null, releaseSubValues, releaseLabels); + foreach(KeyValuePair kv in releaseLabels) + { + string prefix = kv.Key.Contains('.', StringComparison.Ordinal) + ? Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseGroupPrefix + : Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix; + labels[prefix + kv.Key] = kv.Value; + } + } + } + /// /// Resolve readable labels for every Software junction-remove operation in the /// suggested payload by looking up the targeted genre row. The resolved labels are @@ -5209,6 +5381,12 @@ public class SuggestionsController(MarechaiContext context, SuggestionEntityType.Software => $"/software/{entityId}", SuggestionEntityType.SoftwareDescription => $"/software/{entityId}", SuggestionEntityType.SoftwareRelease => $"/software/release/{entityId}", + // SoftwareVersion has no dedicated public page (versions render inline on the + // parent Software view). We don't know the parent SoftwareId at link-construction + // time without an extra DB lookup, so return null and let EntityLink fall back to + // the readable `'{name}' (#{id})` form in notification messages. The user can + // navigate to the Versions card on the parent Software view manually. + SuggestionEntityType.SoftwareVersion => null, SuggestionEntityType.SoftwarePromoArt => $"/software/{entityId}", SuggestionEntityType.SoftwareCover => $"/software/release/{entityId}", SuggestionEntityType.SoftwareScreenshot => $"/software/{entityId}", diff --git a/Marechai.Server/Suggestions/SoftwareReleaseSuggestionApplier.cs b/Marechai.Server/Suggestions/SoftwareReleaseSuggestionApplier.cs index a9aea8af..93195f92 100644 --- a/Marechai.Server/Suggestions/SoftwareReleaseSuggestionApplier.cs +++ b/Marechai.Server/Suggestions/SoftwareReleaseSuggestionApplier.cs @@ -69,6 +69,19 @@ internal static class SoftwareReleaseSuggestionApplier /// public const string FieldSoftwareId = "software_id"; + /// + /// Pseudo-field carrying the parent SoftwareVersion FK at addition time only + /// (consumed by ). Same semantics as + /// : NOT in s_scalarFieldNames (edit-mode + /// re-parenting between versions stays admin-only), but whitelisted in + /// so the controller's field-name validator accepts + /// it on the wire. Used by the combined SoftwareVersion + first-Release atomic + /// creation flow (Marechai.Server.Suggestions.SoftwareVersionSuggestionApplier) + /// to link the freshly-minted release to the freshly-minted version. Optional on + /// stand-alone release creation — omitted when the release isn't version-scoped. + /// + public const string FieldSoftwareVersionId = "software_version_id"; + // ---- Junction group identifiers --------------------------------------------------- public const string GroupRegions = "regions"; public const string GroupLanguages = "languages"; @@ -106,6 +119,7 @@ internal static class SoftwareReleaseSuggestionApplier if(string.IsNullOrEmpty(fieldName)) return false; if(s_scalarFieldNames.Contains(fieldName)) return true; if(fieldName == FieldSoftwareId) return true; + if(fieldName == FieldSoftwareVersionId) return true; return TryParseJunctionKey(fieldName, out _, out _, out _); } @@ -234,6 +248,26 @@ internal static class SoftwareReleaseSuggestionApplier applied.Add(FieldTitle); applied.Add(FieldPlatformId); + // Optional: SoftwareVersionId pseudo-field (creation-time only). Used by the + // combined SoftwareVersion + first-Release atomic creation flow to link the + // freshly-minted release back to the freshly-minted version. Silently skipped + // when not accepted or when the FK row doesn't exist (defence-in-depth — the + // caller injects this id from the just-saved SoftwareVersion.Id so it WILL + // exist in practice). + if(accepted.Contains(FieldSoftwareVersionId) && + suggested.TryGetValue(FieldSoftwareVersionId, out object versionIdRaw)) + { + ulong? versionIdParsed = ToUlong(versionIdRaw); + if(versionIdParsed.HasValue && + versionIdParsed.Value != 0 && + await context.SoftwareVersions.AsNoTracking() + .AnyAsync(v => v.Id == versionIdParsed.Value)) + { + r.SoftwareVersionId = versionIdParsed.Value; + applied.Add(FieldSoftwareVersionId); + } + } + // Apply remaining accepted scalar fields (release_date, release_date_precision). // Mandatory fields are handled above; junctions and software_id pseudo-field skip. foreach(string fieldName in accepted) diff --git a/Marechai.Server/Suggestions/SoftwareVersionSuggestionApplier.cs b/Marechai.Server/Suggestions/SoftwareVersionSuggestionApplier.cs new file mode 100644 index 00000000..b01d6096 --- /dev/null +++ b/Marechai.Server/Suggestions/SoftwareVersionSuggestionApplier.cs @@ -0,0 +1,472 @@ +/****************************************************************************** +// MARECHAI: Master repository of computing history artifacts information +// ---------------------------------------------------------------------------- +// +// Author(s) : Natalia Portillo +// +// --[ License ] -------------------------------------------------------------- +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// ---------------------------------------------------------------------------- +// Copyright © 2003-2026 Natalia Portillo +*******************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Threading.Tasks; +using Marechai.Data; +using Marechai.Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Marechai.Server.Suggestions; + +/// +/// Server-side applier for brand-new creation +/// suggestions. Mirrors the combined parent+child atomic-creation pattern proven by +/// : a SoftwareVersion has no useful surface +/// without a SoftwareRelease, so every creation submission MUST carry a first-release +/// half prefixed with / , +/// and both rows are inserted atomically inside an EF transaction. Acceptance of the +/// mandatory fields of EITHER half is required for the whole suggestion to apply; if +/// the release applier returns null the version is rolled back as well. +/// +/// +/// Edit-mode suggestions for an existing SoftwareVersion are out of scope. The +/// stub returns (empty, false) so the dispatch +/// switch in SuggestionsController stays exhaustive while no edit-mode UI +/// exposes this entity type. +/// +/// +/// +/// Re-parenting fields ( / parent_version_id +/// used as a re-parent on edit) stay admin-only: software_id is whitelisted +/// in but NOT in s_scalarFieldNames, so the +/// creation path picks it up while the (currently stubbed) edit path would ignore +/// it. +/// +/// +internal static class SoftwareVersionSuggestionApplier +{ + // ---- Scalar field names (mirror client-side metadata) ----------------------------- + public const string FieldVersionString = "version_string"; + public const string FieldPublicVersion = "public_version"; + public const string FieldCodename = "codename"; + public const string FieldParentVersionId = "parent_version_id"; + public const string FieldLicenseId = "license_id"; + + static readonly HashSet s_scalarFieldNames = new(StringComparer.Ordinal) + { + FieldVersionString, + FieldPublicVersion, + FieldCodename, + FieldParentVersionId, + FieldLicenseId + }; + + /// + /// Pseudo-field carrying the parent FK at addition time + /// only (consumed by ). Intentionally NOT in + /// s_scalarFieldNames: edit-mode re-parenting between Softwares stays + /// admin-only. Whitelisted in so the controller's + /// field-name validator accepts it on the wire. + /// + public const string FieldSoftwareId = "software_id"; + + // ---- First-release pseudo-prefixes (mirror server-side; creation-mode only) -------- + /// + /// Field-name prefix for first-release SCALAR fields embedded in a SoftwareVersion + /// creation payload (e.g. first_release_title). Stripped before delegating + /// to . + /// + public const string FirstReleaseScalarPrefix = "first_release_"; + + /// + /// Field-name prefix for first-release JUNCTION operation keys embedded in a + /// SoftwareVersion creation payload (e.g. + /// first_release.regions.add.<uuid>). The dot delimits the original + /// <group>.<op>.<token> shape and survives the strip + /// verbatim. + /// + public const string FirstReleaseGroupPrefix = "first_release."; + + public static bool IsKnownFieldName(string fieldName) + { + if(string.IsNullOrEmpty(fieldName)) return false; + if(s_scalarFieldNames.Contains(fieldName)) return true; + if(fieldName == FieldSoftwareId) return true; + + // Delegate first-release-prefixed keys to the release applier. The dot-prefix + // ("first_release.") must be checked BEFORE the scalar prefix ("first_release_") + // — they share the leading "first_release" run and the underscore comes second, + // so a "first_release.regions.add.X" key would also match the underscore-prefix + // text check if we tried that first. Test dot first. + if(fieldName.StartsWith(FirstReleaseGroupPrefix, StringComparison.Ordinal)) + return SoftwareReleaseSuggestionApplier.IsKnownFieldName(fieldName.Substring(FirstReleaseGroupPrefix.Length)); + if(fieldName.StartsWith(FirstReleaseScalarPrefix, StringComparison.Ordinal)) + return SoftwareReleaseSuggestionApplier.IsKnownFieldName(fieldName.Substring(FirstReleaseScalarPrefix.Length)); + + return false; + } + + /// + /// Edit-mode is out of scope for SoftwareVersion suggestions (no user-facing UI + /// exposes it today). Stub returns (empty, entityMissing=false) so the + /// dispatch switch in SuggestionsController.ApplyAcceptedFieldsAsync stays + /// exhaustive without throwing NotImplementedException on an unrelated code + /// path that might be reached e.g. by an admin manually pushing through an old + /// edit suggestion shape. + /// + public static Task<(HashSet applied, bool entityMissing)> ApplyAsync( + MarechaiContext context, long entityId, + Dictionary suggested, + HashSet accepted) + { + _ = context; + _ = entityId; + _ = suggested; + _ = accepted; + return Task.FromResult((new HashSet(StringComparer.Ordinal), false)); + } + + /// + /// Returns the current scalar snapshot of the targeted SoftwareVersion row. Used + /// by the diff endpoint when reviewing an (unsupported) edit-mode suggestion; the + /// creation path passes a synthetic snapshot in the SuggestionsController so this + /// method is exercised only as a defensive default. + /// + public static async Task> GetCurrentValuesAsync(MarechaiContext context, + long entityId) + { + SoftwareVersion v = await context.SoftwareVersions.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == (ulong)entityId); + if(v is null) return null; + + return new Dictionary(StringComparer.Ordinal) + { + [FieldVersionString] = v.VersionString, + [FieldPublicVersion] = v.PublicVersion, + [FieldCodename] = v.Codename, + [FieldParentVersionId] = v.ParentVersionId, + [FieldLicenseId] = v.LicenseId + }; + } + + /// + /// Create a brand-new SoftwareVersion AND its first SoftwareRelease in a single + /// atomic EF transaction. Both halves are inserted together; if the release-side + /// mandatories fail after the version is saved, the version row is rolled back too. + /// + /// + /// The accepted/suggested dictionaries may contain four flavours of keys: + /// + /// Top-level version scalars (version_string, codename, etc.). + /// The parent-FK pseudo-field . + /// First-release scalars prefixed with + /// (first_release_title, first_release_publisher_id, etc.). + /// First-release junction-add keys prefixed with + /// (first_release.regions.add.<uuid>, etc.). + /// + /// + /// + /// + /// Mandatory: (FK existence-checked), + /// (non-whitespace), + /// first_release_title, first_release_publisher_id, + /// first_release_platform_id. The matching software_id and + /// software_version_id on the release are injected after the version is + /// saved; callers do not (and must not) provide them via the wire. + /// + /// + public static async Task<(ulong? newId, HashSet applied)> CreateAsync( + MarechaiContext context, + Dictionary suggested, + HashSet accepted, + string creditedUserId) + { + var applied = new HashSet(StringComparer.Ordinal); + + // ── Mandatory: parent Software FK (consumed only here, not on edit-path) ── + if(!accepted.Contains(FieldSoftwareId) || + !suggested.TryGetValue(FieldSoftwareId, out object softwareIdRaw)) + return (null, applied); + ulong? softwareIdParsed = ToUlong(softwareIdRaw); + if(!softwareIdParsed.HasValue || softwareIdParsed.Value == 0) return (null, applied); + ulong softwareId = softwareIdParsed.Value; + if(!await context.Softwares.AsNoTracking().AnyAsync(s => s.Id == softwareId)) + return (null, applied); + + // ── Mandatory: VersionString ([Required] on the entity, ≤50 chars) ── + if(!accepted.Contains(FieldVersionString) || + !suggested.TryGetValue(FieldVersionString, out object versionStringRaw)) + return (null, applied); + string versionString = ToStringValue(versionStringRaw); + if(string.IsNullOrWhiteSpace(versionString)) return (null, applied); + versionString = versionString.Trim(); + // The model has no explicit StringLength on VersionString; impose a soft cap to + // protect downstream UI (mirrors the dialog's MudTextField MaxLength). + if(versionString.Length > 255) return (null, applied); + + // ── Split payload into version-side vs release-side dicts via prefix-strip ── + var versionSuggested = new Dictionary(StringComparer.Ordinal); + var versionAccepted = new HashSet(StringComparer.Ordinal); + var releaseSuggested = new Dictionary(StringComparer.Ordinal); + var releaseAccepted = new HashSet(StringComparer.Ordinal); + + foreach(string fieldName in accepted) + { + if(!suggested.TryGetValue(fieldName, out object value)) continue; + + // Test dot-prefix first (see IsKnownFieldName comment for why). + if(fieldName.StartsWith(FirstReleaseGroupPrefix, StringComparison.Ordinal)) + { + string stripped = fieldName.Substring(FirstReleaseGroupPrefix.Length); + releaseSuggested[stripped] = value; + releaseAccepted.Add(stripped); + } + else if(fieldName.StartsWith(FirstReleaseScalarPrefix, StringComparison.Ordinal)) + { + string stripped = fieldName.Substring(FirstReleaseScalarPrefix.Length); + releaseSuggested[stripped] = value; + releaseAccepted.Add(stripped); + } + else + { + versionSuggested[fieldName] = value; + versionAccepted.Add(fieldName); + } + } + + // ── Validate first-release mandatories (priority: title → publisher → platform) ── + if(!releaseAccepted.Contains(SoftwareReleaseSuggestionApplier.FieldTitle) || + !releaseSuggested.TryGetValue(SoftwareReleaseSuggestionApplier.FieldTitle, out object titleRaw) || + string.IsNullOrWhiteSpace(ToStringValue(titleRaw))) + return (null, applied); + + if(!releaseAccepted.Contains(SoftwareReleaseSuggestionApplier.FieldPublisherId) || + !releaseSuggested.ContainsKey(SoftwareReleaseSuggestionApplier.FieldPublisherId)) + return (null, applied); + + if(!releaseAccepted.Contains(SoftwareReleaseSuggestionApplier.FieldPlatformId) || + !releaseSuggested.ContainsKey(SoftwareReleaseSuggestionApplier.FieldPlatformId)) + return (null, applied); + + // ── Atomic transaction: insert SoftwareVersion + its first Release together ── + await using var tx = await context.Database.BeginTransactionAsync(); + + try + { + var v = new SoftwareVersion + { + SoftwareId = softwareId, + VersionString = versionString + }; + + // Apply remaining accepted version scalars (PublicVersion / Codename / + // ParentVersionId / LicenseId). VersionString is handled above. + foreach(string fieldName in versionAccepted) + { + if(fieldName == FieldVersionString) continue; + if(fieldName == FieldSoftwareId) continue; + if(!s_scalarFieldNames.Contains(fieldName)) continue; + if(!versionSuggested.TryGetValue(fieldName, out object value)) continue; + + try + { + if(await ApplyScalar(context, v, fieldName, value, softwareId)) applied.Add(fieldName); + } + catch + { + // Per-field coercion failure: skip. + } + } + + await context.SoftwareVersions.AddAsync(v); + + if(string.IsNullOrEmpty(creditedUserId)) + await context.SaveChangesAsync(); + else + await context.SaveChangesWithUserAsync(creditedUserId); + + applied.Add(FieldSoftwareId); + applied.Add(FieldVersionString); + + // ── Inject software_id + software_version_id and delegate to release applier ── + // Cast through long so the release applier's ToUlong helper picks them up + // cleanly via its long arm. Both FKs are populated on the new release row so + // the existing Software/View Releases card (which groups by Software) keeps + // working, and the release also chains to the new Version for downstream + // version-scoped lookups. + releaseSuggested[SoftwareReleaseSuggestionApplier.FieldSoftwareId] = (long)softwareId; + releaseAccepted.Add(SoftwareReleaseSuggestionApplier.FieldSoftwareId); + releaseSuggested[SoftwareReleaseSuggestionApplier.FieldSoftwareVersionId] = (long)v.Id; + releaseAccepted.Add(SoftwareReleaseSuggestionApplier.FieldSoftwareVersionId); + + (ulong? releaseId, HashSet releaseApplied) = + await SoftwareReleaseSuggestionApplier.CreateAsync( + context, releaseSuggested, releaseAccepted, creditedUserId); + + if(!releaseId.HasValue) + { + // Mandatory release fields failed server-side validation after passing + // the controller's defence-in-depth. Roll back the version too — a + // version without a release is intentionally not allowed via this flow. + await tx.RollbackAsync(); + return (null, new HashSet(StringComparer.Ordinal)); + } + + // Re-prefix the release-applied keys back to wire shape so the admin diff + // panel sees the original keys (first_release_title, etc.). Internally + // injected pseudo-fields (software_id / software_version_id) are NOT + // exposed — they're an implementation detail of the atomic insert. + foreach(string releaseKey in releaseApplied) + { + if(releaseKey == SoftwareReleaseSuggestionApplier.FieldSoftwareId) continue; + if(releaseKey == SoftwareReleaseSuggestionApplier.FieldSoftwareVersionId) continue; + + string wireKey = releaseKey.Contains('.', StringComparison.Ordinal) + ? FirstReleaseGroupPrefix + releaseKey + : FirstReleaseScalarPrefix + releaseKey; + applied.Add(wireKey); + } + + await tx.CommitAsync(); + return (v.Id, applied); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + // ───────────────────────────── Scalar-field application ───────────────────────────── + + static async Task ApplyScalar(MarechaiContext context, SoftwareVersion v, string fieldName, object value, + ulong softwareId) + { + switch(fieldName) + { + case FieldPublicVersion: + { + string s = ToStringValue(value); + v.PublicVersion = string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + return true; + } + case FieldCodename: + { + string s = ToStringValue(value); + v.Codename = string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + return true; + } + case FieldParentVersionId: + { + ulong? pid = ToUlong(value); + if(!pid.HasValue) { v.ParentVersionId = null; return true; } + // Existence + same-parent-Software check. Disallow cross-Software linking + // (matches the dialog's same-software autocomplete scope). + bool ok = await context.SoftwareVersions.AsNoTracking() + .AnyAsync(x => x.Id == pid.Value && x.SoftwareId == softwareId); + if(!ok) return false; + v.ParentVersionId = pid.Value; + return true; + } + case FieldLicenseId: + { + int? lid = ToInt(value); + if(!lid.HasValue) { v.LicenseId = null; return true; } + if(!await context.Licenses.AsNoTracking().AnyAsync(l => l.Id == lid.Value)) return false; + v.LicenseId = lid.Value; + return true; + } + } + return false; + } + + // ───────────────────────────── Value coercion helpers ───────────────────────────── + // Mirror of the helpers in SoftwareSuggestionApplier / SoftwareReleaseSuggestionApplier. + // Kept local to avoid cross-applier dependency creep. + + static string ToStringValue(object v) + { + return v switch + { + null => null, + JsonElement je => je.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.String => je.GetString(), + _ => je.ToString() + }, + string s => s, + _ => v.ToString() + }; + } + + static int? ToInt(object v) + { + return v switch + { + null => null, + int i => i, + short s => s, + long l => (int?)l, + byte b => b, + JsonElement je => je.ValueKind switch + { + JsonValueKind.Number => je.TryGetInt32(out int i) ? i : null, + JsonValueKind.String => int.TryParse(je.GetString(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int p) + ? p + : null, + _ => null + }, + string str => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out int p) + ? p + : null, + _ => null + }; + } + + static ulong? ToUlong(object v) + { + return v switch + { + null => null, + ulong u => u, + uint u => u, + int i => i >= 0 ? (ulong)i : null, + short s => s >= 0 ? (ulong)s : null, + long l => l >= 0 ? (ulong)l : null, + byte b => b, + JsonElement je => je.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.Number => je.TryGetUInt64(out ulong p) ? p : + je.TryGetInt64(out long pl) && pl >= 0 ? (ulong)pl : null, + JsonValueKind.String => ulong.TryParse(je.GetString(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out ulong pu) + ? pu + : null, + _ => null + }, + string str => ulong.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong p) + ? p + : null, + _ => null + }; + } +} diff --git a/Marechai/Pages/Software/View.razor b/Marechai/Pages/Software/View.razor index d5b1d467..254f509c 100644 --- a/Marechai/Pages/Software/View.razor +++ b/Marechai/Pages/Software/View.razor @@ -663,45 +663,64 @@ else @* ── Versions Card ── *@ -@if(_versions.Count > 0) -{ - + - - - @L["Versions"] - @_versions.Count + + + + @L["Versions"] + @_versions.Count + + + + + + + + + + - @foreach(SoftwareVersionDto version in _versions) + @if(_versions.Count == 0) { - - - - - @version.VersionString - @if(!string.IsNullOrEmpty(version.PublicVersion)) + @L["No versions recorded yet."] + } + else + { + @foreach(SoftwareVersionDto version in _versions) + { + + + + + @version.VersionString + @if(!string.IsNullOrEmpty(version.PublicVersion)) + { + (@version.PublicVersion) + } + + @if(!string.IsNullOrEmpty(version.Codename)) { - (@version.PublicVersion) + @L["Codename"]: @version.Codename } - - @if(!string.IsNullOrEmpty(version.Codename)) - { - @L["Codename"]: @version.Codename - } - @if(!string.IsNullOrEmpty(version.License)) - { - @L["License"]: @version.License - } - - + @if(!string.IsNullOrEmpty(version.License)) + { + @L["License"]: @version.License + } + + + } } -} @* ── Releases Card (flat list) ── *@ diff --git a/Marechai/Pages/Software/View.razor.cs b/Marechai/Pages/Software/View.razor.cs index 9cddff98..051d0572 100644 --- a/Marechai/Pages/Software/View.razor.cs +++ b/Marechai/Pages/Software/View.razor.cs @@ -813,6 +813,48 @@ public partial class View // No reload needed — pending suggestions only take effect after admin review. } + /// + /// Opens the SoftwareVersionSuggestionDialog in CREATION mode for a brand-new + /// version of this Software. Because a SoftwareVersion has no useful surface + /// without a SoftwareRelease, the dialog also collects a mandatory first + /// SoftwareRelease in the same submission; both rows are inserted atomically by + /// the server applier when an administrator accepts the suggestion. The parent + /// software_id + display label are prefilled and shown as a read-only + /// "Version of: {0}" alert. + /// + async Task OpenSuggestNewVersionDialogAsync() + { + if(AuthState is null) return; + + AuthenticationState state = await AuthState; + if(state?.User?.Identity?.IsAuthenticated != true) return; + + long softwareId = Id; + if(softwareId <= 0 || _software is null) return; + + var parameters = new DialogParameters + { + { x => x.EntityId, 0L }, + { x => x.PrefillSoftwareId, (ulong?)softwareId }, + { x => x.PrefillSoftwareLabel, _software.Name } + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Large, + FullWidth = true, + CloseOnEscapeKey = true + }; + + IDialogReference dialog = await DialogService.ShowAsync( + L["Suggest new version"], parameters, options); + + DialogResult result = await dialog.Result; + if(result is null || result.Canceled) return; + + // No reload needed — pending suggestions only take effect after admin review. + } + /// /// Opens the SoftwarePromoArtSuggestionDialog so a logged-in collaborator can stage /// 1-30 pending promo art images for this Software, choose a group (free-text diff --git a/Marechai/Pages/Suggestions/SoftwareVersionSuggestionDialog.razor b/Marechai/Pages/Suggestions/SoftwareVersionSuggestionDialog.razor new file mode 100644 index 00000000..0a94675d --- /dev/null +++ b/Marechai/Pages/Suggestions/SoftwareVersionSuggestionDialog.razor @@ -0,0 +1,680 @@ +@using System +@using System.Collections.Generic +@using System.Globalization +@using System.Linq +@using System.Text.Json +@using System.Threading +@using Humanizer +@using Marechai.ApiClient.Models +@using Marechai.Data +@using Marechai.Services +@using Marechai.Suggestions +@using Marechai.Suggestions.Metadata + +@inject IStringLocalizer L +@inject ISnackbar Snackbar +@inject SoftwareVersionsService VersionsService +@inject SoftwareReleasesService ReleasesService +@inject SuggestionsService SuggestionsService + +@* + Creation-only suggestion dialog for a brand-new SoftwareVersion. Because a + SoftwareVersion has no useful surface without a SoftwareRelease, every submission + MUST carry a first-release in the same payload; the server inserts both rows + atomically (rollback on either half failing) inside SoftwareVersionSuggestionApplier. + + Wire payload shape (dual-prefix convention, mirroring the Software-creation port): + - version-side scalars: version_string / public_version / codename / parent_version_id / license_id + - version-side parent pseudo-FK: software_id (prefilled, never user-editable) + - first-release scalars: first_release_title / first_release_publisher_id / first_release_platform_id / first_release_release_date / first_release_release_date_precision + - first-release junction adds: first_release.regions.add.{uuid} etc. (4 groups: regions / languages / barcodes / product_codes) + + Junctions on the version side (Companies) are intentionally admin-only and not + exposed here, matching the existing admin SoftwareVersionDialog scope. +*@ + + + + @if(_loading) + { + + } + else + { + + @L["Suggest a new version of this software. A first release is required at the same time; both will be created together when an administrator accepts the suggestion."] + + + @* ── Read-only parent Software context (admin-only re-parenting) ── *@ + @if(!string.IsNullOrEmpty(PrefillSoftwareLabel)) + { + + @string.Format(L["Version of: {0}"], PrefillSoftwareLabel) + + } + + @* ── Version info ── *@ + + @L["Version info"] + + + + + + + + + + + + + @* ── First release info ── *@ + + @L["First release"] + + @L["Title, publisher and platform are required for the accompanying release."] + + + + + + + + + + + + @L["Full date"] + @L["Month and year only"] + @L["Year only"] + + + + @* ── First release junctions (regions / languages / barcodes / product_codes) ── *@ + + + @* Regions *@ + + @foreach(KeyValuePair kv in _firstReleaseRegionAdds) + { + + + @kv.Value.Display + @L["Undo"] + + } + + + @L["Add region"] + + + + @* Languages *@ + + @foreach(KeyValuePair kv in _firstReleaseLanguageAdds) + { + + + @kv.Value.Display + @L["Undo"] + + } + + + @L["Add language"] + + + + @* Barcodes *@ + + @foreach(KeyValuePair kv in _firstReleaseBarcodeAdds) + { + + + @kv.Value.Display + @L["Undo"] + + } + + + @foreach(BarcodeType bt in Enum.GetValues()) + { + int val = (int)bt; + @bt.Humanize() + } + + + @L["Add barcode"] + + + + @* Product codes *@ + + @foreach(KeyValuePair kv in _firstReleaseProductCodeAdds) + { + + + @kv.Value.Display + @L["Undo"] + + } + + + @foreach(ProductCodeIssuer pi in Enum.GetValues()) + { + int val = (int)pi; + @pi.Humanize() + } + + + @L["Add product code"] + + + + + + + + @if(!string.IsNullOrWhiteSpace(_validationError)) + { + @_validationError + } + } + + + @L["Cancel"] + + @if(_submitting) + { + + } + else + { + @L["Submit suggestion"] + } + + + + +@code +{ + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } + + /// + /// Unused in this dialog (creation-only) but kept on the parameter surface so the + /// caller can use the standard DialogParameters<T> shape. Always pass + /// 0L from the call site. + /// + [Parameter] public long EntityId { get; set; } + + /// Parent Software id — required. The dialog refuses to submit without it. + [Parameter] public ulong? PrefillSoftwareId { get; set; } + + /// Display label for the prefilled parent Software (e.g. its name). + [Parameter] public string PrefillSoftwareLabel { get; set; } + + sealed class JunctionAddState + { + public Dictionary Payload { get; set; } + public string Display { get; set; } + } + + bool _loading = true; + bool _submitting; + string _validationError; + string _userComment; + + // ── Version-side state ── + string _versionString; + string _publicVersion; + string _codename; + SoftwareVersionDto _selectedParentVersion; + LicenseDto _selectedLicense; + + List _siblingVersions; + List _allLicenses; + + // ── First-release state (mirrors edit-mode release dialog state, adds-only) ── + string _firstReleaseTitle; + SoftwarePlatformDto _firstReleaseSelectedPlatform; + CompanyDto _firstReleaseSelectedPublisher; + DateTime? _firstReleaseDate; + int _firstReleaseDatePrecision; + + List _allFirstReleasePlatforms; + List _allFirstReleaseCompanies; + List _allFirstReleaseRegions; + List _allFirstReleaseLanguages; + + Dictionary _firstReleaseRegionAdds = new(); + Dictionary _firstReleaseLanguageAdds = new(); + Dictionary _firstReleaseBarcodeAdds = new(); + Dictionary _firstReleaseProductCodeAdds = new(); + + UnM49Dto _pickFirstReleaseRegion; + Iso639Dto _pickFirstReleaseLanguage; + int _pickFirstReleaseBarcodeType; + string _pickFirstReleaseBarcodeCode; + int _pickFirstReleaseProductCodeIssuer; + string _pickFirstReleaseProductCodeCode; + + protected override async Task OnInitializedAsync() + { + if(PrefillSoftwareId is null || PrefillSoftwareId.Value == 0) + { + // Caller wiring error — the dialog cannot operate without a parent software. + // Leave _loading=false so the validation alert renders. + _loading = false; + _validationError = L["Cannot open: parent software is missing."]; + return; + } + + try + { + // Sibling versions for the ParentVersion autocomplete — scoped to the same + // parent Software to keep the picker relevant. Casts ulong → int because the + // Kiota wrapper only exposes the int? wire type. + Task> siblingsT = VersionsService.GetBySoftwareAsync((int)PrefillSoftwareId.Value); + Task> licensesT = VersionsService.GetAllLicensesAsync(); + + // First-release picker corpora (small enough to pre-load — same as the + // existing SoftwareReleaseSuggestionDialog edit-mode flow). + Task> platformsT = ReleasesService.GetAllPlatformsAsync(); + Task> companiesT = ReleasesService.GetAllCompaniesAsync(); + Task> regionsT = ReleasesService.GetAllUnM49Async(); + Task> languagesT = ReleasesService.GetAllLanguagesAsync(); + + await Task.WhenAll(siblingsT, licensesT, platformsT, companiesT, regionsT, languagesT); + + _siblingVersions = siblingsT.Result ?? new List(); + _allLicenses = licensesT.Result ?? new List(); + _allFirstReleasePlatforms = platformsT.Result ?? new List(); + _allFirstReleaseCompanies = companiesT.Result ?? new List(); + _allFirstReleaseRegions = regionsT.Result ?? new List(); + _allFirstReleaseLanguages = languagesT.Result ?? new List(); + } + catch + { + _validationError = L["Failed to load picker data — please retry."]; + } + finally + { + _loading = false; + } + } + + // ───────────────────────────── Search functions ───────────────────────────── + + Task> SearchSiblingVersions(string value, CancellationToken ct) + { + IEnumerable r = _siblingVersions ?? []; + if(!string.IsNullOrWhiteSpace(value)) + r = r.Where(v => (v.VersionString?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false) + || (v.PublicVersion?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false)); + return Task.FromResult(r.Take(50)); + } + + Task> SearchLicenses(string value, CancellationToken ct) + { + IEnumerable r = _allLicenses ?? []; + if(!string.IsNullOrWhiteSpace(value)) + r = r.Where(l => l.Name?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false); + return Task.FromResult(r.Take(50)); + } + + Task> SearchPlatforms(string value, CancellationToken ct) + { + IEnumerable r = _allFirstReleasePlatforms ?? []; + if(!string.IsNullOrWhiteSpace(value)) + r = r.Where(p => p.Name?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false); + return Task.FromResult(r.Take(50)); + } + + Task> SearchCompanies(string value, CancellationToken ct) + { + IEnumerable r = _allFirstReleaseCompanies ?? []; + if(!string.IsNullOrWhiteSpace(value)) + r = r.Where(c => c.Name?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false); + return Task.FromResult(r.Take(50)); + } + + Task> SearchRegions(string value, CancellationToken ct) + { + // Dedup against pending adds (no existing rows in creation mode). + HashSet assigned = new(); + foreach(JunctionAddState a in _firstReleaseRegionAdds.Values) + if(GetIntField(a.Payload, "unm49_id") is int v) assigned.Add(v); + IEnumerable r = (_allFirstReleaseRegions ?? []).Where(rg => rg.Id is int id && !assigned.Contains(id)); + if(!string.IsNullOrWhiteSpace(value)) + r = r.Where(rg => rg.Name?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false); + return Task.FromResult(r.Take(50)); + } + + Task> SearchLanguages(string value, CancellationToken ct) + { + HashSet assigned = new(StringComparer.Ordinal); + foreach(JunctionAddState a in _firstReleaseLanguageAdds.Values) + if(GetStringField(a.Payload, "language_code") is string c && !string.IsNullOrEmpty(c)) assigned.Add(c); + IEnumerable r = (_allFirstReleaseLanguages ?? []).Where(l => !string.IsNullOrEmpty(l.Id) && !assigned.Contains(l.Id)); + if(!string.IsNullOrWhiteSpace(value)) + r = r.Where(l => (l.ReferenceName?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false) + || (l.Id?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false)); + return Task.FromResult(r.Take(50)); + } + + static int? GetIntField(Dictionary payload, string key) + { + if(payload is null || !payload.TryGetValue(key, out object v) || v is null) return null; + return v switch + { + int i => i, + long l => (int?)l, + short s => s, + string str => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out int p) ? p : null, + _ => null + }; + } + + static string GetStringField(Dictionary payload, string key) + { + if(payload is null || !payload.TryGetValue(key, out object v) || v is null) return null; + return v as string ?? v.ToString(); + } + + static string NewKey(string group, string op) => $"{group}.{op}.{Guid.NewGuid():N}"; + + // ───────────────────────────── Add handlers ───────────────────────────── + + void AddFirstReleaseRegion() + { + if(_pickFirstReleaseRegion is null || !_pickFirstReleaseRegion.Id.HasValue) return; + string key = NewKey(SoftwareReleaseSuggestionMetadata.GroupRegions, "add"); + _firstReleaseRegionAdds[key] = new JunctionAddState + { + Payload = new Dictionary(StringComparer.Ordinal) + { + ["unm49_id"] = _pickFirstReleaseRegion.Id.Value + }, + Display = _pickFirstReleaseRegion.Name ?? $"#{_pickFirstReleaseRegion.Id.Value}" + }; + _pickFirstReleaseRegion = null; + } + + void AddFirstReleaseLanguage() + { + if(_pickFirstReleaseLanguage is null || string.IsNullOrEmpty(_pickFirstReleaseLanguage.Id)) return; + string key = NewKey(SoftwareReleaseSuggestionMetadata.GroupLanguages, "add"); + _firstReleaseLanguageAdds[key] = new JunctionAddState + { + Payload = new Dictionary(StringComparer.Ordinal) + { + ["language_code"] = _pickFirstReleaseLanguage.Id + }, + Display = _pickFirstReleaseLanguage.ReferenceName ?? _pickFirstReleaseLanguage.Id + }; + _pickFirstReleaseLanguage = null; + } + + void AddFirstReleaseBarcode() + { + if(string.IsNullOrWhiteSpace(_pickFirstReleaseBarcodeCode)) return; + string code = _pickFirstReleaseBarcodeCode.Trim(); + bool clash = _firstReleaseBarcodeAdds.Values.Any(a => GetStringField(a.Payload, "code") == code); + if(clash) + { + _validationError = L["This barcode is already present in the list."]; + return; + } + _validationError = null; + string key = NewKey(SoftwareReleaseSuggestionMetadata.GroupBarcodes, "add"); + BarcodeType bt = (BarcodeType)_pickFirstReleaseBarcodeType; + _firstReleaseBarcodeAdds[key] = new JunctionAddState + { + Payload = new Dictionary(StringComparer.Ordinal) + { + ["code"] = code, + ["type"] = _pickFirstReleaseBarcodeType + }, + Display = $"{bt.Humanize()}: {code}" + }; + _pickFirstReleaseBarcodeCode = string.Empty; + } + + void AddFirstReleaseProductCode() + { + if(string.IsNullOrWhiteSpace(_pickFirstReleaseProductCodeCode)) return; + string code = _pickFirstReleaseProductCodeCode.Trim(); + bool clash = _firstReleaseProductCodeAdds.Values.Any(a => + GetStringField(a.Payload, "code") == code + && GetIntField(a.Payload, "issuer") == _pickFirstReleaseProductCodeIssuer); + if(clash) + { + _validationError = L["This product code is already present in the list."]; + return; + } + _validationError = null; + string key = NewKey(SoftwareReleaseSuggestionMetadata.GroupProductCodes, "add"); + ProductCodeIssuer issuer = (ProductCodeIssuer)_pickFirstReleaseProductCodeIssuer; + _firstReleaseProductCodeAdds[key] = new JunctionAddState + { + Payload = new Dictionary(StringComparer.Ordinal) + { + ["code"] = code, + ["issuer"] = _pickFirstReleaseProductCodeIssuer + }, + Display = $"{issuer.Humanize()}: {code}" + }; + _pickFirstReleaseProductCodeCode = string.Empty; + } + + // ───────────────────────────── Submit gate ───────────────────────────── + + /// + /// UX gate for the Submit button. Mirrors the server's + /// SuggestionsController.ValidateAdditionPayload arm for SoftwareVersion: + /// parent software, version_string, first_release_title, first_release_publisher, + /// first_release_platform must all be set. + /// + bool HasMandatoryFields() => + PrefillSoftwareId.HasValue + && !string.IsNullOrWhiteSpace(_versionString) + && !string.IsNullOrWhiteSpace(_firstReleaseTitle) + && _firstReleaseSelectedPublisher is not null + && _firstReleaseSelectedPlatform is not null; + + void Cancel() => MudDialog.Cancel(); + + async Task Submit() + { + _validationError = null; + if(_loading) return; + + // Per-field validation in priority order — surface a specific error for the first + // missing mandatory field. Matches the server's ValidateAdditionPayload arm. + if(!PrefillSoftwareId.HasValue || PrefillSoftwareId.Value == 0) + { + _validationError = L["Cannot submit: parent software is missing."]; + return; + } + if(string.IsNullOrWhiteSpace(_versionString)) + { + _validationError = L["Version string is required."]; + return; + } + if(string.IsNullOrWhiteSpace(_firstReleaseTitle)) + { + _validationError = L["Release title is required."]; + return; + } + if(_firstReleaseSelectedPublisher is null || !_firstReleaseSelectedPublisher.Id.HasValue) + { + _validationError = L["Publisher is required."]; + return; + } + if(_firstReleaseSelectedPlatform is null || !_firstReleaseSelectedPlatform.Id.HasValue) + { + _validationError = L["Platform is required."]; + return; + } + + var diff = new Dictionary(StringComparer.Ordinal); + + // ── Version-side payload ── + // Wire the parent-FK pseudo-field as long for clean coercion server-side. The + // applier's ToUlong helper handles both int and long arms identically. + diff[SoftwareVersionSuggestionMetadata.FieldSoftwareId] = (long)PrefillSoftwareId.Value; + diff[SoftwareVersionSuggestionMetadata.FieldVersionString] = _versionString; + if(!string.IsNullOrWhiteSpace(_publicVersion)) + diff[SoftwareVersionSuggestionMetadata.FieldPublicVersion] = _publicVersion; + if(!string.IsNullOrWhiteSpace(_codename)) + diff[SoftwareVersionSuggestionMetadata.FieldCodename] = _codename; + if(_selectedParentVersion?.Id.HasValue == true && _selectedParentVersion.Id.Value > 0) + diff[SoftwareVersionSuggestionMetadata.FieldParentVersionId] = (long)_selectedParentVersion.Id.Value; + if(_selectedLicense?.Id.HasValue == true) + diff[SoftwareVersionSuggestionMetadata.FieldLicenseId] = _selectedLicense.Id.Value; + + // ── First-release scalars (first_release_ prefix) ── + diff[SoftwareVersionSuggestionMetadata.FirstReleaseScalarPrefix + SoftwareReleaseSuggestionMetadata.FieldTitle] = _firstReleaseTitle; + diff[SoftwareVersionSuggestionMetadata.FirstReleaseScalarPrefix + SoftwareReleaseSuggestionMetadata.FieldPublisherId] = _firstReleaseSelectedPublisher.Id.Value; + diff[SoftwareVersionSuggestionMetadata.FirstReleaseScalarPrefix + SoftwareReleaseSuggestionMetadata.FieldPlatformId] = _firstReleaseSelectedPlatform.Id.Value; + string firstReleaseDate = _firstReleaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + if(!string.IsNullOrEmpty(firstReleaseDate)) + diff[SoftwareVersionSuggestionMetadata.FirstReleaseScalarPrefix + SoftwareReleaseSuggestionMetadata.FieldReleaseDate] = firstReleaseDate; + if(_firstReleaseDatePrecision != 0) + diff[SoftwareVersionSuggestionMetadata.FirstReleaseScalarPrefix + SoftwareReleaseSuggestionMetadata.FieldReleaseDatePrecision] = _firstReleaseDatePrecision; + + // ── First-release junction adds (first_release. prefix) ── + AddPrefixedJunctionAdds(diff, _firstReleaseRegionAdds); + AddPrefixedJunctionAdds(diff, _firstReleaseLanguageAdds); + AddPrefixedJunctionAdds(diff, _firstReleaseBarcodeAdds); + AddPrefixedJunctionAdds(diff, _firstReleaseProductCodeAdds); + + _submitting = true; + try + { + string json = JsonSerializer.Serialize(diff); + var dto = new SuggestionDto + { + EntityType = (int?)SuggestionEntityType.SoftwareVersion, + EntityId = null, + Status = (int?)SuggestionStatus.Pending, + UserComment = string.IsNullOrWhiteSpace(_userComment) ? null : _userComment.Trim(), + SuggestedValuesJson = json + }; + + var (created, error) = await SuggestionsService.CreateAsync(dto); + + if(created is null) + { + _validationError = string.IsNullOrWhiteSpace(error) ? L["Failed to submit suggestion."].Value : error; + return; + } + + Snackbar.Add(L["Suggestion submitted. An administrator will review it."], Severity.Success); + MudDialog.Close(DialogResult.Ok(created)); + } + finally + { + _submitting = false; + } + } + + /// + /// Re-prefix every queued first-release junction-add key with + /// so the + /// server applier recognises them as release-side junctions to forward to the + /// release CreateAsync. The original key shape is group.add.<uuid>; + /// after prefixing it becomes first_release.group.add.<uuid>. + /// + static void AddPrefixedJunctionAdds(Dictionary diff, + Dictionary adds) + { + foreach(KeyValuePair kv in adds) + diff[SoftwareVersionSuggestionMetadata.FirstReleaseGroupPrefix + kv.Key] = kv.Value.Payload; + } +} diff --git a/Marechai/Resources/Services/SoftwareService.de.resx b/Marechai/Resources/Services/SoftwareService.de.resx index b4cb9e74..df3a250c 100644 --- a/Marechai/Resources/Services/SoftwareService.de.resx +++ b/Marechai/Resources/Services/SoftwareService.de.resx @@ -1252,4 +1252,14 @@ No platform + + Noch keine Versionen erfasst. + + + Neue Version vorschlagen + + + Schlagen Sie eine neue Version vor, die dem Katalog hinzugefügt werden soll. + + \ No newline at end of file diff --git a/Marechai/Resources/Services/SoftwareService.en.resx b/Marechai/Resources/Services/SoftwareService.en.resx index 3d23355c..16203f5c 100644 --- a/Marechai/Resources/Services/SoftwareService.en.resx +++ b/Marechai/Resources/Services/SoftwareService.en.resx @@ -1117,4 +1117,14 @@ No platform + + No versions recorded yet. + + + Suggest new version + + + Suggest a new version to be added to the catalogue. + + \ No newline at end of file diff --git a/Marechai/Resources/Services/SoftwareService.es.resx b/Marechai/Resources/Services/SoftwareService.es.resx index 33de4c28..8159ba24 100644 --- a/Marechai/Resources/Services/SoftwareService.es.resx +++ b/Marechai/Resources/Services/SoftwareService.es.resx @@ -1206,4 +1206,14 @@ No platform + + Aún no hay versiones registradas. + + + Sugerir nueva versión + + + Sugiere una nueva versión para añadir al catálogo. + + \ No newline at end of file diff --git a/Marechai/Resources/Services/SoftwareService.fr.resx b/Marechai/Resources/Services/SoftwareService.fr.resx index 3a73da9c..9722bfb0 100644 --- a/Marechai/Resources/Services/SoftwareService.fr.resx +++ b/Marechai/Resources/Services/SoftwareService.fr.resx @@ -1252,4 +1252,14 @@ No platform + + Aucune version enregistrée pour le moment. + + + Suggérer une nouvelle version + + + Suggérez une nouvelle version à ajouter au catalogue. + + \ No newline at end of file diff --git a/Marechai/Resources/Services/SoftwareService.it.resx b/Marechai/Resources/Services/SoftwareService.it.resx index 1446cdda..2671860a 100644 --- a/Marechai/Resources/Services/SoftwareService.it.resx +++ b/Marechai/Resources/Services/SoftwareService.it.resx @@ -1171,4 +1171,14 @@ No platform + + Nessuna versione registrata finora. + + + Suggerisci nuova versione + + + Suggerisci una nuova versione da aggiungere al catalogo. + + \ No newline at end of file diff --git a/Marechai/Resources/Services/SoftwareService.nl.resx b/Marechai/Resources/Services/SoftwareService.nl.resx index ce444eeb..8ee89f56 100644 --- a/Marechai/Resources/Services/SoftwareService.nl.resx +++ b/Marechai/Resources/Services/SoftwareService.nl.resx @@ -1624,4 +1624,14 @@ No platform + + Nog geen versies geregistreerd. + + + Nieuwe versie voorstellen + + + Stel een nieuwe versie voor om aan de catalogus toe te voegen. + + \ No newline at end of file diff --git a/Marechai/Resources/Services/SoftwareVersionsService.de.resx b/Marechai/Resources/Services/SoftwareVersionsService.de.resx index 33e97746..c329508c 100644 --- a/Marechai/Resources/Services/SoftwareVersionsService.de.resx +++ b/Marechai/Resources/Services/SoftwareVersionsService.de.resx @@ -289,4 +289,122 @@ Veröffentlichungen anlegen + + Schlagen Sie eine neue Version dieser Software vor. Eine erste Veröffentlichung ist gleichzeitig erforderlich; beide werden zusammen angelegt, sobald ein Administrator den Vorschlag akzeptiert. + + + Version von: {0} + + + Versionsinformationen + + + Die kanonische Versionsbezeichnung, z. B. „4.00.950“ oder „2.0“. + + + Optional. Der Vermarktungsname, z. B. „Windows 95“ für Version 4.00.950. + + + Optional. Interner Codename, der während der Entwicklung verwendet wurde. + + + Optional. Verknüpfung zu einer verwandten Version derselben Software (z. B. Service-Pack-Ketten). + + + Optional. Verwenden, wenn diese spezifische Version unter einer anderen Lizenz veröffentlicht wurde als die übergeordnete Software. + + + Erste Veröffentlichung + + + Für die begleitende Veröffentlichung sind Titel, Herausgeber und Plattform erforderlich. + + + Titel der Veröffentlichung + + + Der Titel der Veröffentlichung ist erforderlich. + + + Plattform + + + Herausgeber + + + Veröffentlichungsdatum + + + Erste Veröffentlichung: Regionen + + + Erste Veröffentlichung: Sprachen + + + Erste Veröffentlichung: Strichcodes + + + Erste Veröffentlichung: Produktcodes + + + Typ + + + Strichcode + + + Aussteller + + + Produktcode + + + Rückgängig + + + Region hinzufügen + + + Sprache hinzufügen + + + Strichcode hinzufügen + + + Produktcode hinzufügen + + + Dieser Strichcode ist bereits in der Liste vorhanden. + + + Dieser Produktcode ist bereits in der Liste vorhanden. + + + Optionaler Kommentar (Referenzen, Quellen usw.) + + + Vorschlag senden + + + Vorschlag gesendet. Ein Administrator wird ihn prüfen. + + + Senden des Vorschlags fehlgeschlagen. + + + Kann nicht gesendet werden: Übergeordnete Software fehlt. + + + Kann nicht geöffnet werden: Übergeordnete Software fehlt. + + + Auswahldaten konnten nicht geladen werden — bitte erneut versuchen. + + + Herausgeber ist erforderlich. + + + Neue Version vorschlagen + + diff --git a/Marechai/Resources/Services/SoftwareVersionsService.es.resx b/Marechai/Resources/Services/SoftwareVersionsService.es.resx index e935e79e..6216fa10 100644 --- a/Marechai/Resources/Services/SoftwareVersionsService.es.resx +++ b/Marechai/Resources/Services/SoftwareVersionsService.es.resx @@ -244,4 +244,122 @@ Precisión de fechaFecha completaSolo mes y añoSolo año Crear publicaciones + + Sugiere una nueva versión de este software. Se requiere también una primera publicación; ambas se crearán juntas cuando un administrador acepte la sugerencia. + + + Versión de: {0} + + + Información de la versión + + + El identificador canónico de la versión, p. ej. "4.00.950" o "2.0". + + + Opcional. El nombre comercial, p. ej. "Windows 95" para la versión 4.00.950. + + + Opcional. Nombre en clave interno utilizado durante el desarrollo. + + + Opcional. Enlace a una versión hermana del mismo software (p. ej. cadenas de service pack). + + + Opcional. Úsese cuando esta versión concreta se publicó bajo una licencia distinta de la del software principal. + + + Primera publicación + + + Para la publicación que la acompaña son obligatorios el título, el editor y la plataforma. + + + Título de la publicación + + + El título de la publicación es obligatorio. + + + Plataforma + + + Editor + + + Fecha de publicación + + + Primera publicación: regiones + + + Primera publicación: idiomas + + + Primera publicación: códigos de barras + + + Primera publicación: códigos de producto + + + Tipo + + + Código de barras + + + Emisor + + + Código de producto + + + Deshacer + + + Añadir región + + + Añadir idioma + + + Añadir código de barras + + + Añadir código de producto + + + Este código de barras ya está en la lista. + + + Este código de producto ya está en la lista. + + + Comentario opcional (referencias, fuentes, etc.) + + + Enviar sugerencia + + + Sugerencia enviada. Un administrador la revisará. + + + Error al enviar la sugerencia. + + + No se puede enviar: falta el software principal. + + + No se puede abrir: falta el software principal. + + + Error al cargar los datos del selector — vuelva a intentarlo. + + + El editor es obligatorio. + + + Sugerir nueva versión + + \ No newline at end of file diff --git a/Marechai/Resources/Services/SoftwareVersionsService.fr.resx b/Marechai/Resources/Services/SoftwareVersionsService.fr.resx index ccb8a0ec..73aa2965 100644 --- a/Marechai/Resources/Services/SoftwareVersionsService.fr.resx +++ b/Marechai/Resources/Services/SoftwareVersionsService.fr.resx @@ -289,4 +289,122 @@ Créer des publications + + Suggérez une nouvelle version de ce logiciel. Une première sortie est également requise ; les deux seront créées ensemble lorsqu'un administrateur acceptera la suggestion. + + + Version de : {0} + + + Informations sur la version + + + L’identifiant canonique de la version, par exemple « 4.00.950 » ou « 2.0 ». + + + Facultatif. Le nom commercial, par exemple « Windows 95 » pour la version 4.00.950. + + + Facultatif. Nom de code interne utilisé pendant le développement. + + + Facultatif. Lien vers une version sœur du même logiciel (par exemple chaînes de service packs). + + + Facultatif. À appliquer lorsque cette version spécifique a été publiée sous une licence différente de celle du logiciel parent. + + + Première sortie + + + Le titre, l’éditeur et la plateforme sont obligatoires pour la sortie associée. + + + Titre de la sortie + + + Le titre de la sortie est obligatoire. + + + Plateforme + + + Éditeur + + + Date de sortie + + + Première sortie : régions + + + Première sortie : langues + + + Première sortie : codes-barres + + + Première sortie : codes produit + + + Type + + + Code-barres + + + Émetteur + + + Code produit + + + Annuler + + + Ajouter une région + + + Ajouter une langue + + + Ajouter un code-barres + + + Ajouter un code produit + + + Ce code-barres est déjà présent dans la liste. + + + Ce code produit est déjà présent dans la liste. + + + Commentaire facultatif (références, sources, etc.) + + + Soumettre la suggestion + + + Suggestion soumise. Un administrateur l'examinera. + + + Échec de la soumission de la suggestion. + + + Impossible de soumettre : le logiciel parent est manquant. + + + Impossible d'ouvrir : le logiciel parent est manquant. + + + Échec du chargement des données du sélecteur — veuillez réessayer. + + + L’éditeur est obligatoire. + + + Suggérer une nouvelle version + + diff --git a/Marechai/Resources/Services/SoftwareVersionsService.it.resx b/Marechai/Resources/Services/SoftwareVersionsService.it.resx index 2577b0d9..f8deaf85 100644 --- a/Marechai/Resources/Services/SoftwareVersionsService.it.resx +++ b/Marechai/Resources/Services/SoftwareVersionsService.it.resx @@ -289,4 +289,122 @@ Crea pubblicazioni + + Suggerisci una nuova versione di questo software. È necessaria anche una prima uscita; entrambe verranno create insieme quando un amministratore accetterà il suggerimento. + + + Versione di: {0} + + + Informazioni sulla versione + + + L’identificatore canonico della versione, ad esempio "4.00.950" o "2.0". + + + Facoltativo. Il nome commerciale, ad esempio "Windows 95" per la versione 4.00.950. + + + Facoltativo. Nome in codice interno utilizzato durante lo sviluppo. + + + Facoltativo. Collegamento a una versione affine dello stesso software (ad es. catene di service pack). + + + Facoltativo. Da applicare quando questa specifica versione è stata rilasciata con una licenza diversa da quella del software principale. + + + Prima uscita + + + Per l’uscita associata sono obbligatori titolo, editore e piattaforma. + + + Titolo dell’uscita + + + Il titolo dell’uscita è obbligatorio. + + + Piattaforma + + + Editore + + + Data di uscita + + + Prima uscita: regioni + + + Prima uscita: lingue + + + Prima uscita: codici a barre + + + Prima uscita: codici prodotto + + + Tipo + + + Codice a barre + + + Emittente + + + Codice prodotto + + + Annulla + + + Aggiungi regione + + + Aggiungi lingua + + + Aggiungi codice a barre + + + Aggiungi codice prodotto + + + Questo codice a barre è già presente nell'elenco. + + + Questo codice prodotto è già presente nell'elenco. + + + Commento facoltativo (riferimenti, fonti, ecc.) + + + Invia suggerimento + + + Suggerimento inviato. Un amministratore lo esaminerà. + + + Invio del suggerimento non riuscito. + + + Impossibile inviare: software principale mancante. + + + Impossibile aprire: software principale mancante. + + + Caricamento dei dati del selettore non riuscito — riprovare. + + + L’editore è obbligatorio. + + + Suggerisci nuova versione + + diff --git a/Marechai/Resources/Services/SoftwareVersionsService.nl.resx b/Marechai/Resources/Services/SoftwareVersionsService.nl.resx index 25b5e201..9f79db9b 100644 --- a/Marechai/Resources/Services/SoftwareVersionsService.nl.resx +++ b/Marechai/Resources/Services/SoftwareVersionsService.nl.resx @@ -229,4 +229,122 @@ Uitgaven aanmaken + + Stel een nieuwe versie van deze software voor. Een eerste uitgave is tegelijkertijd vereist; beide worden samen aangemaakt wanneer een beheerder de suggestie accepteert. + + + Versie van: {0} + + + Versie-informatie + + + De canonieke versie-aanduiding, bijvoorbeeld "4.00.950" of "2.0". + + + Optioneel. De marketingnaam, bijvoorbeeld "Windows 95" voor versie 4.00.950. + + + Optioneel. Interne codenaam die tijdens de ontwikkeling werd gebruikt. + + + Optioneel. Koppeling naar een verwante versie van dezelfde software (bijv. service-pack-ketens). + + + Optioneel. Gebruik dit wanneer deze specifieke versie onder een andere licentie is uitgebracht dan de bovenliggende software. + + + Eerste uitgave + + + Titel, uitgever en platform zijn vereist voor de bijbehorende uitgave. + + + Titel van de uitgave + + + De titel van de uitgave is vereist. + + + Platform + + + Uitgever + + + Uitgavedatum + + + Eerste uitgave: regio's + + + Eerste uitgave: talen + + + Eerste uitgave: streepjescodes + + + Eerste uitgave: productcodes + + + Type + + + Streepjescode + + + Uitgever + + + Productcode + + + Ongedaan maken + + + Regio toevoegen + + + Taal toevoegen + + + Streepjescode toevoegen + + + Productcode toevoegen + + + Deze streepjescode staat al in de lijst. + + + Deze productcode staat al in de lijst. + + + Optionele opmerking (referenties, bronnen, enz.) + + + Suggestie indienen + + + Suggestie ingediend. Een beheerder zal deze beoordelen. + + + Kon suggestie niet indienen. + + + Kan niet indienen: bovenliggende software ontbreekt. + + + Kan niet openen: bovenliggende software ontbreekt. + + + Kon kiezergegevens niet laden — probeer het opnieuw. + + + Uitgever is vereist. + + + Nieuwe versie voorstellen + + diff --git a/Marechai/Suggestions/Metadata/SoftwareVersionSuggestionMetadata.cs b/Marechai/Suggestions/Metadata/SoftwareVersionSuggestionMetadata.cs new file mode 100644 index 00000000..a7ccedca --- /dev/null +++ b/Marechai/Suggestions/Metadata/SoftwareVersionSuggestionMetadata.cs @@ -0,0 +1,265 @@ +/****************************************************************************** +// MARECHAI: Master repository of computing history artifacts information +// ---------------------------------------------------------------------------- +// +// Author(s) : Natalia Portillo +// +// --[ License ] -------------------------------------------------------------- +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// ---------------------------------------------------------------------------- +// Copyright © 2003-2026 Natalia Portillo +*******************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using Marechai.ApiClient.Models; +using Marechai.Data; + +namespace Marechai.Suggestions.Metadata; + +/// +/// Suggestion metadata for the entity. Combined +/// parent+child atomic-creation shape — mirrors +/// verbatim except that the parent here is a SoftwareVersion (rather than a Software) +/// and there are no editable junctions on the version side. Exposes 5 directly editable +/// scalar fields PLUS the dual-prefix delegation surface for first-release scalars and +/// junction-op keys. +/// +/// +/// No junction groups are exposed on the SoftwareVersion half (Companies stays +/// admin-only, matching the existing admin SoftwareVersionDialog scope). +/// The release-side junctions surface via the first_release.<group>.add.X +/// shape and are resolved through the lazy accessor. +/// +/// +public sealed class SoftwareVersionSuggestionMetadata : SuggestionMetadata +{ + // ---- Scalar field names (mirror server-side constants) ----------------------------- + public const string FieldVersionString = "version_string"; + public const string FieldPublicVersion = "public_version"; + public const string FieldCodename = "codename"; + public const string FieldParentVersionId = "parent_version_id"; + public const string FieldLicenseId = "license_id"; + + /// + /// Pseudo-field carrying the parent FK at addition time + /// only. Mirrors the server-side + /// SoftwareVersionSuggestionApplier.FieldSoftwareId. NOT in + /// s_scalarFieldNames so the (unused-today) edit path would silently ignore + /// it; whitelisted in so the wire-side field-name + /// validator accepts it. + /// + public const string FieldSoftwareId = "software_id"; + + static readonly HashSet s_scalarFieldNames = new(StringComparer.Ordinal) + { + FieldVersionString, + FieldPublicVersion, + FieldCodename, + FieldParentVersionId, + FieldLicenseId + }; + + // ---- First-release pseudo-prefixes (mirror server-side; creation-mode only) -------- + /// + /// Field-name prefix for first-release SCALAR fields embedded in a SoftwareVersion + /// creation payload (e.g. first_release_title). Stripped before delegating + /// to for value formatting. + /// + public const string FirstReleaseScalarPrefix = "first_release_"; + + /// + /// Field-name prefix for first-release JUNCTION operation keys embedded in a + /// SoftwareVersion creation payload (e.g. + /// first_release.regions.add.<uuid>). + /// + public const string FirstReleaseGroupPrefix = "first_release."; + + /// + /// Lazily-instantiated release-side metadata for delegation. Allocated once on + /// first read; the static ctor of + /// registers a separate instance with the registry — that's fine because both + /// instances are identical, immutable behaviour bags. + /// + static SoftwareReleaseSuggestionMetadata s_releaseMetadata; + static SoftwareReleaseSuggestionMetadata ReleaseMetadata => + s_releaseMetadata ??= new SoftwareReleaseSuggestionMetadata(); + + static readonly IReadOnlyList s_fields = new SuggestionFieldDescriptor[] + { + new(FieldVersionString, "Version", SuggestionFieldKind.Text), + new(FieldPublicVersion, "Public version", SuggestionFieldKind.Text), + new(FieldCodename, "Codename", SuggestionFieldKind.Text), + new(FieldParentVersionId, "Parent version", SuggestionFieldKind.Text), + new(FieldLicenseId, "License", SuggestionFieldKind.Text) + }; + + static SoftwareVersionSuggestionMetadata() => + SuggestionMetadataRegistry.Register(new SoftwareVersionSuggestionMetadata()); + + /// Touch this to make sure the static constructor runs. + public static void EnsureRegistered() { /* triggers the static ctor */ } + + public override SuggestionEntityType EntityType => SuggestionEntityType.SoftwareVersion; + + public override IReadOnlyList Fields => s_fields; + + public override Dictionary ExtractCurrentValues(object currentDto) + { + var result = new Dictionary(StringComparer.Ordinal); + + if(currentDto is not SoftwareVersionDto v) return result; + + result[FieldVersionString] = v.VersionString; + result[FieldPublicVersion] = v.PublicVersion; + result[FieldCodename] = v.Codename; + result[FieldParentVersionId] = v.ParentVersionId; + result[FieldLicenseId] = v.LicenseId; + + return result; + } + + /// + /// Accepts static scalar field names, the parent-FK pseudo-field, AND first-release- + /// prefixed keys (delegated to ). + /// No junction-key parsing — Companies is admin-only on the version side. + /// + public override bool IsKnownFieldName(string name) + { + if(string.IsNullOrEmpty(name)) return false; + if(s_scalarFieldNames.Contains(name)) return true; + if(name == FieldSoftwareId) return true; + + // Test dot-prefix first (see SoftwareSuggestionMetadata comment for ordering). + if(name.StartsWith(FirstReleaseGroupPrefix, StringComparison.Ordinal)) + return ReleaseMetadata.IsKnownFieldName(name.Substring(FirstReleaseGroupPrefix.Length)); + if(name.StartsWith(FirstReleaseScalarPrefix, StringComparison.Ordinal)) + return ReleaseMetadata.IsKnownFieldName(name.Substring(FirstReleaseScalarPrefix.Length)); + + return false; + } + + /// + /// Falls back to the static descriptor lookup; first-release-prefixed keys delegate + /// to the release metadata so junction-add/remove keys are rendered with the same + /// row layout as a stand-alone release suggestion. + /// + public override SuggestionFieldKind GetFieldKind(string name) + { + if(FieldsByName.TryGetValue(name, out SuggestionFieldDescriptor d)) return d.Kind; + + if(name != null) + { + if(name.StartsWith(FirstReleaseGroupPrefix, StringComparison.Ordinal)) + return ReleaseMetadata.GetFieldKind(name.Substring(FirstReleaseGroupPrefix.Length)); + if(name.StartsWith(FirstReleaseScalarPrefix, StringComparison.Ordinal)) + return ReleaseMetadata.GetFieldKind(name.Substring(FirstReleaseScalarPrefix.Length)); + } + + return SuggestionFieldKind.Text; + } + + /// + /// Returns the descriptor label for static fields; for first-release-prefixed keys + /// returns the release-side label prepended with "First release: " so the + /// admin diff panel groups the release rows visually. + /// + public override string GetFieldLabelKey(string name) + { + if(FieldsByName.TryGetValue(name, out SuggestionFieldDescriptor d)) return d.LabelKey; + + if(name != null) + { + if(name.StartsWith(FirstReleaseGroupPrefix, StringComparison.Ordinal)) + return "First release: " + ReleaseMetadata.GetFieldLabelKey(name.Substring(FirstReleaseGroupPrefix.Length)); + if(name.StartsWith(FirstReleaseScalarPrefix, StringComparison.Ordinal)) + return "First release: " + ReleaseMetadata.GetFieldLabelKey(name.Substring(FirstReleaseScalarPrefix.Length)); + } + + return name; + } + + public override string FormatDisplayValue(string fieldName, object value) + { + if(value is null) return string.Empty; + + // First-release-prefixed values: delegate to the release-side formatter so + // rendering matches a stand-alone release suggestion. + if(fieldName != null) + { + if(fieldName.StartsWith(FirstReleaseGroupPrefix, StringComparison.Ordinal)) + return ReleaseMetadata.FormatDisplayValue(fieldName.Substring(FirstReleaseGroupPrefix.Length), value); + if(fieldName.StartsWith(FirstReleaseScalarPrefix, StringComparison.Ordinal)) + return ReleaseMetadata.FormatDisplayValue(fieldName.Substring(FirstReleaseScalarPrefix.Length), value); + } + + // FK ids: "#{id}" fallback when server didn't resolve a readable label. + if(fieldName == FieldParentVersionId) + { + ulong? u = ToUlong(value); + return u.HasValue ? $"#{u.Value}" : string.Empty; + } + if(fieldName == FieldLicenseId) + { + int? i = ToInt(value); + return i.HasValue ? $"#{i.Value}" : string.Empty; + } + + if(value is JsonElement je) + return je.ValueKind == JsonValueKind.Null ? string.Empty : je.ToString(); + + return value.ToString() ?? string.Empty; + } + + static int? ToInt(object v) + { + return v switch + { + int i => i, + short s => s, + long l => (int?)l, + ulong u => (int?)u, + JsonElement je => je.ValueKind == JsonValueKind.Number ? je.GetInt32() : null, + string str => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out int p) + ? p + : null, + _ => null + }; + } + + static ulong? ToUlong(object v) + { + return v switch + { + ulong u => u, + uint u => u, + int i => i >= 0 ? (ulong)i : null, + short s => s >= 0 ? (ulong)s : null, + long l => l >= 0 ? (ulong)l : null, + byte b => b, + JsonElement je => je.ValueKind == JsonValueKind.Number + ? je.TryGetUInt64(out ulong p) ? p : + je.TryGetInt64(out long pl) && pl >= 0 ? (ulong)pl : null + : null, + string str => ulong.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out ulong p) + ? p + : null, + _ => null + }; + } +} diff --git a/Marechai/Suggestions/SuggestionMetadataRegistry.cs b/Marechai/Suggestions/SuggestionMetadataRegistry.cs index 3e69805b..a036d08b 100644 --- a/Marechai/Suggestions/SuggestionMetadataRegistry.cs +++ b/Marechai/Suggestions/SuggestionMetadataRegistry.cs @@ -91,6 +91,7 @@ public static class SuggestionMetadataRegistry Marechai.Suggestions.Metadata.PersonSuggestionMetadata.EnsureRegistered(); Marechai.Suggestions.Metadata.SoftwareSuggestionMetadata.EnsureRegistered(); Marechai.Suggestions.Metadata.SoftwareReleaseSuggestionMetadata.EnsureRegistered(); + Marechai.Suggestions.Metadata.SoftwareVersionSuggestionMetadata.EnsureRegistered(); s_seeded = true; } }