Files
marechai/Marechai.App/Presentation/ViewModels/SoftwareReleaseViewViewModel.cs
Natalia Portillo effa7b6791 feat: Complete software compilation refactor across client, UI, and importer
Continues the prior commit (model/migrations/server) by finishing every
remaining consumer of the SoftwareCompilation entity introduced there.

- Regenerate Marechai.ApiClient from the updated OpenAPI schema: new
  SoftwareCompilations request builders and DTOs
  (SoftwareCompilationDto, SoftwareCompilationSuccessorDto,
  SoftwareBySoftwareCompilationDto, SoftwareVersionBySoftwareCompilationDto),
  SoftwareReleaseDto's IsCompilation replaced with SoftwareCompilationId/
  SoftwareCompilation. Commit the openapi.yaml snapshot used to drive the
  regeneration.

- Marechai (Blazor): add SoftwareCompilationsService and a full admin UI
  for the new entity (SoftwareCompilations grid + SoftwareCompilationDialog
  with bundled-software/machine pickers, predecessor/relationship type,
  and tabs for included software, included versions, sub-compilations,
  and releases) plus a public detail page at /software-compilation/{id}.
  SoftwareReleaseDialog drops the old IsCompilation checkbox and per-release
  included-items management in favor of a SoftwareCompilation autocomplete
  picker, since compilation membership now lives on the compilation entity
  itself rather than on individual releases. Updated every remaining
  IsCompilation/old-junction-DTO reference in release dialogs, the public
  release/software view pages, and the suggestion dialog. Search.razor now
  routes compilation rows to /software-compilation/{id} instead of a release.

- Marechai.App (Uno): mechanical fixes for the removed IsCompilation
  property and old junction DTOs, and the same scope cut as the Blazor
  app — included-items management removed from the release editor and
  read-only release view (that UI now belongs solely on the compilation),
  with the now-dead helper methods and XAML sections removed.

- Marechai.MobyGames: CompilationRelationService and ImportService now
  create a real SoftwareCompilation row instead of flipping a release flag,
  and resolve each contained slug to either a Software or an existing
  SoftwareCompilation. This fixes a long-standing bug where a slug
  previously converted into a compilation had no replacement pointer
  recorded on MobyGamesImportState, so re-encountering it as a nested
  member of another compilation could never resolve. Nested compilations
  are now linked via SoftwareCompilationBySoftwareCompilation.
  StateService.MarkImportedAsync gained a softwareCompilationId parameter,
  and OrphanCleanupService's software-merge dedup pass was re-keyed from
  the old release-keyed junction to SoftwareBySoftwareCompilation.

Full solution build is clean with no pending EF model changes against the
three migrations from the prior commit.
2026-06-26 04:54:35 +01:00

363 lines
13 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using Humanizer;
using Marechai.App.Navigation;
using Marechai.App.Presentation.Views;
using Marechai.App.Services;
using Marechai.Data;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
namespace Marechai.App.Presentation.ViewModels;
[Bindable]
public partial class SoftwareReleaseViewViewModel : ObservableObject, IRegionAware
{
private readonly SoftwareBrowsingService _browsingService;
private readonly IStringLocalizer _localizer;
private readonly ILogger<SoftwareReleaseViewViewModel> _logger;
private readonly IRegionManager _regionManager;
private int _sourceSoftwareId;
[ObservableProperty]
private string _releaseTitle = string.Empty;
[ObservableProperty]
private string? _softwareName;
[ObservableProperty]
private string? _versionString;
[ObservableProperty]
private string? _platform;
[ObservableProperty]
private string? _regionsDisplay;
[ObservableProperty]
private string? _languagesDisplay;
[ObservableProperty]
private string? _title;
[ObservableProperty]
private string? _publisher;
[ObservableProperty]
private string? _releaseDateDisplay;
[ObservableProperty]
private string _errorMessage = string.Empty;
[ObservableProperty]
private bool _hasError;
[ObservableProperty]
private bool _isDataLoaded;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private Visibility _showPlatform = Visibility.Collapsed;
[ObservableProperty]
private Visibility _showRegions = Visibility.Collapsed;
[ObservableProperty]
private Visibility _showLanguages = Visibility.Collapsed;
[ObservableProperty]
private Visibility _showTitle = Visibility.Collapsed;
[ObservableProperty]
private Visibility _showPublisher = Visibility.Collapsed;
[ObservableProperty]
private Visibility _showReleaseDate = Visibility.Collapsed;
[ObservableProperty]
private Visibility _showBarcodes = Visibility.Collapsed;
[ObservableProperty]
private Visibility _showProductCodes = Visibility.Collapsed;
[ObservableProperty]
private Visibility _showCompanies = Visibility.Collapsed;
[ObservableProperty]
private bool _isCompilation;
public SoftwareReleaseViewViewModel(ILogger<SoftwareReleaseViewViewModel> logger,
IRegionManager regionManager,
SoftwareBrowsingService browsingService,
IStringLocalizer localizer)
{
_logger = logger;
_regionManager = regionManager;
_browsingService = browsingService;
_localizer = localizer;
}
public ObservableCollection<string> Barcodes { get; } = [];
public ObservableCollection<string> ProductCodes { get; } = [];
public ObservableCollection<string> Companies { get; } = [];
public bool IsNavigationTarget(NavigationContext navigationContext) => false;
public void OnNavigatedFrom(NavigationContext navigationContext) { }
public void OnNavigatedTo(NavigationContext navigationContext)
{
if(navigationContext.Parameters.TryGetValue<int>(NavParamKeys.SoftwareId, out int softwareId))
_sourceSoftwareId = softwareId;
if(navigationContext.Parameters.TryGetValue<int>(NavParamKeys.SoftwareReleaseId, out int releaseId))
_ = LoadReleaseAsync(releaseId);
}
[RelayCommand]
public Task GoBack()
{
var parameters = new NavigationParameters
{
{ NavParamKeys.SoftwareId, _sourceSoftwareId },
{ NavParamKeys.NavigationSource, nameof(SoftwareListViewModel) }
};
_regionManager.RequestNavigate(RegionNames.Content, nameof(SoftwareViewPage), parameters);
return Task.CompletedTask;
}
[RelayCommand]
public Task LoadData()
{
HasError = false;
ErrorMessage = string.Empty;
return Task.CompletedTask;
}
private async Task LoadReleaseAsync(int releaseId)
{
try
{
IsLoading = true;
IsDataLoaded = false;
HasError = false;
ErrorMessage = string.Empty;
Barcodes.Clear();
ProductCodes.Clear();
Companies.Clear();
SoftwareReleaseDto? release = await _browsingService.GetReleaseByIdAsync(releaseId);
if(release is null)
{
HasError = true;
ErrorMessage = _localizer["Release not found"];
IsLoading = false;
return;
}
VersionString = release.SoftwareVersion;
Platform = release.Platform;
RegionsDisplay = release.Regions is { Count: > 0 }
? string.Join(", ", release.Regions.Select(r => r.RegionName))
: null;
LanguagesDisplay = release.Languages is { Count: > 0 }
? string.Join(", ", release.Languages.Select(l => l.Language))
: null;
Publisher = release.Publisher;
if(release.ReleaseDate.HasValue)
ReleaseDateDisplay = (release.ReleaseDatePrecision ?? 0) == 2 ? $"{release.ReleaseDate.Value.Year}" : (release.ReleaseDatePrecision ?? 0) == 1 ? release.ReleaseDate.Value.ToString("MMMM yyyy") : release.ReleaseDate.Value.DateTime.ToString("MMMM d, yyyy");
// Determine if this is a compilation
IsCompilation = release.SoftwareCompilationId is not null;
if(IsCompilation)
{
// Compilation: use Title or fallback. Included software/versions are
// managed and displayed on the compilation's own page, not here.
ReleaseTitle = release.Title ?? _localizer["Compilation"];
}
else
{
// Single release: build title from version string or software name
if(!string.IsNullOrEmpty(VersionString))
{
ReleaseTitle = VersionString;
}
else
{
// Versionless single release: use software name from DTO
ReleaseTitle = release.Software ?? _localizer["Software Release"];
}
// Show Title if set
if(!string.IsNullOrEmpty(release.Title))
Title = release.Title;
// Load software name for display
if(!string.IsNullOrEmpty(release.Software))
{
SoftwareName = release.Software;
}
else if(_sourceSoftwareId > 0)
{
SoftwareDto? software = await _browsingService.GetSoftwareByIdAsync(_sourceSoftwareId);
SoftwareName = software?.Name;
}
}
// Load barcodes
List<SoftwareBarcodeDto> barcodes = await _browsingService.GetBarcodesAsync(releaseId);
foreach(SoftwareBarcodeDto barcode in barcodes)
Barcodes.Add($"{barcode.Code} ({((BarcodeType)(barcode.Type ?? 0)).Humanize()})");
// Load product codes
List<SoftwareProductCodeDto> productCodes = await _browsingService.GetProductCodesAsync(releaseId);
foreach(SoftwareProductCodeDto pc in productCodes)
ProductCodes.Add($"{pc.Code} ({((ProductCodeIssuer)(pc.Issuer ?? 0)).Humanize()})");
// Load all companies from all junction levels
await LoadAllCompaniesAsync(release);
UpdateVisibilities();
IsDataLoaded = true;
IsLoading = false;
}
catch(Exception ex)
{
_logger.LogError(ex, "Error loading release {ReleaseId}", releaseId);
HasError = true;
ErrorMessage = ex.Message;
IsLoading = false;
}
}
private void UpdateVisibilities()
{
ShowPlatform = !string.IsNullOrEmpty(Platform) ? Visibility.Visible : Visibility.Collapsed;
ShowRegions = !string.IsNullOrEmpty(RegionsDisplay) ? Visibility.Visible : Visibility.Collapsed;
ShowLanguages = !string.IsNullOrEmpty(LanguagesDisplay) ? Visibility.Visible : Visibility.Collapsed;
ShowTitle = !string.IsNullOrEmpty(Title) ? Visibility.Visible : Visibility.Collapsed;
ShowPublisher = !string.IsNullOrEmpty(Publisher) ? Visibility.Visible : Visibility.Collapsed;
ShowReleaseDate = !string.IsNullOrEmpty(ReleaseDateDisplay) ? Visibility.Visible : Visibility.Collapsed;
ShowBarcodes = Barcodes.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
ShowProductCodes = ProductCodes.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
ShowCompanies = Companies.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
private async Task LoadAllCompaniesAsync(SoftwareReleaseDto release)
{
var companySet = new HashSet<(int companyId, string roleId)>();
var companyDisplays = new List<(string company, string? role)>();
// 1. Companies from the software version
if(release.SoftwareVersionId.HasValue)
{
int versionId = release.SoftwareVersionId.Value;
List<CompanyBySoftwareVersionDto> versionCompanies =
await _browsingService.GetCompaniesByVersionAsync(versionId);
foreach(CompanyBySoftwareVersionDto c in versionCompanies)
{
var key = (c.CompanyId ?? 0, c.RoleId ?? string.Empty);
if(companySet.Add(key))
companyDisplays.Add((c.Company ?? string.Empty, c.Role));
}
// 2. Companies from the software itself
SoftwareVersionDto? version = await _browsingService.GetVersionByIdAsync(versionId);
if(version?.SoftwareId is > 0)
{
int softwareId = version.SoftwareId.Value;
List<SoftwareCompanyRoleDto> softwareCompanies =
await _browsingService.GetCompaniesAsync(softwareId);
foreach(SoftwareCompanyRoleDto c in softwareCompanies)
{
var key = (c.CompanyId ?? 0, c.RoleId ?? string.Empty);
if(companySet.Add(key))
companyDisplays.Add((c.Company ?? string.Empty, c.Role));
}
// 3. Companies from the software family
SoftwareDto? software = await _browsingService.GetSoftwareByIdAsync(softwareId);
if(software?.FamilyId is > 0)
{
List<CompanyBySoftwareFamilyDto> familyCompanies =
await _browsingService.GetCompaniesByFamilyAsync(software.FamilyId.Value);
foreach(CompanyBySoftwareFamilyDto c in familyCompanies)
{
var key = (c.CompanyId ?? 0, c.RoleId ?? string.Empty);
if(companySet.Add(key))
companyDisplays.Add((c.Company ?? string.Empty, c.Role));
}
}
}
}
else if(release.SoftwareId is > 0)
{
// Versionless single release: load companies from software + family
int softwareId = release.SoftwareId.Value;
List<SoftwareCompanyRoleDto> softwareCompanies =
await _browsingService.GetCompaniesAsync(softwareId);
foreach(SoftwareCompanyRoleDto c in softwareCompanies)
{
var key = (c.CompanyId ?? 0, c.RoleId ?? string.Empty);
if(companySet.Add(key))
companyDisplays.Add((c.Company ?? string.Empty, c.Role));
}
SoftwareDto? software = await _browsingService.GetSoftwareByIdAsync(softwareId);
if(software?.FamilyId is > 0)
{
List<CompanyBySoftwareFamilyDto> familyCompanies =
await _browsingService.GetCompaniesByFamilyAsync(software.FamilyId.Value);
foreach(CompanyBySoftwareFamilyDto c in familyCompanies)
{
var key = (c.CompanyId ?? 0, c.RoleId ?? string.Empty);
if(companySet.Add(key))
companyDisplays.Add((c.Company ?? string.Empty, c.Role));
}
}
}
// Sort by company name and add to collection
foreach((string company, string? role) in companyDisplays.OrderBy(c => c.company))
{
string localizedRole = !string.IsNullOrEmpty(role) ? _localizer[role] : null;
string display = !string.IsNullOrEmpty(localizedRole) ? $"{company} ({localizedRole})" : company;
Companies.Add(display);
}
}
}