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;
}
}