mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
Add software version suggestion functionality and localization updates
- Introduced SoftwareVersionSuggestionMetadata class to handle metadata for software version suggestions, including fields for version string, public version, codename, parent version ID, and license ID. - Updated SuggestionMetadataRegistry to register the new SoftwareVersionSuggestionMetadata. - Enhanced localization files for Spanish, French, Italian, and Dutch with new entries related to software version suggestions, including prompts for suggesting new versions, required fields, and error messages.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve readable labels for a SoftwareVersion creation suggestion. Resolves
|
||||
/// <c>software_id</c> to <c>Software.Name</c>, <c>parent_version_id</c> to its
|
||||
/// version string, and <c>license_id</c> to its name. First-release-prefixed keys
|
||||
/// are forwarded to <see cref="ResolveSoftwareReleaseLabelsAsync" /> via pre-strip
|
||||
/// + delegate (matches <see cref="ResolveSoftwareLabelsAsync" />). The applier has
|
||||
/// no junctions on the version side; companies stay admin-only.
|
||||
/// </summary>
|
||||
async Task ResolveSoftwareVersionLabelsAsync(long? entityId,
|
||||
Dictionary<string, JsonElement> values,
|
||||
Dictionary<string, string> 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<string, JsonElement>(StringComparer.Ordinal);
|
||||
foreach(KeyValuePair<string, JsonElement> 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<string, string>(StringComparer.Ordinal);
|
||||
await ResolveSoftwareReleaseLabelsAsync(null, releaseSubValues, releaseLabels);
|
||||
foreach(KeyValuePair<string, string> kv in releaseLabels)
|
||||
{
|
||||
string prefix = kv.Key.Contains('.', StringComparison.Ordinal)
|
||||
? Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseGroupPrefix
|
||||
: Suggestions.SoftwareVersionSuggestionApplier.FirstReleaseScalarPrefix;
|
||||
labels[prefix + kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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}",
|
||||
|
||||
@@ -69,6 +69,19 @@ internal static class SoftwareReleaseSuggestionApplier
|
||||
/// </summary>
|
||||
public const string FieldSoftwareId = "software_id";
|
||||
|
||||
/// <summary>
|
||||
/// Pseudo-field carrying the parent SoftwareVersion FK at addition time only
|
||||
/// (consumed by <see cref="CreateAsync" />). Same semantics as
|
||||
/// <see cref="FieldSoftwareId" />: NOT in <c>s_scalarFieldNames</c> (edit-mode
|
||||
/// re-parenting between versions stays admin-only), but whitelisted in
|
||||
/// <see cref="IsKnownFieldName" /> so the controller's field-name validator accepts
|
||||
/// it on the wire. Used by the combined SoftwareVersion + first-Release atomic
|
||||
/// creation flow (<c>Marechai.Server.Suggestions.SoftwareVersionSuggestionApplier</c>)
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
472
Marechai.Server/Suggestions/SoftwareVersionSuggestionApplier.cs
Normal file
472
Marechai.Server/Suggestions/SoftwareVersionSuggestionApplier.cs
Normal file
@@ -0,0 +1,472 @@
|
||||
/******************************************************************************
|
||||
// MARECHAI: Master repository of computing history artifacts information
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
||||
//
|
||||
// --[ 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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side applier for brand-new <see cref="SoftwareVersion" /> creation
|
||||
/// suggestions. Mirrors the combined parent+child atomic-creation pattern proven by
|
||||
/// <see cref="SoftwareSuggestionApplier" />: a SoftwareVersion has no useful surface
|
||||
/// without a SoftwareRelease, so every creation submission MUST carry a first-release
|
||||
/// half prefixed with <see cref="FirstReleaseScalarPrefix" /> / <see cref="FirstReleaseGroupPrefix" />,
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// Edit-mode suggestions for an existing SoftwareVersion are out of scope. The
|
||||
/// <see cref="ApplyAsync" /> stub returns <c>(empty, false)</c> so the dispatch
|
||||
/// switch in <c>SuggestionsController</c> stays exhaustive while no edit-mode UI
|
||||
/// exposes this entity type.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Re-parenting fields (<see cref="FieldSoftwareId" /> / <c>parent_version_id</c>
|
||||
/// used as a re-parent on edit) stay admin-only: <c>software_id</c> is whitelisted
|
||||
/// in <see cref="IsKnownFieldName" /> but NOT in <c>s_scalarFieldNames</c>, so the
|
||||
/// creation path picks it up while the (currently stubbed) edit path would ignore
|
||||
/// it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<string> s_scalarFieldNames = new(StringComparer.Ordinal)
|
||||
{
|
||||
FieldVersionString,
|
||||
FieldPublicVersion,
|
||||
FieldCodename,
|
||||
FieldParentVersionId,
|
||||
FieldLicenseId
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Pseudo-field carrying the parent <see cref="Software" /> FK at addition time
|
||||
/// only (consumed by <see cref="CreateAsync" />). Intentionally NOT in
|
||||
/// <c>s_scalarFieldNames</c>: edit-mode re-parenting between Softwares stays
|
||||
/// admin-only. Whitelisted in <see cref="IsKnownFieldName" /> so the controller's
|
||||
/// field-name validator accepts it on the wire.
|
||||
/// </summary>
|
||||
public const string FieldSoftwareId = "software_id";
|
||||
|
||||
// ---- First-release pseudo-prefixes (mirror server-side; creation-mode only) --------
|
||||
/// <summary>
|
||||
/// Field-name prefix for first-release SCALAR fields embedded in a SoftwareVersion
|
||||
/// creation payload (e.g. <c>first_release_title</c>). Stripped before delegating
|
||||
/// to <see cref="SoftwareReleaseSuggestionApplier" />.
|
||||
/// </summary>
|
||||
public const string FirstReleaseScalarPrefix = "first_release_";
|
||||
|
||||
/// <summary>
|
||||
/// Field-name prefix for first-release JUNCTION operation keys embedded in a
|
||||
/// SoftwareVersion creation payload (e.g.
|
||||
/// <c>first_release.regions.add.<uuid></c>). The dot delimits the original
|
||||
/// <c><group>.<op>.<token></c> shape and survives the strip
|
||||
/// verbatim.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edit-mode is out of scope for SoftwareVersion suggestions (no user-facing UI
|
||||
/// exposes it today). Stub returns <c>(empty, entityMissing=false)</c> so the
|
||||
/// dispatch switch in <c>SuggestionsController.ApplyAcceptedFieldsAsync</c> stays
|
||||
/// exhaustive without throwing <c>NotImplementedException</c> on an unrelated code
|
||||
/// path that might be reached e.g. by an admin manually pushing through an old
|
||||
/// edit suggestion shape.
|
||||
/// </summary>
|
||||
public static Task<(HashSet<string> applied, bool entityMissing)> ApplyAsync(
|
||||
MarechaiContext context, long entityId,
|
||||
Dictionary<string, object> suggested,
|
||||
HashSet<string> accepted)
|
||||
{
|
||||
_ = context;
|
||||
_ = entityId;
|
||||
_ = suggested;
|
||||
_ = accepted;
|
||||
return Task.FromResult((new HashSet<string>(StringComparer.Ordinal), false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static async Task<Dictionary<string, object>> 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<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
[FieldVersionString] = v.VersionString,
|
||||
[FieldPublicVersion] = v.PublicVersion,
|
||||
[FieldCodename] = v.Codename,
|
||||
[FieldParentVersionId] = v.ParentVersionId,
|
||||
[FieldLicenseId] = v.LicenseId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// The accepted/suggested dictionaries may contain four flavours of keys:
|
||||
/// <list type="bullet">
|
||||
/// <item>Top-level version scalars (<c>version_string</c>, <c>codename</c>, etc.).</item>
|
||||
/// <item>The parent-FK pseudo-field <see cref="FieldSoftwareId" />.</item>
|
||||
/// <item>First-release scalars prefixed with <see cref="FirstReleaseScalarPrefix" />
|
||||
/// (<c>first_release_title</c>, <c>first_release_publisher_id</c>, etc.).</item>
|
||||
/// <item>First-release junction-add keys prefixed with <see cref="FirstReleaseGroupPrefix" />
|
||||
/// (<c>first_release.regions.add.<uuid></c>, etc.).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Mandatory: <see cref="FieldSoftwareId" /> (FK existence-checked),
|
||||
/// <see cref="FieldVersionString" /> (non-whitespace),
|
||||
/// <c>first_release_title</c>, <c>first_release_publisher_id</c>,
|
||||
/// <c>first_release_platform_id</c>. The matching <c>software_id</c> and
|
||||
/// <c>software_version_id</c> on the release are injected after the version is
|
||||
/// saved; callers do not (and must not) provide them via the wire.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static async Task<(ulong? newId, HashSet<string> applied)> CreateAsync(
|
||||
MarechaiContext context,
|
||||
Dictionary<string, object> suggested,
|
||||
HashSet<string> accepted,
|
||||
string creditedUserId)
|
||||
{
|
||||
var applied = new HashSet<string>(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<string, object>(StringComparer.Ordinal);
|
||||
var versionAccepted = new HashSet<string>(StringComparer.Ordinal);
|
||||
var releaseSuggested = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
var releaseAccepted = new HashSet<string>(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<string> 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<string>(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<bool> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -663,45 +663,64 @@ else
|
||||
<MudTabPanel Text="@L["Releases"]" Icon="@Icons.Material.Filled.LibraryBooks" ToolTip="@L["Releases"]">
|
||||
|
||||
@* ── Versions Card ── *@
|
||||
@if(_versions.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Elevation="2" Class="mb-4 rounded-lg">
|
||||
<MudExpansionPanel>
|
||||
<MudExpansionPanel Expanded="@(_versions.Count > 0)">
|
||||
<TitleContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.History" Color="Color.Primary"/>
|
||||
<MudText Typo="Typo.h6">@L["Versions"]</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_versions.Count</MudChip>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Style="width: 100%;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.History" Color="Color.Primary"/>
|
||||
<MudText Typo="Typo.h6">@L["Versions"]</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_versions.Count</MudChip>
|
||||
</MudStack>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudTooltip Text="@L["Suggest a new version to be added to the catalogue."]">
|
||||
<span @onclick:stopPropagation="true">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AddCircle"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
OnClick="OpenSuggestNewVersionDialogAsync"
|
||||
aria-label="@L["Suggest new version"]"/>
|
||||
</span>
|
||||
</MudTooltip>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</MudStack>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
@foreach(SoftwareVersionDto version in _versions)
|
||||
@if(_versions.Count == 0)
|
||||
{
|
||||
<MudCard Elevation="1" Class="mb-3 rounded-lg">
|
||||
<MudCardContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Label" Color="Color.Secondary" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.subtitle1"><strong>@version.VersionString</strong></MudText>
|
||||
@if(!string.IsNullOrEmpty(version.PublicVersion))
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@L["No versions recorded yet."]</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach(SoftwareVersionDto version in _versions)
|
||||
{
|
||||
<MudCard Elevation="1" Class="mb-3 rounded-lg">
|
||||
<MudCardContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Label" Color="Color.Secondary" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.subtitle1"><strong>@version.VersionString</strong></MudText>
|
||||
@if(!string.IsNullOrEmpty(version.PublicVersion))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">(@version.PublicVersion)</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
@if(!string.IsNullOrEmpty(version.Codename))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">(@version.PublicVersion)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Tertiary" Class="mb-2">@L["Codename"]: @version.Codename</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
@if(!string.IsNullOrEmpty(version.Codename))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Tertiary" Class="mb-2">@L["Codename"]: @version.Codename</MudText>
|
||||
}
|
||||
@if(!string.IsNullOrEmpty(version.License))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Tertiary" Class="mb-2">@L["License"]: @version.License</MudText>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
@if(!string.IsNullOrEmpty(version.License))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Tertiary" Class="mb-2">@L["License"]: @version.License</MudText>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
}
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
|
||||
@* ── Releases Card (flat list) ── *@
|
||||
<MudExpansionPanels Elevation="2" Class="mb-4 rounded-lg">
|
||||
|
||||
@@ -813,6 +813,48 @@ public partial class View
|
||||
// No reload needed — pending suggestions only take effect after admin review.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>software_id</c> + display label are prefilled and shown as a read-only
|
||||
/// "Version of: {0}" alert.
|
||||
/// </summary>
|
||||
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<SoftwareVersionSuggestionDialog>
|
||||
{
|
||||
{ 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<SoftwareVersionSuggestionDialog>(
|
||||
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.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the SoftwarePromoArtSuggestionDialog so a logged-in collaborator can stage
|
||||
/// 1-30 pending promo art images for this Software, choose a group (free-text
|
||||
|
||||
680
Marechai/Pages/Suggestions/SoftwareVersionSuggestionDialog.razor
Normal file
680
Marechai/Pages/Suggestions/SoftwareVersionSuggestionDialog.razor
Normal file
@@ -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<SoftwareVersionsService> 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.
|
||||
*@
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@if(_loading)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
@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."]
|
||||
</MudText>
|
||||
|
||||
@* ── Read-only parent Software context (admin-only re-parenting) ── *@
|
||||
@if(!string.IsNullOrEmpty(PrefillSoftwareLabel))
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mb-3" Dense="true">
|
||||
@string.Format(L["Version of: {0}"], PrefillSoftwareLabel)
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* ── Version info ── *@
|
||||
<MudPaper Outlined="true" Class="pa-3 mb-3">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@L["Version info"]</MudText>
|
||||
|
||||
<MudTextField T="string" Label="@L["Version string"]"
|
||||
Required="true"
|
||||
RequiredError="@L["Version string is required."]"
|
||||
MaxLength="255"
|
||||
Value="@_versionString"
|
||||
ValueChanged="@(v => { _versionString = v; })"
|
||||
HelperText="@L["The canonical version identifier, e.g. \"4.00.950\" or \"2.0\"."]"
|
||||
Class="mb-3"/>
|
||||
|
||||
<MudTextField T="string" Label="@L["Public version"]"
|
||||
MaxLength="255"
|
||||
Value="@_publicVersion"
|
||||
ValueChanged="@(v => { _publicVersion = v; })"
|
||||
HelperText="@L["Optional. The marketing name, e.g. \"Windows 95\" for version 4.00.950."]"
|
||||
Class="mb-3"/>
|
||||
|
||||
<MudTextField T="string" Label="@L["Codename"]"
|
||||
MaxLength="255"
|
||||
Value="@_codename"
|
||||
ValueChanged="@(v => { _codename = v; })"
|
||||
HelperText="@L["Optional. Internal codename used during development."]"
|
||||
Class="mb-3"/>
|
||||
|
||||
<MudAutocomplete T="SoftwareVersionDto" Value="@_selectedParentVersion"
|
||||
ValueChanged="@(v => { _selectedParentVersion = v; })"
|
||||
Label="@L["Parent version"]"
|
||||
SearchFunc="SearchSiblingVersions"
|
||||
ToStringFunc="@(v => v is null ? string.Empty : v.VersionString ?? string.Empty)"
|
||||
Clearable="true"
|
||||
HelperText="@L["Optional. Link to a sibling version of the same software (e.g. service-pack chains)."]"
|
||||
Class="mb-3"/>
|
||||
|
||||
<MudAutocomplete T="LicenseDto" Value="@_selectedLicense"
|
||||
ValueChanged="@(l => { _selectedLicense = l; })"
|
||||
Label="@L["License"]"
|
||||
SearchFunc="SearchLicenses"
|
||||
ToStringFunc="@(l => l?.Name ?? string.Empty)"
|
||||
Clearable="true"
|
||||
HelperText="@L["Optional. Apply when this specific version was released under a license different from the parent software."]"
|
||||
Class="mb-3"/>
|
||||
</MudPaper>
|
||||
|
||||
@* ── First release info ── *@
|
||||
<MudPaper Outlined="true" Class="pa-3 mb-3">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@L["First release"]</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-3 d-block">
|
||||
@L["Title, publisher and platform are required for the accompanying release."]
|
||||
</MudText>
|
||||
|
||||
<MudTextField T="string" Label="@L["Release title"]"
|
||||
Required="true"
|
||||
RequiredError="@L["Release title is required."]"
|
||||
MaxLength="255"
|
||||
Value="@_firstReleaseTitle"
|
||||
ValueChanged="@(v => { _firstReleaseTitle = v; })"
|
||||
Class="mb-3"/>
|
||||
|
||||
<MudAutocomplete T="SoftwarePlatformDto" Value="@_firstReleaseSelectedPlatform"
|
||||
ValueChanged="@(p => { _firstReleaseSelectedPlatform = p; })"
|
||||
Label="@L["Platform"]"
|
||||
Required="true"
|
||||
SearchFunc="SearchPlatforms"
|
||||
ToStringFunc="@(p => p?.Name ?? string.Empty)"
|
||||
Clearable="true"
|
||||
Class="mb-3"/>
|
||||
|
||||
<MudAutocomplete T="CompanyDto" Value="@_firstReleaseSelectedPublisher"
|
||||
ValueChanged="@(c => { _firstReleaseSelectedPublisher = c; })"
|
||||
Label="@L["Publisher"]"
|
||||
Required="true"
|
||||
SearchFunc="SearchCompanies"
|
||||
ToStringFunc="@(c => c?.Name ?? string.Empty)"
|
||||
Clearable="true"
|
||||
Class="mb-3"/>
|
||||
|
||||
<MudDatePicker Label="@L["Release date"]" Editable="true" Clearable="true"
|
||||
Date="@_firstReleaseDate"
|
||||
DateChanged="@(d => { _firstReleaseDate = d; })" Class="mb-3"/>
|
||||
|
||||
<MudSelect T="int" Label="@L["Date precision"]"
|
||||
Value="@_firstReleaseDatePrecision"
|
||||
ValueChanged="@(v => { _firstReleaseDatePrecision = v; })">
|
||||
<MudSelectItem T="int" Value="0">@L["Full date"]</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1">@L["Month and year only"]</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="2">@L["Year only"]</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudPaper>
|
||||
|
||||
@* ── First release junctions (regions / languages / barcodes / product_codes) ── *@
|
||||
<MudExpansionPanels MultiExpansion="true" Class="mb-3">
|
||||
|
||||
@* Regions *@
|
||||
<MudExpansionPanel Text="@($"{L["First release: Regions"]} ({_firstReleaseRegionAdds.Count})")"
|
||||
Expanded="@(_firstReleaseRegionAdds.Count > 0)">
|
||||
@foreach(KeyValuePair<string, JunctionAddState> kv in _firstReleaseRegionAdds)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText><strong>+ @kv.Value.Display</strong></MudText>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Default"
|
||||
OnClick="@(() => _firstReleaseRegionAdds.Remove(kv.Key))">@L["Undo"]</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mt-2">
|
||||
<MudAutocomplete T="UnM49Dto" Value="@_pickFirstReleaseRegion"
|
||||
ValueChanged="@(r => { _pickFirstReleaseRegion = r; })"
|
||||
SearchFunc="SearchRegions"
|
||||
ToStringFunc="@(r => r?.Name ?? string.Empty)"
|
||||
Clearable="true" Style="flex: 1;"/>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="AddFirstReleaseRegion"
|
||||
Disabled="@(_pickFirstReleaseRegion is null)">@L["Add region"]</MudButton>
|
||||
</MudStack>
|
||||
</MudExpansionPanel>
|
||||
|
||||
@* Languages *@
|
||||
<MudExpansionPanel Text="@($"{L["First release: Languages"]} ({_firstReleaseLanguageAdds.Count})")"
|
||||
Expanded="@(_firstReleaseLanguageAdds.Count > 0)">
|
||||
@foreach(KeyValuePair<string, JunctionAddState> kv in _firstReleaseLanguageAdds)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText><strong>+ @kv.Value.Display</strong></MudText>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Default"
|
||||
OnClick="@(() => _firstReleaseLanguageAdds.Remove(kv.Key))">@L["Undo"]</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mt-2">
|
||||
<MudAutocomplete T="Iso639Dto" Value="@_pickFirstReleaseLanguage"
|
||||
ValueChanged="@(l => { _pickFirstReleaseLanguage = l; })"
|
||||
SearchFunc="SearchLanguages"
|
||||
ToStringFunc="@(l => l is null ? string.Empty : $"{l.ReferenceName} ({l.Id})")"
|
||||
Clearable="true" Style="flex: 1;"/>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="AddFirstReleaseLanguage"
|
||||
Disabled="@(_pickFirstReleaseLanguage is null)">@L["Add language"]</MudButton>
|
||||
</MudStack>
|
||||
</MudExpansionPanel>
|
||||
|
||||
@* Barcodes *@
|
||||
<MudExpansionPanel Text="@($"{L["First release: Barcodes"]} ({_firstReleaseBarcodeAdds.Count})")"
|
||||
Expanded="@(_firstReleaseBarcodeAdds.Count > 0)">
|
||||
@foreach(KeyValuePair<string, JunctionAddState> kv in _firstReleaseBarcodeAdds)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText><strong>+ @kv.Value.Display</strong></MudText>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Default"
|
||||
OnClick="@(() => _firstReleaseBarcodeAdds.Remove(kv.Key))">@L["Undo"]</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mt-2">
|
||||
<MudSelect T="int" Value="@_pickFirstReleaseBarcodeType"
|
||||
ValueChanged="@(v => { _pickFirstReleaseBarcodeType = v; })"
|
||||
Label="@L["Type"]" Style="min-width: 160px;">
|
||||
@foreach(BarcodeType bt in Enum.GetValues<BarcodeType>())
|
||||
{
|
||||
int val = (int)bt;
|
||||
<MudSelectItem T="int" Value="@val">@bt.Humanize()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" Label="@L["Barcode"]" Value="@_pickFirstReleaseBarcodeCode"
|
||||
ValueChanged="@(v => { _pickFirstReleaseBarcodeCode = v; })"
|
||||
MaxLength="64" Style="flex: 1;"/>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="AddFirstReleaseBarcode"
|
||||
Disabled="@(string.IsNullOrWhiteSpace(_pickFirstReleaseBarcodeCode))">@L["Add barcode"]</MudButton>
|
||||
</MudStack>
|
||||
</MudExpansionPanel>
|
||||
|
||||
@* Product codes *@
|
||||
<MudExpansionPanel Text="@($"{L["First release: Product codes"]} ({_firstReleaseProductCodeAdds.Count})")"
|
||||
Expanded="@(_firstReleaseProductCodeAdds.Count > 0)">
|
||||
@foreach(KeyValuePair<string, JunctionAddState> kv in _firstReleaseProductCodeAdds)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText><strong>+ @kv.Value.Display</strong></MudText>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Default"
|
||||
OnClick="@(() => _firstReleaseProductCodeAdds.Remove(kv.Key))">@L["Undo"]</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mt-2">
|
||||
<MudSelect T="int" Value="@_pickFirstReleaseProductCodeIssuer"
|
||||
ValueChanged="@(v => { _pickFirstReleaseProductCodeIssuer = v; })"
|
||||
Label="@L["Issuer"]" Style="min-width: 180px;">
|
||||
@foreach(ProductCodeIssuer pi in Enum.GetValues<ProductCodeIssuer>())
|
||||
{
|
||||
int val = (int)pi;
|
||||
<MudSelectItem T="int" Value="@val">@pi.Humanize()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField T="string" Label="@L["Product code"]" Value="@_pickFirstReleaseProductCodeCode"
|
||||
ValueChanged="@(v => { _pickFirstReleaseProductCodeCode = v; })"
|
||||
MaxLength="64" Style="flex: 1;"/>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="AddFirstReleaseProductCode"
|
||||
Disabled="@(string.IsNullOrWhiteSpace(_pickFirstReleaseProductCodeCode))">@L["Add product code"]</MudButton>
|
||||
</MudStack>
|
||||
</MudExpansionPanel>
|
||||
|
||||
</MudExpansionPanels>
|
||||
|
||||
<MudTextField T="string" Label="@L["Optional comment (References, sources, etc...)"]" Lines="2"
|
||||
MaxLength="2000" @bind-Value="_userComment"/>
|
||||
|
||||
@if(!string.IsNullOrWhiteSpace(_validationError))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-2">@_validationError</MudAlert>
|
||||
}
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">@L["Cancel"]</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit"
|
||||
Disabled="@(_loading || _submitting || !HasMandatoryFields())">
|
||||
@if(_submitting)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
@L["Submit suggestion"]
|
||||
}
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] IMudDialogInstance MudDialog { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unused in this dialog (creation-only) but kept on the parameter surface so the
|
||||
/// caller can use the standard <c>DialogParameters<T></c> shape. Always pass
|
||||
/// <c>0L</c> from the call site.
|
||||
/// </summary>
|
||||
[Parameter] public long EntityId { get; set; }
|
||||
|
||||
/// <summary>Parent <c>Software</c> id — required. The dialog refuses to submit without it.</summary>
|
||||
[Parameter] public ulong? PrefillSoftwareId { get; set; }
|
||||
|
||||
/// <summary>Display label for the prefilled parent Software (e.g. its name).</summary>
|
||||
[Parameter] public string PrefillSoftwareLabel { get; set; }
|
||||
|
||||
sealed class JunctionAddState
|
||||
{
|
||||
public Dictionary<string, object> 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<SoftwareVersionDto> _siblingVersions;
|
||||
List<LicenseDto> _allLicenses;
|
||||
|
||||
// ── First-release state (mirrors edit-mode release dialog state, adds-only) ──
|
||||
string _firstReleaseTitle;
|
||||
SoftwarePlatformDto _firstReleaseSelectedPlatform;
|
||||
CompanyDto _firstReleaseSelectedPublisher;
|
||||
DateTime? _firstReleaseDate;
|
||||
int _firstReleaseDatePrecision;
|
||||
|
||||
List<SoftwarePlatformDto> _allFirstReleasePlatforms;
|
||||
List<CompanyDto> _allFirstReleaseCompanies;
|
||||
List<UnM49Dto> _allFirstReleaseRegions;
|
||||
List<Iso639Dto> _allFirstReleaseLanguages;
|
||||
|
||||
Dictionary<string, JunctionAddState> _firstReleaseRegionAdds = new();
|
||||
Dictionary<string, JunctionAddState> _firstReleaseLanguageAdds = new();
|
||||
Dictionary<string, JunctionAddState> _firstReleaseBarcodeAdds = new();
|
||||
Dictionary<string, JunctionAddState> _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<List<SoftwareVersionDto>> siblingsT = VersionsService.GetBySoftwareAsync((int)PrefillSoftwareId.Value);
|
||||
Task<List<LicenseDto>> licensesT = VersionsService.GetAllLicensesAsync();
|
||||
|
||||
// First-release picker corpora (small enough to pre-load — same as the
|
||||
// existing SoftwareReleaseSuggestionDialog edit-mode flow).
|
||||
Task<List<SoftwarePlatformDto>> platformsT = ReleasesService.GetAllPlatformsAsync();
|
||||
Task<List<CompanyDto>> companiesT = ReleasesService.GetAllCompaniesAsync();
|
||||
Task<List<UnM49Dto>> regionsT = ReleasesService.GetAllUnM49Async();
|
||||
Task<List<Iso639Dto>> languagesT = ReleasesService.GetAllLanguagesAsync();
|
||||
|
||||
await Task.WhenAll(siblingsT, licensesT, platformsT, companiesT, regionsT, languagesT);
|
||||
|
||||
_siblingVersions = siblingsT.Result ?? new List<SoftwareVersionDto>();
|
||||
_allLicenses = licensesT.Result ?? new List<LicenseDto>();
|
||||
_allFirstReleasePlatforms = platformsT.Result ?? new List<SoftwarePlatformDto>();
|
||||
_allFirstReleaseCompanies = companiesT.Result ?? new List<CompanyDto>();
|
||||
_allFirstReleaseRegions = regionsT.Result ?? new List<UnM49Dto>();
|
||||
_allFirstReleaseLanguages = languagesT.Result ?? new List<Iso639Dto>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_validationError = L["Failed to load picker data — please retry."];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Search functions ─────────────────────────────
|
||||
|
||||
Task<IEnumerable<SoftwareVersionDto>> SearchSiblingVersions(string value, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<SoftwareVersionDto> 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<IEnumerable<LicenseDto>> SearchLicenses(string value, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<LicenseDto> r = _allLicenses ?? [];
|
||||
if(!string.IsNullOrWhiteSpace(value))
|
||||
r = r.Where(l => l.Name?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
return Task.FromResult(r.Take(50));
|
||||
}
|
||||
|
||||
Task<IEnumerable<SoftwarePlatformDto>> SearchPlatforms(string value, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<SoftwarePlatformDto> r = _allFirstReleasePlatforms ?? [];
|
||||
if(!string.IsNullOrWhiteSpace(value))
|
||||
r = r.Where(p => p.Name?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
return Task.FromResult(r.Take(50));
|
||||
}
|
||||
|
||||
Task<IEnumerable<CompanyDto>> SearchCompanies(string value, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<CompanyDto> r = _allFirstReleaseCompanies ?? [];
|
||||
if(!string.IsNullOrWhiteSpace(value))
|
||||
r = r.Where(c => c.Name?.Contains(value, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
return Task.FromResult(r.Take(50));
|
||||
}
|
||||
|
||||
Task<IEnumerable<UnM49Dto>> SearchRegions(string value, CancellationToken ct)
|
||||
{
|
||||
// Dedup against pending adds (no existing rows in creation mode).
|
||||
HashSet<int> assigned = new();
|
||||
foreach(JunctionAddState a in _firstReleaseRegionAdds.Values)
|
||||
if(GetIntField(a.Payload, "unm49_id") is int v) assigned.Add(v);
|
||||
IEnumerable<UnM49Dto> 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<IEnumerable<Iso639Dto>> SearchLanguages(string value, CancellationToken ct)
|
||||
{
|
||||
HashSet<string> 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<Iso639Dto> 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<string, object> 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<string, object> 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<string, object>(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<string, object>(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<string, object>(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<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["code"] = code,
|
||||
["issuer"] = _pickFirstReleaseProductCodeIssuer
|
||||
},
|
||||
Display = $"{issuer.Humanize()}: {code}"
|
||||
};
|
||||
_pickFirstReleaseProductCodeCode = string.Empty;
|
||||
}
|
||||
|
||||
// ───────────────────────────── Submit gate ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// UX gate for the Submit button. Mirrors the server's
|
||||
/// <c>SuggestionsController.ValidateAdditionPayload</c> arm for SoftwareVersion:
|
||||
/// parent software, version_string, first_release_title, first_release_publisher,
|
||||
/// first_release_platform must all be set.
|
||||
/// </summary>
|
||||
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<string, object>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-prefix every queued first-release junction-add key with
|
||||
/// <see cref="SoftwareVersionSuggestionMetadata.FirstReleaseGroupPrefix" /> so the
|
||||
/// server applier recognises them as release-side junctions to forward to the
|
||||
/// release CreateAsync. The original key shape is <c>group.add.<uuid></c>;
|
||||
/// after prefixing it becomes <c>first_release.group.add.<uuid></c>.
|
||||
/// </summary>
|
||||
static void AddPrefixedJunctionAdds(Dictionary<string, object> diff,
|
||||
Dictionary<string, JunctionAddState> adds)
|
||||
{
|
||||
foreach(KeyValuePair<string, JunctionAddState> kv in adds)
|
||||
diff[SoftwareVersionSuggestionMetadata.FirstReleaseGroupPrefix + kv.Key] = kv.Value.Payload;
|
||||
}
|
||||
}
|
||||
@@ -1252,4 +1252,14 @@
|
||||
<data name="No platform" xml:space="preserve">
|
||||
<value>No platform</value>
|
||||
</data>
|
||||
<data name="No versions recorded yet." xml:space="preserve">
|
||||
<value>Noch keine Versionen erfasst.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Neue Version vorschlagen</value>
|
||||
</data>
|
||||
<data name="Suggest a new version to be added to the catalogue." xml:space="preserve">
|
||||
<value>Schlagen Sie eine neue Version vor, die dem Katalog hinzugefügt werden soll.</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -1117,4 +1117,14 @@
|
||||
<data name="No platform" xml:space="preserve">
|
||||
<value>No platform</value>
|
||||
</data>
|
||||
<data name="No versions recorded yet." xml:space="preserve">
|
||||
<value>No versions recorded yet.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Suggest new version</value>
|
||||
</data>
|
||||
<data name="Suggest a new version to be added to the catalogue." xml:space="preserve">
|
||||
<value>Suggest a new version to be added to the catalogue.</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -1206,4 +1206,14 @@
|
||||
<data name="No platform" xml:space="preserve">
|
||||
<value>No platform</value>
|
||||
</data>
|
||||
<data name="No versions recorded yet." xml:space="preserve">
|
||||
<value>Aún no hay versiones registradas.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Sugerir nueva versión</value>
|
||||
</data>
|
||||
<data name="Suggest a new version to be added to the catalogue." xml:space="preserve">
|
||||
<value>Sugiere una nueva versión para añadir al catálogo.</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -1252,4 +1252,14 @@
|
||||
<data name="No platform" xml:space="preserve">
|
||||
<value>No platform</value>
|
||||
</data>
|
||||
<data name="No versions recorded yet." xml:space="preserve">
|
||||
<value>Aucune version enregistrée pour le moment.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Suggérer une nouvelle version</value>
|
||||
</data>
|
||||
<data name="Suggest a new version to be added to the catalogue." xml:space="preserve">
|
||||
<value>Suggérez une nouvelle version à ajouter au catalogue.</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -1171,4 +1171,14 @@
|
||||
<data name="No platform" xml:space="preserve">
|
||||
<value>No platform</value>
|
||||
</data>
|
||||
<data name="No versions recorded yet." xml:space="preserve">
|
||||
<value>Nessuna versione registrata finora.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Suggerisci nuova versione</value>
|
||||
</data>
|
||||
<data name="Suggest a new version to be added to the catalogue." xml:space="preserve">
|
||||
<value>Suggerisci una nuova versione da aggiungere al catalogo.</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -1624,4 +1624,14 @@
|
||||
<data name="No platform" xml:space="preserve">
|
||||
<value>No platform</value>
|
||||
</data>
|
||||
<data name="No versions recorded yet." xml:space="preserve">
|
||||
<value>Nog geen versies geregistreerd.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Nieuwe versie voorstellen</value>
|
||||
</data>
|
||||
<data name="Suggest a new version to be added to the catalogue." xml:space="preserve">
|
||||
<value>Stel een nieuwe versie voor om aan de catalogus toe te voegen.</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -289,4 +289,122 @@
|
||||
<data name="Create releases" xml:space="preserve">
|
||||
<value>Veröffentlichungen anlegen</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Version of: {0}" xml:space="preserve">
|
||||
<value>Version von: {0}</value>
|
||||
</data>
|
||||
<data name="Version info" xml:space="preserve">
|
||||
<value>Versionsinformationen</value>
|
||||
</data>
|
||||
<data name="The canonical version identifier, e.g. "4.00.950" or "2.0"." xml:space="preserve">
|
||||
<value>Die kanonische Versionsbezeichnung, z. B. „4.00.950“ oder „2.0“.</value>
|
||||
</data>
|
||||
<data name="Optional. The marketing name, e.g. "Windows 95" for version 4.00.950." xml:space="preserve">
|
||||
<value>Optional. Der Vermarktungsname, z. B. „Windows 95“ für Version 4.00.950.</value>
|
||||
</data>
|
||||
<data name="Optional. Internal codename used during development." xml:space="preserve">
|
||||
<value>Optional. Interner Codename, der während der Entwicklung verwendet wurde.</value>
|
||||
</data>
|
||||
<data name="Optional. Link to a sibling version of the same software (e.g. service-pack chains)." xml:space="preserve">
|
||||
<value>Optional. Verknüpfung zu einer verwandten Version derselben Software (z. B. Service-Pack-Ketten).</value>
|
||||
</data>
|
||||
<data name="Optional. Apply when this specific version was released under a license different from the parent software." xml:space="preserve">
|
||||
<value>Optional. Verwenden, wenn diese spezifische Version unter einer anderen Lizenz veröffentlicht wurde als die übergeordnete Software.</value>
|
||||
</data>
|
||||
<data name="First release" xml:space="preserve">
|
||||
<value>Erste Veröffentlichung</value>
|
||||
</data>
|
||||
<data name="Title, publisher and platform are required for the accompanying release." xml:space="preserve">
|
||||
<value>Für die begleitende Veröffentlichung sind Titel, Herausgeber und Plattform erforderlich.</value>
|
||||
</data>
|
||||
<data name="Release title" xml:space="preserve">
|
||||
<value>Titel der Veröffentlichung</value>
|
||||
</data>
|
||||
<data name="Release title is required." xml:space="preserve">
|
||||
<value>Der Titel der Veröffentlichung ist erforderlich.</value>
|
||||
</data>
|
||||
<data name="Platform" xml:space="preserve">
|
||||
<value>Plattform</value>
|
||||
</data>
|
||||
<data name="Publisher" xml:space="preserve">
|
||||
<value>Herausgeber</value>
|
||||
</data>
|
||||
<data name="Release date" xml:space="preserve">
|
||||
<value>Veröffentlichungsdatum</value>
|
||||
</data>
|
||||
<data name="First release: Regions" xml:space="preserve">
|
||||
<value>Erste Veröffentlichung: Regionen</value>
|
||||
</data>
|
||||
<data name="First release: Languages" xml:space="preserve">
|
||||
<value>Erste Veröffentlichung: Sprachen</value>
|
||||
</data>
|
||||
<data name="First release: Barcodes" xml:space="preserve">
|
||||
<value>Erste Veröffentlichung: Strichcodes</value>
|
||||
</data>
|
||||
<data name="First release: Product codes" xml:space="preserve">
|
||||
<value>Erste Veröffentlichung: Produktcodes</value>
|
||||
</data>
|
||||
<data name="Type" xml:space="preserve">
|
||||
<value>Typ</value>
|
||||
</data>
|
||||
<data name="Barcode" xml:space="preserve">
|
||||
<value>Strichcode</value>
|
||||
</data>
|
||||
<data name="Issuer" xml:space="preserve">
|
||||
<value>Aussteller</value>
|
||||
</data>
|
||||
<data name="Product code" xml:space="preserve">
|
||||
<value>Produktcode</value>
|
||||
</data>
|
||||
<data name="Undo" xml:space="preserve">
|
||||
<value>Rückgängig</value>
|
||||
</data>
|
||||
<data name="Add region" xml:space="preserve">
|
||||
<value>Region hinzufügen</value>
|
||||
</data>
|
||||
<data name="Add language" xml:space="preserve">
|
||||
<value>Sprache hinzufügen</value>
|
||||
</data>
|
||||
<data name="Add barcode" xml:space="preserve">
|
||||
<value>Strichcode hinzufügen</value>
|
||||
</data>
|
||||
<data name="Add product code" xml:space="preserve">
|
||||
<value>Produktcode hinzufügen</value>
|
||||
</data>
|
||||
<data name="This barcode is already present in the list." xml:space="preserve">
|
||||
<value>Dieser Strichcode ist bereits in der Liste vorhanden.</value>
|
||||
</data>
|
||||
<data name="This product code is already present in the list." xml:space="preserve">
|
||||
<value>Dieser Produktcode ist bereits in der Liste vorhanden.</value>
|
||||
</data>
|
||||
<data name="Optional comment (References, sources, etc...)" xml:space="preserve">
|
||||
<value>Optionaler Kommentar (Referenzen, Quellen usw.)</value>
|
||||
</data>
|
||||
<data name="Submit suggestion" xml:space="preserve">
|
||||
<value>Vorschlag senden</value>
|
||||
</data>
|
||||
<data name="Suggestion submitted. An administrator will review it." xml:space="preserve">
|
||||
<value>Vorschlag gesendet. Ein Administrator wird ihn prüfen.</value>
|
||||
</data>
|
||||
<data name="Failed to submit suggestion." xml:space="preserve">
|
||||
<value>Senden des Vorschlags fehlgeschlagen.</value>
|
||||
</data>
|
||||
<data name="Cannot submit: parent software is missing." xml:space="preserve">
|
||||
<value>Kann nicht gesendet werden: Übergeordnete Software fehlt.</value>
|
||||
</data>
|
||||
<data name="Cannot open: parent software is missing." xml:space="preserve">
|
||||
<value>Kann nicht geöffnet werden: Übergeordnete Software fehlt.</value>
|
||||
</data>
|
||||
<data name="Failed to load picker data — please retry." xml:space="preserve">
|
||||
<value>Auswahldaten konnten nicht geladen werden — bitte erneut versuchen.</value>
|
||||
</data>
|
||||
<data name="Publisher is required." xml:space="preserve">
|
||||
<value>Herausgeber ist erforderlich.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Neue Version vorschlagen</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
|
||||
@@ -244,4 +244,122 @@
|
||||
<data name="Date precision" xml:space="preserve"><value>Precisión de fecha</value></data><data name="Full date" xml:space="preserve"><value>Fecha completa</value></data><data name="Month and year only" xml:space="preserve"><value>Solo mes y año</value></data><data name="Year only" xml:space="preserve"><value>Solo año</value></data> <data name="Create releases" xml:space="preserve">
|
||||
<value>Crear publicaciones</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Version of: {0}" xml:space="preserve">
|
||||
<value>Versión de: {0}</value>
|
||||
</data>
|
||||
<data name="Version info" xml:space="preserve">
|
||||
<value>Información de la versión</value>
|
||||
</data>
|
||||
<data name="The canonical version identifier, e.g. "4.00.950" or "2.0"." xml:space="preserve">
|
||||
<value>El identificador canónico de la versión, p. ej. "4.00.950" o "2.0".</value>
|
||||
</data>
|
||||
<data name="Optional. The marketing name, e.g. "Windows 95" for version 4.00.950." xml:space="preserve">
|
||||
<value>Opcional. El nombre comercial, p. ej. "Windows 95" para la versión 4.00.950.</value>
|
||||
</data>
|
||||
<data name="Optional. Internal codename used during development." xml:space="preserve">
|
||||
<value>Opcional. Nombre en clave interno utilizado durante el desarrollo.</value>
|
||||
</data>
|
||||
<data name="Optional. Link to a sibling version of the same software (e.g. service-pack chains)." xml:space="preserve">
|
||||
<value>Opcional. Enlace a una versión hermana del mismo software (p. ej. cadenas de service pack).</value>
|
||||
</data>
|
||||
<data name="Optional. Apply when this specific version was released under a license different from the parent software." xml:space="preserve">
|
||||
<value>Opcional. Úsese cuando esta versión concreta se publicó bajo una licencia distinta de la del software principal.</value>
|
||||
</data>
|
||||
<data name="First release" xml:space="preserve">
|
||||
<value>Primera publicación</value>
|
||||
</data>
|
||||
<data name="Title, publisher and platform are required for the accompanying release." xml:space="preserve">
|
||||
<value>Para la publicación que la acompaña son obligatorios el título, el editor y la plataforma.</value>
|
||||
</data>
|
||||
<data name="Release title" xml:space="preserve">
|
||||
<value>Título de la publicación</value>
|
||||
</data>
|
||||
<data name="Release title is required." xml:space="preserve">
|
||||
<value>El título de la publicación es obligatorio.</value>
|
||||
</data>
|
||||
<data name="Platform" xml:space="preserve">
|
||||
<value>Plataforma</value>
|
||||
</data>
|
||||
<data name="Publisher" xml:space="preserve">
|
||||
<value>Editor</value>
|
||||
</data>
|
||||
<data name="Release date" xml:space="preserve">
|
||||
<value>Fecha de publicación</value>
|
||||
</data>
|
||||
<data name="First release: Regions" xml:space="preserve">
|
||||
<value>Primera publicación: regiones</value>
|
||||
</data>
|
||||
<data name="First release: Languages" xml:space="preserve">
|
||||
<value>Primera publicación: idiomas</value>
|
||||
</data>
|
||||
<data name="First release: Barcodes" xml:space="preserve">
|
||||
<value>Primera publicación: códigos de barras</value>
|
||||
</data>
|
||||
<data name="First release: Product codes" xml:space="preserve">
|
||||
<value>Primera publicación: códigos de producto</value>
|
||||
</data>
|
||||
<data name="Type" xml:space="preserve">
|
||||
<value>Tipo</value>
|
||||
</data>
|
||||
<data name="Barcode" xml:space="preserve">
|
||||
<value>Código de barras</value>
|
||||
</data>
|
||||
<data name="Issuer" xml:space="preserve">
|
||||
<value>Emisor</value>
|
||||
</data>
|
||||
<data name="Product code" xml:space="preserve">
|
||||
<value>Código de producto</value>
|
||||
</data>
|
||||
<data name="Undo" xml:space="preserve">
|
||||
<value>Deshacer</value>
|
||||
</data>
|
||||
<data name="Add region" xml:space="preserve">
|
||||
<value>Añadir región</value>
|
||||
</data>
|
||||
<data name="Add language" xml:space="preserve">
|
||||
<value>Añadir idioma</value>
|
||||
</data>
|
||||
<data name="Add barcode" xml:space="preserve">
|
||||
<value>Añadir código de barras</value>
|
||||
</data>
|
||||
<data name="Add product code" xml:space="preserve">
|
||||
<value>Añadir código de producto</value>
|
||||
</data>
|
||||
<data name="This barcode is already present in the list." xml:space="preserve">
|
||||
<value>Este código de barras ya está en la lista.</value>
|
||||
</data>
|
||||
<data name="This product code is already present in the list." xml:space="preserve">
|
||||
<value>Este código de producto ya está en la lista.</value>
|
||||
</data>
|
||||
<data name="Optional comment (References, sources, etc...)" xml:space="preserve">
|
||||
<value>Comentario opcional (referencias, fuentes, etc.)</value>
|
||||
</data>
|
||||
<data name="Submit suggestion" xml:space="preserve">
|
||||
<value>Enviar sugerencia</value>
|
||||
</data>
|
||||
<data name="Suggestion submitted. An administrator will review it." xml:space="preserve">
|
||||
<value>Sugerencia enviada. Un administrador la revisará.</value>
|
||||
</data>
|
||||
<data name="Failed to submit suggestion." xml:space="preserve">
|
||||
<value>Error al enviar la sugerencia.</value>
|
||||
</data>
|
||||
<data name="Cannot submit: parent software is missing." xml:space="preserve">
|
||||
<value>No se puede enviar: falta el software principal.</value>
|
||||
</data>
|
||||
<data name="Cannot open: parent software is missing." xml:space="preserve">
|
||||
<value>No se puede abrir: falta el software principal.</value>
|
||||
</data>
|
||||
<data name="Failed to load picker data — please retry." xml:space="preserve">
|
||||
<value>Error al cargar los datos del selector — vuelva a intentarlo.</value>
|
||||
</data>
|
||||
<data name="Publisher is required." xml:space="preserve">
|
||||
<value>El editor es obligatorio.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Sugerir nueva versión</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -289,4 +289,122 @@
|
||||
<data name="Create releases" xml:space="preserve">
|
||||
<value>Créer des publications</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Version of: {0}" xml:space="preserve">
|
||||
<value>Version de : {0}</value>
|
||||
</data>
|
||||
<data name="Version info" xml:space="preserve">
|
||||
<value>Informations sur la version</value>
|
||||
</data>
|
||||
<data name="The canonical version identifier, e.g. "4.00.950" or "2.0"." xml:space="preserve">
|
||||
<value>L’identifiant canonique de la version, par exemple « 4.00.950 » ou « 2.0 ».</value>
|
||||
</data>
|
||||
<data name="Optional. The marketing name, e.g. "Windows 95" for version 4.00.950." xml:space="preserve">
|
||||
<value>Facultatif. Le nom commercial, par exemple « Windows 95 » pour la version 4.00.950.</value>
|
||||
</data>
|
||||
<data name="Optional. Internal codename used during development." xml:space="preserve">
|
||||
<value>Facultatif. Nom de code interne utilisé pendant le développement.</value>
|
||||
</data>
|
||||
<data name="Optional. Link to a sibling version of the same software (e.g. service-pack chains)." xml:space="preserve">
|
||||
<value>Facultatif. Lien vers une version sœur du même logiciel (par exemple chaînes de service packs).</value>
|
||||
</data>
|
||||
<data name="Optional. Apply when this specific version was released under a license different from the parent software." xml:space="preserve">
|
||||
<value>Facultatif. À appliquer lorsque cette version spécifique a été publiée sous une licence différente de celle du logiciel parent.</value>
|
||||
</data>
|
||||
<data name="First release" xml:space="preserve">
|
||||
<value>Première sortie</value>
|
||||
</data>
|
||||
<data name="Title, publisher and platform are required for the accompanying release." xml:space="preserve">
|
||||
<value>Le titre, l’éditeur et la plateforme sont obligatoires pour la sortie associée.</value>
|
||||
</data>
|
||||
<data name="Release title" xml:space="preserve">
|
||||
<value>Titre de la sortie</value>
|
||||
</data>
|
||||
<data name="Release title is required." xml:space="preserve">
|
||||
<value>Le titre de la sortie est obligatoire.</value>
|
||||
</data>
|
||||
<data name="Platform" xml:space="preserve">
|
||||
<value>Plateforme</value>
|
||||
</data>
|
||||
<data name="Publisher" xml:space="preserve">
|
||||
<value>Éditeur</value>
|
||||
</data>
|
||||
<data name="Release date" xml:space="preserve">
|
||||
<value>Date de sortie</value>
|
||||
</data>
|
||||
<data name="First release: Regions" xml:space="preserve">
|
||||
<value>Première sortie : régions</value>
|
||||
</data>
|
||||
<data name="First release: Languages" xml:space="preserve">
|
||||
<value>Première sortie : langues</value>
|
||||
</data>
|
||||
<data name="First release: Barcodes" xml:space="preserve">
|
||||
<value>Première sortie : codes-barres</value>
|
||||
</data>
|
||||
<data name="First release: Product codes" xml:space="preserve">
|
||||
<value>Première sortie : codes produit</value>
|
||||
</data>
|
||||
<data name="Type" xml:space="preserve">
|
||||
<value>Type</value>
|
||||
</data>
|
||||
<data name="Barcode" xml:space="preserve">
|
||||
<value>Code-barres</value>
|
||||
</data>
|
||||
<data name="Issuer" xml:space="preserve">
|
||||
<value>Émetteur</value>
|
||||
</data>
|
||||
<data name="Product code" xml:space="preserve">
|
||||
<value>Code produit</value>
|
||||
</data>
|
||||
<data name="Undo" xml:space="preserve">
|
||||
<value>Annuler</value>
|
||||
</data>
|
||||
<data name="Add region" xml:space="preserve">
|
||||
<value>Ajouter une région</value>
|
||||
</data>
|
||||
<data name="Add language" xml:space="preserve">
|
||||
<value>Ajouter une langue</value>
|
||||
</data>
|
||||
<data name="Add barcode" xml:space="preserve">
|
||||
<value>Ajouter un code-barres</value>
|
||||
</data>
|
||||
<data name="Add product code" xml:space="preserve">
|
||||
<value>Ajouter un code produit</value>
|
||||
</data>
|
||||
<data name="This barcode is already present in the list." xml:space="preserve">
|
||||
<value>Ce code-barres est déjà présent dans la liste.</value>
|
||||
</data>
|
||||
<data name="This product code is already present in the list." xml:space="preserve">
|
||||
<value>Ce code produit est déjà présent dans la liste.</value>
|
||||
</data>
|
||||
<data name="Optional comment (References, sources, etc...)" xml:space="preserve">
|
||||
<value>Commentaire facultatif (références, sources, etc.)</value>
|
||||
</data>
|
||||
<data name="Submit suggestion" xml:space="preserve">
|
||||
<value>Soumettre la suggestion</value>
|
||||
</data>
|
||||
<data name="Suggestion submitted. An administrator will review it." xml:space="preserve">
|
||||
<value>Suggestion soumise. Un administrateur l'examinera.</value>
|
||||
</data>
|
||||
<data name="Failed to submit suggestion." xml:space="preserve">
|
||||
<value>Échec de la soumission de la suggestion.</value>
|
||||
</data>
|
||||
<data name="Cannot submit: parent software is missing." xml:space="preserve">
|
||||
<value>Impossible de soumettre : le logiciel parent est manquant.</value>
|
||||
</data>
|
||||
<data name="Cannot open: parent software is missing." xml:space="preserve">
|
||||
<value>Impossible d'ouvrir : le logiciel parent est manquant.</value>
|
||||
</data>
|
||||
<data name="Failed to load picker data — please retry." xml:space="preserve">
|
||||
<value>Échec du chargement des données du sélecteur — veuillez réessayer.</value>
|
||||
</data>
|
||||
<data name="Publisher is required." xml:space="preserve">
|
||||
<value>L’éditeur est obligatoire.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Suggérer une nouvelle version</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
|
||||
@@ -289,4 +289,122 @@
|
||||
<data name="Create releases" xml:space="preserve">
|
||||
<value>Crea pubblicazioni</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>Suggerisci una nuova versione di questo software. È necessaria anche una prima uscita; entrambe verranno create insieme quando un amministratore accetterà il suggerimento.</value>
|
||||
</data>
|
||||
<data name="Version of: {0}" xml:space="preserve">
|
||||
<value>Versione di: {0}</value>
|
||||
</data>
|
||||
<data name="Version info" xml:space="preserve">
|
||||
<value>Informazioni sulla versione</value>
|
||||
</data>
|
||||
<data name="The canonical version identifier, e.g. "4.00.950" or "2.0"." xml:space="preserve">
|
||||
<value>L’identificatore canonico della versione, ad esempio "4.00.950" o "2.0".</value>
|
||||
</data>
|
||||
<data name="Optional. The marketing name, e.g. "Windows 95" for version 4.00.950." xml:space="preserve">
|
||||
<value>Facoltativo. Il nome commerciale, ad esempio "Windows 95" per la versione 4.00.950.</value>
|
||||
</data>
|
||||
<data name="Optional. Internal codename used during development." xml:space="preserve">
|
||||
<value>Facoltativo. Nome in codice interno utilizzato durante lo sviluppo.</value>
|
||||
</data>
|
||||
<data name="Optional. Link to a sibling version of the same software (e.g. service-pack chains)." xml:space="preserve">
|
||||
<value>Facoltativo. Collegamento a una versione affine dello stesso software (ad es. catene di service pack).</value>
|
||||
</data>
|
||||
<data name="Optional. Apply when this specific version was released under a license different from the parent software." xml:space="preserve">
|
||||
<value>Facoltativo. Da applicare quando questa specifica versione è stata rilasciata con una licenza diversa da quella del software principale.</value>
|
||||
</data>
|
||||
<data name="First release" xml:space="preserve">
|
||||
<value>Prima uscita</value>
|
||||
</data>
|
||||
<data name="Title, publisher and platform are required for the accompanying release." xml:space="preserve">
|
||||
<value>Per l’uscita associata sono obbligatori titolo, editore e piattaforma.</value>
|
||||
</data>
|
||||
<data name="Release title" xml:space="preserve">
|
||||
<value>Titolo dell’uscita</value>
|
||||
</data>
|
||||
<data name="Release title is required." xml:space="preserve">
|
||||
<value>Il titolo dell’uscita è obbligatorio.</value>
|
||||
</data>
|
||||
<data name="Platform" xml:space="preserve">
|
||||
<value>Piattaforma</value>
|
||||
</data>
|
||||
<data name="Publisher" xml:space="preserve">
|
||||
<value>Editore</value>
|
||||
</data>
|
||||
<data name="Release date" xml:space="preserve">
|
||||
<value>Data di uscita</value>
|
||||
</data>
|
||||
<data name="First release: Regions" xml:space="preserve">
|
||||
<value>Prima uscita: regioni</value>
|
||||
</data>
|
||||
<data name="First release: Languages" xml:space="preserve">
|
||||
<value>Prima uscita: lingue</value>
|
||||
</data>
|
||||
<data name="First release: Barcodes" xml:space="preserve">
|
||||
<value>Prima uscita: codici a barre</value>
|
||||
</data>
|
||||
<data name="First release: Product codes" xml:space="preserve">
|
||||
<value>Prima uscita: codici prodotto</value>
|
||||
</data>
|
||||
<data name="Type" xml:space="preserve">
|
||||
<value>Tipo</value>
|
||||
</data>
|
||||
<data name="Barcode" xml:space="preserve">
|
||||
<value>Codice a barre</value>
|
||||
</data>
|
||||
<data name="Issuer" xml:space="preserve">
|
||||
<value>Emittente</value>
|
||||
</data>
|
||||
<data name="Product code" xml:space="preserve">
|
||||
<value>Codice prodotto</value>
|
||||
</data>
|
||||
<data name="Undo" xml:space="preserve">
|
||||
<value>Annulla</value>
|
||||
</data>
|
||||
<data name="Add region" xml:space="preserve">
|
||||
<value>Aggiungi regione</value>
|
||||
</data>
|
||||
<data name="Add language" xml:space="preserve">
|
||||
<value>Aggiungi lingua</value>
|
||||
</data>
|
||||
<data name="Add barcode" xml:space="preserve">
|
||||
<value>Aggiungi codice a barre</value>
|
||||
</data>
|
||||
<data name="Add product code" xml:space="preserve">
|
||||
<value>Aggiungi codice prodotto</value>
|
||||
</data>
|
||||
<data name="This barcode is already present in the list." xml:space="preserve">
|
||||
<value>Questo codice a barre è già presente nell'elenco.</value>
|
||||
</data>
|
||||
<data name="This product code is already present in the list." xml:space="preserve">
|
||||
<value>Questo codice prodotto è già presente nell'elenco.</value>
|
||||
</data>
|
||||
<data name="Optional comment (References, sources, etc...)" xml:space="preserve">
|
||||
<value>Commento facoltativo (riferimenti, fonti, ecc.)</value>
|
||||
</data>
|
||||
<data name="Submit suggestion" xml:space="preserve">
|
||||
<value>Invia suggerimento</value>
|
||||
</data>
|
||||
<data name="Suggestion submitted. An administrator will review it." xml:space="preserve">
|
||||
<value>Suggerimento inviato. Un amministratore lo esaminerà.</value>
|
||||
</data>
|
||||
<data name="Failed to submit suggestion." xml:space="preserve">
|
||||
<value>Invio del suggerimento non riuscito.</value>
|
||||
</data>
|
||||
<data name="Cannot submit: parent software is missing." xml:space="preserve">
|
||||
<value>Impossibile inviare: software principale mancante.</value>
|
||||
</data>
|
||||
<data name="Cannot open: parent software is missing." xml:space="preserve">
|
||||
<value>Impossibile aprire: software principale mancante.</value>
|
||||
</data>
|
||||
<data name="Failed to load picker data — please retry." xml:space="preserve">
|
||||
<value>Caricamento dei dati del selettore non riuscito — riprovare.</value>
|
||||
</data>
|
||||
<data name="Publisher is required." xml:space="preserve">
|
||||
<value>L’editore è obbligatorio.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Suggerisci nuova versione</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
|
||||
@@ -229,4 +229,122 @@
|
||||
<data name="Create releases" xml:space="preserve">
|
||||
<value>Uitgaven aanmaken</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>Stel een nieuwe versie van deze software voor. Een eerste uitgave is tegelijkertijd vereist; beide worden samen aangemaakt wanneer een beheerder de suggestie accepteert.</value>
|
||||
</data>
|
||||
<data name="Version of: {0}" xml:space="preserve">
|
||||
<value>Versie van: {0}</value>
|
||||
</data>
|
||||
<data name="Version info" xml:space="preserve">
|
||||
<value>Versie-informatie</value>
|
||||
</data>
|
||||
<data name="The canonical version identifier, e.g. "4.00.950" or "2.0"." xml:space="preserve">
|
||||
<value>De canonieke versie-aanduiding, bijvoorbeeld "4.00.950" of "2.0".</value>
|
||||
</data>
|
||||
<data name="Optional. The marketing name, e.g. "Windows 95" for version 4.00.950." xml:space="preserve">
|
||||
<value>Optioneel. De marketingnaam, bijvoorbeeld "Windows 95" voor versie 4.00.950.</value>
|
||||
</data>
|
||||
<data name="Optional. Internal codename used during development." xml:space="preserve">
|
||||
<value>Optioneel. Interne codenaam die tijdens de ontwikkeling werd gebruikt.</value>
|
||||
</data>
|
||||
<data name="Optional. Link to a sibling version of the same software (e.g. service-pack chains)." xml:space="preserve">
|
||||
<value>Optioneel. Koppeling naar een verwante versie van dezelfde software (bijv. service-pack-ketens).</value>
|
||||
</data>
|
||||
<data name="Optional. Apply when this specific version was released under a license different from the parent software." xml:space="preserve">
|
||||
<value>Optioneel. Gebruik dit wanneer deze specifieke versie onder een andere licentie is uitgebracht dan de bovenliggende software.</value>
|
||||
</data>
|
||||
<data name="First release" xml:space="preserve">
|
||||
<value>Eerste uitgave</value>
|
||||
</data>
|
||||
<data name="Title, publisher and platform are required for the accompanying release." xml:space="preserve">
|
||||
<value>Titel, uitgever en platform zijn vereist voor de bijbehorende uitgave.</value>
|
||||
</data>
|
||||
<data name="Release title" xml:space="preserve">
|
||||
<value>Titel van de uitgave</value>
|
||||
</data>
|
||||
<data name="Release title is required." xml:space="preserve">
|
||||
<value>De titel van de uitgave is vereist.</value>
|
||||
</data>
|
||||
<data name="Platform" xml:space="preserve">
|
||||
<value>Platform</value>
|
||||
</data>
|
||||
<data name="Publisher" xml:space="preserve">
|
||||
<value>Uitgever</value>
|
||||
</data>
|
||||
<data name="Release date" xml:space="preserve">
|
||||
<value>Uitgavedatum</value>
|
||||
</data>
|
||||
<data name="First release: Regions" xml:space="preserve">
|
||||
<value>Eerste uitgave: regio's</value>
|
||||
</data>
|
||||
<data name="First release: Languages" xml:space="preserve">
|
||||
<value>Eerste uitgave: talen</value>
|
||||
</data>
|
||||
<data name="First release: Barcodes" xml:space="preserve">
|
||||
<value>Eerste uitgave: streepjescodes</value>
|
||||
</data>
|
||||
<data name="First release: Product codes" xml:space="preserve">
|
||||
<value>Eerste uitgave: productcodes</value>
|
||||
</data>
|
||||
<data name="Type" xml:space="preserve">
|
||||
<value>Type</value>
|
||||
</data>
|
||||
<data name="Barcode" xml:space="preserve">
|
||||
<value>Streepjescode</value>
|
||||
</data>
|
||||
<data name="Issuer" xml:space="preserve">
|
||||
<value>Uitgever</value>
|
||||
</data>
|
||||
<data name="Product code" xml:space="preserve">
|
||||
<value>Productcode</value>
|
||||
</data>
|
||||
<data name="Undo" xml:space="preserve">
|
||||
<value>Ongedaan maken</value>
|
||||
</data>
|
||||
<data name="Add region" xml:space="preserve">
|
||||
<value>Regio toevoegen</value>
|
||||
</data>
|
||||
<data name="Add language" xml:space="preserve">
|
||||
<value>Taal toevoegen</value>
|
||||
</data>
|
||||
<data name="Add barcode" xml:space="preserve">
|
||||
<value>Streepjescode toevoegen</value>
|
||||
</data>
|
||||
<data name="Add product code" xml:space="preserve">
|
||||
<value>Productcode toevoegen</value>
|
||||
</data>
|
||||
<data name="This barcode is already present in the list." xml:space="preserve">
|
||||
<value>Deze streepjescode staat al in de lijst.</value>
|
||||
</data>
|
||||
<data name="This product code is already present in the list." xml:space="preserve">
|
||||
<value>Deze productcode staat al in de lijst.</value>
|
||||
</data>
|
||||
<data name="Optional comment (References, sources, etc...)" xml:space="preserve">
|
||||
<value>Optionele opmerking (referenties, bronnen, enz.)</value>
|
||||
</data>
|
||||
<data name="Submit suggestion" xml:space="preserve">
|
||||
<value>Suggestie indienen</value>
|
||||
</data>
|
||||
<data name="Suggestion submitted. An administrator will review it." xml:space="preserve">
|
||||
<value>Suggestie ingediend. Een beheerder zal deze beoordelen.</value>
|
||||
</data>
|
||||
<data name="Failed to submit suggestion." xml:space="preserve">
|
||||
<value>Kon suggestie niet indienen.</value>
|
||||
</data>
|
||||
<data name="Cannot submit: parent software is missing." xml:space="preserve">
|
||||
<value>Kan niet indienen: bovenliggende software ontbreekt.</value>
|
||||
</data>
|
||||
<data name="Cannot open: parent software is missing." xml:space="preserve">
|
||||
<value>Kan niet openen: bovenliggende software ontbreekt.</value>
|
||||
</data>
|
||||
<data name="Failed to load picker data — please retry." xml:space="preserve">
|
||||
<value>Kon kiezergegevens niet laden — probeer het opnieuw.</value>
|
||||
</data>
|
||||
<data name="Publisher is required." xml:space="preserve">
|
||||
<value>Uitgever is vereist.</value>
|
||||
</data>
|
||||
<data name="Suggest new version" xml:space="preserve">
|
||||
<value>Nieuwe versie voorstellen</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/******************************************************************************
|
||||
// MARECHAI: Master repository of computing history artifacts information
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Author(s) : Natalia Portillo <claunia@claunia.com>
|
||||
//
|
||||
// --[ 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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Suggestion metadata for the <see cref="SoftwareVersionDto" /> entity. Combined
|
||||
/// parent+child atomic-creation shape — mirrors <see cref="SoftwareSuggestionMetadata" />
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// No junction groups are exposed on the SoftwareVersion half (Companies stays
|
||||
/// admin-only, matching the existing admin <c>SoftwareVersionDialog</c> scope).
|
||||
/// The release-side junctions surface via the <c>first_release.<group>.add.X</c>
|
||||
/// shape and are resolved through the lazy <see cref="ReleaseMetadata" /> accessor.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Pseudo-field carrying the parent <see cref="SoftwareDto" /> FK at addition time
|
||||
/// only. Mirrors the server-side
|
||||
/// <c>SoftwareVersionSuggestionApplier.FieldSoftwareId</c>. NOT in
|
||||
/// <c>s_scalarFieldNames</c> so the (unused-today) edit path would silently ignore
|
||||
/// it; whitelisted in <see cref="IsKnownFieldName" /> so the wire-side field-name
|
||||
/// validator accepts it.
|
||||
/// </summary>
|
||||
public const string FieldSoftwareId = "software_id";
|
||||
|
||||
static readonly HashSet<string> s_scalarFieldNames = new(StringComparer.Ordinal)
|
||||
{
|
||||
FieldVersionString,
|
||||
FieldPublicVersion,
|
||||
FieldCodename,
|
||||
FieldParentVersionId,
|
||||
FieldLicenseId
|
||||
};
|
||||
|
||||
// ---- First-release pseudo-prefixes (mirror server-side; creation-mode only) --------
|
||||
/// <summary>
|
||||
/// Field-name prefix for first-release SCALAR fields embedded in a SoftwareVersion
|
||||
/// creation payload (e.g. <c>first_release_title</c>). Stripped before delegating
|
||||
/// to <see cref="SoftwareReleaseSuggestionMetadata" /> for value formatting.
|
||||
/// </summary>
|
||||
public const string FirstReleaseScalarPrefix = "first_release_";
|
||||
|
||||
/// <summary>
|
||||
/// Field-name prefix for first-release JUNCTION operation keys embedded in a
|
||||
/// SoftwareVersion creation payload (e.g.
|
||||
/// <c>first_release.regions.add.<uuid></c>).
|
||||
/// </summary>
|
||||
public const string FirstReleaseGroupPrefix = "first_release.";
|
||||
|
||||
/// <summary>
|
||||
/// Lazily-instantiated release-side metadata for delegation. Allocated once on
|
||||
/// first read; the static ctor of <see cref="SoftwareReleaseSuggestionMetadata" />
|
||||
/// registers a separate instance with the registry — that's fine because both
|
||||
/// instances are identical, immutable behaviour bags.
|
||||
/// </summary>
|
||||
static SoftwareReleaseSuggestionMetadata s_releaseMetadata;
|
||||
static SoftwareReleaseSuggestionMetadata ReleaseMetadata =>
|
||||
s_releaseMetadata ??= new SoftwareReleaseSuggestionMetadata();
|
||||
|
||||
static readonly IReadOnlyList<SuggestionFieldDescriptor> 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());
|
||||
|
||||
/// <summary>Touch this to make sure the static constructor runs.</summary>
|
||||
public static void EnsureRegistered() { /* triggers the static ctor */ }
|
||||
|
||||
public override SuggestionEntityType EntityType => SuggestionEntityType.SoftwareVersion;
|
||||
|
||||
public override IReadOnlyList<SuggestionFieldDescriptor> Fields => s_fields;
|
||||
|
||||
public override Dictionary<string, object> ExtractCurrentValues(object currentDto)
|
||||
{
|
||||
var result = new Dictionary<string, object>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts static scalar field names, the parent-FK pseudo-field, AND first-release-
|
||||
/// prefixed keys (delegated to <see cref="SoftwareReleaseSuggestionMetadata" />).
|
||||
/// No junction-key parsing — Companies is admin-only on the version side.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the descriptor label for static fields; for first-release-prefixed keys
|
||||
/// returns the release-side label prepended with <c>"First release: "</c> so the
|
||||
/// admin diff panel groups the release rows visually.
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user