Add software compilation merge to Blazor and Uno admin apps

Lets admins merge duplicate SoftwareCompilation entries: a new
POST /software-compilations/{id}/merge endpoint re-parents releases,
covers, junction rows (with dedup/self-loop guards), and predecessor
references onto the target before deleting the sources. Wired into
both the Blazor MudBlazor admin page and the Uno admin page, each
with a search-based target/source picker, plus full localization.
This commit is contained in:
2026-06-29 12:37:02 +01:00
parent e11229d0a0
commit 4232de3546
25 changed files with 1362 additions and 1 deletions

View File

@@ -0,0 +1,69 @@
// <auto-generated/>
#pragma warning disable CS0618
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions.Serialization;
using System.Collections.Generic;
using System.IO;
using System;
namespace Marechai.ApiClient.Models
{
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
#pragma warning disable CS1591
public partial class MergeSoftwareCompilationsRequest : IAdditionalDataHolder, IParsable
#pragma warning restore CS1591
{
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
public IDictionary<string, object> AdditionalData { get; set; }
/// <summary>The sourceIds property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public List<int?>? SourceIds { get; set; }
#nullable restore
#else
public List<int?> SourceIds { get; set; }
#endif
/// <summary>The targetId property</summary>
public int? TargetId { get; set; }
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest"/> and sets the default values.
/// </summary>
public MergeSoftwareCompilationsRequest()
{
AdditionalData = new Dictionary<string, object>();
}
/// <summary>
/// Creates a new instance of the appropriate class based on discriminator value
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest"/></returns>
/// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
public static global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest CreateFromDiscriminatorValue(IParseNode parseNode)
{
if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode));
return new global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest();
}
/// <summary>
/// The deserialization information for the current model
/// </summary>
/// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
{
return new Dictionary<string, Action<IParseNode>>
{
{ "sourceIds", n => { SourceIds = n.GetCollectionOfPrimitiveValues<int?>()?.AsList(); } },
{ "targetId", n => { TargetId = n.GetIntValue(); } },
};
}
/// <summary>
/// Serializes information the current object
/// </summary>
/// <param name="writer">Serialization writer to use to serialize this model</param>
public virtual void Serialize(ISerializationWriter writer)
{
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
writer.WriteCollectionOfPrimitiveValues<int?>("sourceIds", SourceIds);
writer.WriteIntValue("targetId", TargetId);
writer.WriteAdditionalData(AdditionalData);
}
}
}
#pragma warning restore CS0618

View File

@@ -0,0 +1,102 @@
// <auto-generated/>
#pragma warning disable CS0618
using Marechai.ApiClient.Models;
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions.Serialization;
using Microsoft.Kiota.Abstractions;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System.Threading;
using System;
namespace Marechai.ApiClient.SoftwareCompilations.Item.Merge
{
/// <summary>
/// Builds and executes requests for operations under \software-compilations\{id}\merge
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class MergeRequestBuilder : BaseRequestBuilder
{
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public MergeRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software-compilations/{id}/merge", pathParameters)
{
}
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public MergeRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software-compilations/{id}/merge", rawUrl)
{
}
/// <returns>A <see cref="Stream"/></returns>
/// <param name="body">The request body</param>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 400 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 409 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<Stream?> PostAsync(global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<Stream> PostAsync(global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
var requestInfo = ToPostRequestInformation(body, requestConfiguration);
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "400", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "409", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
return await RequestAdapter.SendPrimitiveAsync<Stream>(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false);
}
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="body">The request body</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters);
requestInfo.Configure(requestConfiguration);
requestInfo.Headers.TryAdd("Accept", "application/json, text/plain;q=0.9");
requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body);
return requestInfo;
}
/// <summary>
/// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder"/></returns>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
public global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder WithUrl(string rawUrl)
{
return new global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder(rawUrl, RequestAdapter);
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class MergeRequestBuilderPostRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
{
}
}
}
#pragma warning restore CS0618

View File

@@ -3,6 +3,7 @@
using Marechai.ApiClient.Models;
using Marechai.ApiClient.SoftwareCompilations.Item.Compilations;
using Marechai.ApiClient.SoftwareCompilations.Item.Covers;
using Marechai.ApiClient.SoftwareCompilations.Item.Merge;
using Marechai.ApiClient.SoftwareCompilations.Item.Releases;
using Marechai.ApiClient.SoftwareCompilations.Item.Software;
using Marechai.ApiClient.SoftwareCompilations.Item.Versions;
@@ -32,6 +33,11 @@ namespace Marechai.ApiClient.SoftwareCompilations.Item
{
get => new global::Marechai.ApiClient.SoftwareCompilations.Item.Covers.CoversRequestBuilder(PathParameters, RequestAdapter);
}
/// <summary>The merge property</summary>
public global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder Merge
{
get => new global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder(PathParameters, RequestAdapter);
}
/// <summary>The releases property</summary>
public global::Marechai.ApiClient.SoftwareCompilations.Item.Releases.ReleasesRequestBuilder Releases
{

View File

@@ -1,5 +1,5 @@
{
"descriptionHash": "EF23CFA3E3D7BEEBEDACF0F1C162F3E65C5100A4E247529FC19A317185AC443E038F5D1F706BED6C0A696F2ABF5E1C4CECB13352C16548B9812C094FC0E068CC",
"descriptionHash": "0AA7E1D641D98EEE0410A0EB52E5A5C64562D47536F6BA516F5AF89FD4902F6253992E5ABA435A45DEB050C4C933C2E34B1474F7D82962B9971E337948E133B7",
"descriptionLocation": "../../../../../tmp/openapi.json",
"lockFileVersion": "1.0.0",
"kiotaVersion": "1.31.1",

View File

@@ -27,6 +27,8 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg
private CancellationTokenSource? _predecessorPickerDebounce;
private CancellationTokenSource? _includedSoftwareDebounce;
private CancellationTokenSource? _includedCompilationDebounce;
private CancellationTokenSource? _mergeTargetDebounce;
private CancellationTokenSource? _mergeSourceDebounce;
private int? _editingId;
private List<MachineDto>? _allMachines;
@@ -91,6 +93,18 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg
[ObservableProperty] private ObservableCollection<SoftwareCompilationDto> _includedCompilationSuggestions = [];
[ObservableProperty] private SoftwareCompilationDto? _selectedIncludedCompilation;
// --- Merge ---
[ObservableProperty] private bool _isMerging;
[ObservableProperty] private string _mergeTargetSearchText = string.Empty;
[ObservableProperty] private ObservableCollection<SoftwareCompilationDto> _mergeTargetSuggestions = [];
[ObservableProperty] private SoftwareCompilationDto? _selectedMergeTargetCompilation;
[ObservableProperty] private string _mergeSourceSearchText = string.Empty;
[ObservableProperty] private ObservableCollection<SoftwareCompilationDto> _mergeSourceSuggestions = [];
[ObservableProperty] private SoftwareCompilationDto? _mergeSourcePickerValue;
[ObservableProperty] private ObservableCollection<SoftwareCompilationDto> _mergeSelectedSources = [];
public bool CanConfirmMerge => SelectedMergeTargetCompilation?.Id is not null && MergeSelectedSources.Count > 0;
public AdminSoftwareCompilationsViewModel(SoftwareCompilationsService service,
IJwtService jwtService,
ITokenService tokenService,
@@ -128,6 +142,11 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg
AddIncludedCompilationCommand = new AsyncRelayCommand(AddIncludedCompilationAsync);
RemoveIncludedCompilationCommand = new AsyncRelayCommand<SoftwareCompilationDto>(RemoveIncludedCompilationAsync);
OpenMergeCommand = new RelayCommand(OpenMerge);
ConfirmMergeCommand = new AsyncRelayCommand(ConfirmMergeAsync);
CancelMergeCommand = new RelayCommand(CancelMerge);
RemoveMergeSourceCommand = new RelayCommand<SoftwareCompilationDto>(RemoveMergeSource);
CheckAdminRole();
}
@@ -147,6 +166,11 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg
public IAsyncRelayCommand AddIncludedCompilationCommand { get; }
public IAsyncRelayCommand<SoftwareCompilationDto> RemoveIncludedCompilationCommand { get; }
public IRelayCommand OpenMergeCommand { get; }
public IAsyncRelayCommand ConfirmMergeCommand { get; }
public IRelayCommand CancelMergeCommand { get; }
public IRelayCommand<SoftwareCompilationDto> RemoveMergeSourceCommand { get; }
// --- IRegionAware ---
public bool IsNavigationTarget(NavigationContext navigationContext) => true;
@@ -617,4 +641,123 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg
}
private static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value;
// --- Merge ---
public string MergeConfirmDialogTitle => _localizer["MergeConfirmDialogTitle"];
public string MergeConfirmDialogMessage => _localizer["MergeConfirmDialogMessage"];
public string MergeButtonText => _localizer["MergeButton"];
public string CancelButtonText => _localizer["CancelButton"];
partial void OnSelectedMergeTargetCompilationChanged(SoftwareCompilationDto? value)
{
MergeSelectedSources.Clear();
MergeSourceSearchText = string.Empty;
MergeSourceSuggestions.Clear();
OnPropertyChanged(nameof(CanConfirmMerge));
}
partial void OnMergeSourcePickerValueChanged(SoftwareCompilationDto? value)
{
if(value?.Id is null) return;
if(MergeSelectedSources.All(s => s.Id != value.Id))
MergeSelectedSources.Add(value);
MergeSourcePickerValue = null;
MergeSourceSearchText = string.Empty;
MergeSourceSuggestions.Clear();
OnPropertyChanged(nameof(CanConfirmMerge));
}
private void OpenMerge()
{
CancelEdit();
HasError = false;
ErrorMessage = string.Empty;
IsMerging = true;
MergeTargetSearchText = string.Empty;
MergeTargetSuggestions.Clear();
SelectedMergeTargetCompilation = null;
MergeSourceSearchText = string.Empty;
MergeSourceSuggestions.Clear();
MergeSelectedSources.Clear();
}
public void UpdateMergeTargetSuggestions(string query) => Debounce(ref _mergeTargetDebounce, async () =>
{
List<SoftwareCompilationDto> results = await _service.SearchAsync(query);
MergeTargetSuggestions.Clear();
foreach(SoftwareCompilationDto c in results) MergeTargetSuggestions.Add(c);
});
public void UpdateMergeSourceSuggestions(string query) => Debounce(ref _mergeSourceDebounce, async () =>
{
List<SoftwareCompilationDto> results = await _service.SearchAsync(query);
MergeSourceSuggestions.Clear();
foreach(SoftwareCompilationDto c in results)
if(c.Id != SelectedMergeTargetCompilation?.Id && MergeSelectedSources.All(s => s.Id != c.Id))
MergeSourceSuggestions.Add(c);
});
private void RemoveMergeSource(SoftwareCompilationDto? item)
{
if(item == null) return;
MergeSelectedSources.Remove(item);
OnPropertyChanged(nameof(CanConfirmMerge));
}
private async Task ConfirmMergeAsync()
{
if(SelectedMergeTargetCompilation?.Id is not int targetId) return;
List<int> sourceIds = MergeSelectedSources
.Where(c => c.Id is not null)
.Select(c => c.Id!.Value)
.ToList();
if(sourceIds.Count == 0) return;
try
{
HasError = false;
ErrorMessage = string.Empty;
bool succeeded = await _service.MergeAsync(targetId, sourceIds);
if(!succeeded)
{
ErrorMessage = _localizer["FailedToMergeSoftwareCompilations"];
HasError = true;
return;
}
CancelMerge();
await LoadAsync();
}
catch(Exception ex)
{
_logger.LogError(ex, "Error merging software compilations into {TargetId}", targetId);
ErrorMessage = _localizer["FailedToMergeSoftwareCompilations"];
HasError = true;
}
}
private void CancelMerge()
{
IsMerging = false;
MergeTargetSearchText = string.Empty;
MergeTargetSuggestions.Clear();
SelectedMergeTargetCompilation = null;
MergeSourceSearchText = string.Empty;
MergeSourceSuggestions.Clear();
MergeSelectedSources.Clear();
HasError = false;
ErrorMessage = string.Empty;
OnPropertyChanged(nameof(CanConfirmMerge));
}
}

View File

@@ -65,6 +65,8 @@
<CommandBar Grid.Row="0" DefaultLabelPosition="Right">
<AppBarButton Icon="Add" Label="{Binding Source={StaticResource Strings}, Path=AddButton}"
Command="{Binding OpenAddCommand}" />
<AppBarButton Icon="Switch" Label="{Binding Source={StaticResource Strings}, Path=MergeCompilationsButton}"
Command="{Binding OpenMergeCommand}" />
<AppBarButton Icon="Refresh" Label="{Binding Source={StaticResource Strings}, Path=RefreshButton}"
Command="{Binding LoadCommand}" />
</CommandBar>
@@ -301,6 +303,100 @@
</Grid>
</Grid>
</Border>
<!-- Merge panel -->
<Border Grid.Column="1" Visibility="{Binding IsMerging, Converter={StaticResource BoolToVisibilityConverter}}"
Width="420" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" BorderThickness="1,0,0,0">
<Grid RowSpacing="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Padding="16,12" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" BorderThickness="0,0,0,1">
<StackPanel>
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=MergeCompilationsPanelTitle}"
FontSize="18" FontWeight="SemiBold" />
<TextBlock Text="{Binding SelectedMergeTargetCompilation.Name}" FontSize="13"
Foreground="{ThemeResource SystemBaseMediumColor}" Margin="0,2,0,0" />
</StackPanel>
</Grid>
<ScrollViewer Grid.Row="1" Padding="16" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="12">
<AutoSuggestBox Header="{Binding Source={StaticResource Strings}, Path=MergeCompilationsTargetLabel}"
PlaceholderText="{Binding Source={StaticResource Strings}, Path=SearchPlaceholder}"
Text="{Binding MergeTargetSearchText, Mode=TwoWay}" QueryIcon="Find"
x:Name="MergeTargetFilterBox" TextChanged="MergeTargetFilterBox_TextChanged" />
<ComboBox ItemsSource="{Binding MergeTargetSuggestions}"
SelectedItem="{Binding SelectedMergeTargetCompilation, Mode=TwoWay}"
DisplayMemberPath="Name" HorizontalAlignment="Stretch"
PlaceholderText="{Binding Source={StaticResource Strings}, Path=MergeCompilationsTargetLabel}" />
<InfoBar IsOpen="True"
Visibility="{Binding SelectedMergeTargetCompilation, Converter={StaticResource NullToVisibilityConverter}}"
Severity="Warning"
Title="{Binding Source={StaticResource Strings}, Path=MergeWarningTitle}"
Message="{Binding Source={StaticResource Strings}, Path=MergeCompilationsHelpText}"
IsClosable="False" />
<StackPanel Visibility="{Binding SelectedMergeTargetCompilation, Converter={StaticResource NullToVisibilityConverter}}"
Spacing="8">
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=MergeCompilationsSourcesLabel}"
FontSize="16" FontWeight="SemiBold" />
<Grid ColumnSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<AutoSuggestBox Grid.Column="0"
PlaceholderText="{Binding Source={StaticResource Strings}, Path=SearchPlaceholder}"
Text="{Binding MergeSourceSearchText, Mode=TwoWay}" QueryIcon="Find"
x:Name="MergeSourceFilterBox" TextChanged="MergeSourceFilterBox_TextChanged" />
</Grid>
<ComboBox ItemsSource="{Binding MergeSourceSuggestions}"
SelectedItem="{Binding MergeSourcePickerValue, Mode=TwoWay}"
DisplayMemberPath="Name" HorizontalAlignment="Stretch"
PlaceholderText="{Binding Source={StaticResource Strings}, Path=MergeCompilationsSourcesLabel}" />
<ItemsRepeater ItemsSource="{Binding MergeSelectedSources}">
<ItemsRepeater.Layout>
<StackLayout Spacing="4" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" VerticalAlignment="Center" Text="{Binding Name}" />
<Button Grid.Column="1" Content="{Binding Source={StaticResource Strings}, Path=DeleteButton}"
Command="{Binding DataContext.RemoveMergeSourceCommand, ElementName=PageRoot}"
CommandParameter="{Binding}" Foreground="{ThemeResource SystemErrorTextColor}"
Style="{ThemeResource TextBlockButtonStyle}" Padding="8,4" />
</Grid>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="2" Padding="16,12" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" BorderThickness="0,1,0,0" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="{Binding Source={StaticResource Strings}, Path=CancelButton}"
Command="{Binding CancelMergeCommand}" HorizontalAlignment="Stretch" />
<Button Grid.Column="1" Content="{Binding Source={StaticResource Strings}, Path=MergeButton}"
IsEnabled="{Binding CanConfirmMerge}" Click="MergeButton_Click"
HorizontalAlignment="Stretch" Style="{ThemeResource AccentButtonStyle}" />
</Grid>
</Grid>
</Border>
</Grid>
</Grid>
</Grid>

View File

@@ -1,3 +1,4 @@
using System;
using Marechai.ApiClient.Models;
using Marechai.App.Presentation.ViewModels.Admin;
using Microsoft.UI.Xaml;
@@ -59,4 +60,38 @@ public sealed partial class AdminSoftwareCompilationsPage : Page
if(DataContext is AdminSoftwareCompilationsViewModel vm && sender is ComboBox { SelectedItem: SoftwareDto software })
vm.SelectSoftwareForVersions(software);
}
private void MergeTargetFilterBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if(args.Reason == AutoSuggestionBoxTextChangeReason.UserInput &&
DataContext is AdminSoftwareCompilationsViewModel vm)
vm.UpdateMergeTargetSuggestions(sender.Text);
}
private void MergeSourceFilterBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if(args.Reason == AutoSuggestionBoxTextChangeReason.UserInput &&
DataContext is AdminSoftwareCompilationsViewModel vm)
vm.UpdateMergeSourceSuggestions(sender.Text);
}
private async void MergeButton_Click(object sender, RoutedEventArgs e)
{
if(DataContext is not AdminSoftwareCompilationsViewModel vm) return;
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = vm.MergeConfirmDialogTitle,
PrimaryButtonText = vm.MergeButtonText,
CloseButtonText = vm.CancelButtonText,
DefaultButton = ContentDialogButton.Close,
Content = vm.MergeConfirmDialogMessage
};
ContentDialogResult result = await dialog.ShowAsync();
if(result != ContentDialogResult.Primary) return;
await vm.ConfirmMergeCommand.ExecuteAsync(null);
}
}

View File

@@ -953,6 +953,24 @@
<data name="FailedToMergeSoftwarePlatforms" xml:space="preserve">
<value>Softwareplattformen konnten nicht zusammengeführt werden</value>
</data>
<data name="MergeCompilationsButton" xml:space="preserve">
<value>Kompilationen zusammenführen</value>
</data>
<data name="MergeCompilationsPanelTitle" xml:space="preserve">
<value>Software-Kompilationen zusammenführen</value>
</data>
<data name="MergeCompilationsTargetLabel" xml:space="preserve">
<value>Zielkompilation</value>
</data>
<data name="MergeCompilationsSourcesLabel" xml:space="preserve">
<value>Zu fusionierende Kompilationen</value>
</data>
<data name="MergeCompilationsHelpText" xml:space="preserve">
<value>Wählen Sie eine oder mehrere Quellkompilationen aus, die in das Ziel zusammengeführt werden sollen. Die ausgewählten Quellkompilationen werden gelöscht, nachdem ihre Referenzen verschoben wurden.</value>
</data>
<data name="FailedToMergeSoftwareCompilations" xml:space="preserve">
<value>Softwarekompilationen konnten nicht zusammengeführt werden</value>
</data>
<data name="FailedToMergeCompanies" xml:space="preserve">
<value>Unternehmen konnten nicht zusammengeführt werden</value>
</data>

View File

@@ -953,6 +953,24 @@
<data name="FailedToMergeSoftwarePlatforms" xml:space="preserve">
<value>No se pudieron fusionar las plataformas de software</value>
</data>
<data name="MergeCompilationsButton" xml:space="preserve">
<value>Fusionar compilaciones</value>
</data>
<data name="MergeCompilationsPanelTitle" xml:space="preserve">
<value>Fusionar compilaciones de software</value>
</data>
<data name="MergeCompilationsTargetLabel" xml:space="preserve">
<value>Compilación de destino</value>
</data>
<data name="MergeCompilationsSourcesLabel" xml:space="preserve">
<value>Compilaciones a fusionar</value>
</data>
<data name="MergeCompilationsHelpText" xml:space="preserve">
<value>Seleccione una o más compilaciones de origen para fusionar en el destino. Las compilaciones de origen seleccionadas se eliminarán después de mover sus referencias.</value>
</data>
<data name="FailedToMergeSoftwareCompilations" xml:space="preserve">
<value>No se pudieron fusionar las compilaciones de software</value>
</data>
<data name="FailedToMergeCompanies" xml:space="preserve">
<value>No se pudieron fusionar las empresas</value>
</data>

View File

@@ -953,6 +953,24 @@
<data name="FailedToMergeSoftwarePlatforms" xml:space="preserve">
<value>Impossible de fusionner les plateformes logicielles</value>
</data>
<data name="MergeCompilationsButton" xml:space="preserve">
<value>Fusionner les compilations</value>
</data>
<data name="MergeCompilationsPanelTitle" xml:space="preserve">
<value>Fusionner les compilations logicielles</value>
</data>
<data name="MergeCompilationsTargetLabel" xml:space="preserve">
<value>Compilation cible</value>
</data>
<data name="MergeCompilationsSourcesLabel" xml:space="preserve">
<value>Compilations à fusionner</value>
</data>
<data name="MergeCompilationsHelpText" xml:space="preserve">
<value>Sélectionnez une ou plusieurs compilations source à fusionner dans la cible. Les compilations source sélectionnées seront supprimées après le déplacement de leurs références.</value>
</data>
<data name="FailedToMergeSoftwareCompilations" xml:space="preserve">
<value>Impossible de fusionner les compilations logicielles</value>
</data>
<data name="FailedToMergeCompanies" xml:space="preserve">
<value>Impossible de fusionner les entreprises</value>
</data>

View File

@@ -953,6 +953,24 @@
<data name="FailedToMergeSoftwarePlatforms" xml:space="preserve">
<value>Falha ao mesclar plataformas de software</value>
</data>
<data name="MergeCompilationsButton" xml:space="preserve">
<value>Mesclar compilações</value>
</data>
<data name="MergeCompilationsPanelTitle" xml:space="preserve">
<value>Mesclar compilações de software</value>
</data>
<data name="MergeCompilationsTargetLabel" xml:space="preserve">
<value>Compilação de destino</value>
</data>
<data name="MergeCompilationsSourcesLabel" xml:space="preserve">
<value>Compilações para mesclar</value>
</data>
<data name="MergeCompilationsHelpText" xml:space="preserve">
<value>Selecione uma ou mais compilações de origem para mesclar no destino. As compilações de origem selecionadas serão excluídas após a movimentação de suas referências.</value>
</data>
<data name="FailedToMergeSoftwareCompilations" xml:space="preserve">
<value>Falha ao mesclar compilações de software</value>
</data>
<data name="FailedToMergeCompanies" xml:space="preserve">
<value>Falha ao mesclar empresas</value>
</data>

View File

@@ -1052,6 +1052,24 @@
<data name="FailedToMergeSoftwarePlatforms" xml:space="preserve">
<value>Failed to merge software platforms</value>
</data>
<data name="MergeCompilationsButton" xml:space="preserve">
<value>Merge Compilations</value>
</data>
<data name="MergeCompilationsPanelTitle" xml:space="preserve">
<value>Merge Software Compilations</value>
</data>
<data name="MergeCompilationsTargetLabel" xml:space="preserve">
<value>Target compilation</value>
</data>
<data name="MergeCompilationsSourcesLabel" xml:space="preserve">
<value>Compilations to merge</value>
</data>
<data name="MergeCompilationsHelpText" xml:space="preserve">
<value>Select one or more source compilations to merge into the target. The selected source compilations will be deleted after their references are moved.</value>
</data>
<data name="FailedToMergeSoftwareCompilations" xml:space="preserve">
<value>Failed to merge software compilations</value>
</data>
<data name="FailedToMergeCompanies" xml:space="preserve">
<value>Failed to merge companies</value>
</data>

View File

@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Marechai.App.Services;
@@ -175,6 +176,28 @@ public sealed class SoftwareCompilationsService
}
}
public async Task<bool> MergeAsync(int targetId, IReadOnlyList<int> sourceIds)
{
try
{
var request = new MergeSoftwareCompilationsRequest
{
TargetId = targetId,
SourceIds = sourceIds.Select(id => (int?)id).ToList()
};
await _apiClient.SoftwareCompilations[targetId].Merge.PostAsync(request);
return true;
}
catch(Exception ex)
{
_logger.LogError(ex, "Error merging software compilations {SourceIds} into {TargetId}", sourceIds, targetId);
return false;
}
}
public async Task<bool> AddIncludedSoftwareAsync(SoftwareBySoftwareCompilationDto dto)
{
try

View File

@@ -0,0 +1,41 @@
/******************************************************************************
// 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.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Marechai.Data.Dtos;
public class MergeSoftwareCompilationsRequest
{
[JsonPropertyName("targetId")]
[Required]
public ulong TargetId { get; set; }
[JsonPropertyName("sourceIds")]
[Required]
public required List<ulong> SourceIds { get; set; }
}

View File

@@ -179,6 +179,181 @@ public class SoftwareCompilationsController(MarechaiContext context) : Controlle
return Ok();
}
[HttpPost("{id:ulong}/merge")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> MergeAsync(ulong id, [FromBody] MergeSoftwareCompilationsRequest request)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
if(request?.SourceIds == null || request.SourceIds.Count == 0)
return Problem(detail: "At least one source compilation must be specified", statusCode: StatusCodes.Status400BadRequest);
if(request.SourceIds.Contains(id))
return Problem(detail: "Target compilation cannot be in the source list", statusCode: StatusCodes.Status400BadRequest);
SoftwareCompilation target = await context.SoftwareCompilations.FindAsync(id);
if(target is null) return NotFound("Target compilation not found");
var sourcesToDelete = await context.SoftwareCompilations
.Where(c => request.SourceIds.Contains(c.Id))
.ToListAsync();
if(sourcesToDelete.Count != request.SourceIds.Count)
return NotFound("One or more source compilations not found");
await using var transaction = await context.Database.BeginTransactionAsync();
try
{
await context.SoftwareReleases
.Where(r => r.SoftwareCompilationId.HasValue &&
request.SourceIds.Contains(r.SoftwareCompilationId.Value))
.ExecuteUpdateAsync(setters =>
setters.SetProperty(r => r.SoftwareCompilationId, (ulong?)id));
await context.SoftwareCovers
.Where(c => c.SoftwareCompilationId.HasValue &&
request.SourceIds.Contains(c.SoftwareCompilationId.Value))
.ExecuteUpdateAsync(setters =>
setters.SetProperty(c => c.SoftwareCompilationId, (ulong?)id));
// --- SoftwareBySoftwareCompilation: dedupe on (target id, SoftwareId) ---
var targetSoftwareKeys = (await context.SoftwareBySoftwareCompilation
.Where(x => x.SoftwareCompilationId == id)
.Select(x => x.SoftwareId)
.ToListAsync())
.ToHashSet();
var sourceSoftwareLinks = await context.SoftwareBySoftwareCompilation
.Where(x => request.SourceIds.Contains(x.SoftwareCompilationId))
.ToListAsync();
foreach(SoftwareBySoftwareCompilation link in sourceSoftwareLinks)
{
context.SoftwareBySoftwareCompilation.Remove(link);
if(targetSoftwareKeys.Add(link.SoftwareId))
await context.SoftwareBySoftwareCompilation.AddAsync(new SoftwareBySoftwareCompilation
{
SoftwareCompilationId = id,
SoftwareId = link.SoftwareId
});
}
// --- SoftwareVersionBySoftwareCompilation: dedupe on (target id, SoftwareVersionId) ---
var targetVersionKeys = (await context.SoftwareVersionBySoftwareCompilation
.Where(x => x.SoftwareCompilationId == id)
.Select(x => x.SoftwareVersionId)
.ToListAsync())
.ToHashSet();
var sourceVersionLinks = await context.SoftwareVersionBySoftwareCompilation
.Where(x => request.SourceIds.Contains(x.SoftwareCompilationId))
.ToListAsync();
foreach(SoftwareVersionBySoftwareCompilation link in sourceVersionLinks)
{
context.SoftwareVersionBySoftwareCompilation.Remove(link);
if(targetVersionKeys.Add(link.SoftwareVersionId))
await context.SoftwareVersionBySoftwareCompilation.AddAsync(new SoftwareVersionBySoftwareCompilation
{
SoftwareCompilationId = id,
SoftwareVersionId = link.SoftwareVersionId
});
}
// --- SoftwareCompilationBySoftwareCompilation: re-parent both roles, dedupe and drop self-loops ---
var targetParentKeys = (await context.SoftwareCompilationBySoftwareCompilation
.Where(x => x.ParentCompilationId == id)
.Select(x => x.ChildCompilationId)
.ToListAsync())
.ToHashSet();
var sourceAsParentLinks = await context.SoftwareCompilationBySoftwareCompilation
.Where(x => request.SourceIds.Contains(x.ParentCompilationId))
.ToListAsync();
foreach(SoftwareCompilationBySoftwareCompilation link in sourceAsParentLinks)
{
context.SoftwareCompilationBySoftwareCompilation.Remove(link);
if(link.ChildCompilationId == id) continue; // would become a self-loop
if(targetParentKeys.Add(link.ChildCompilationId))
await context.SoftwareCompilationBySoftwareCompilation.AddAsync(
new SoftwareCompilationBySoftwareCompilation
{
ParentCompilationId = id,
ChildCompilationId = link.ChildCompilationId
});
}
var targetChildKeys = (await context.SoftwareCompilationBySoftwareCompilation
.Where(x => x.ChildCompilationId == id)
.Select(x => x.ParentCompilationId)
.ToListAsync())
.ToHashSet();
var sourceAsChildLinks = await context.SoftwareCompilationBySoftwareCompilation
.Where(x => request.SourceIds.Contains(x.ChildCompilationId))
.ToListAsync();
foreach(SoftwareCompilationBySoftwareCompilation link in sourceAsChildLinks)
{
context.SoftwareCompilationBySoftwareCompilation.Remove(link);
if(link.ParentCompilationId == id) continue; // would become a self-loop
if(targetChildKeys.Add(link.ParentCompilationId))
await context.SoftwareCompilationBySoftwareCompilation.AddAsync(
new SoftwareCompilationBySoftwareCompilation
{
ParentCompilationId = link.ParentCompilationId,
ChildCompilationId = id
});
}
// --- Predecessor self-reference ---
await context.SoftwareCompilations
.Where(c => c.PredecessorId.HasValue &&
request.SourceIds.Contains(c.PredecessorId.Value) &&
!request.SourceIds.Contains(c.Id))
.ExecuteUpdateAsync(setters =>
setters.SetProperty(c => c.PredecessorId, (ulong?)id));
if(target.PredecessorId.HasValue && request.SourceIds.Contains(target.PredecessorId.Value))
target.PredecessorId = null;
foreach(SoftwareCompilation source in sourcesToDelete)
context.SoftwareCompilations.Remove(source);
await context.SaveChangesWithUserAsync(userId);
await transaction.CommitAsync();
}
catch(Exception ex)
{
await transaction.RollbackAsync();
return Problem(detail: $"Merge failed: {ex.Message}", statusCode: StatusCodes.Status409Conflict);
}
return Ok();
}
[HttpGet("{id:ulong}/releases")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]

View File

@@ -0,0 +1,73 @@
@using System.Threading
@using Marechai.ApiClient.Models
@using Marechai.Services
@inject SoftwareCompilationsService SoftwareCompilationsService
@inject IStringLocalizer<SoftwareCompilationsService> L
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudAlert Severity="Severity.Info">
@L["Select a target compilation and the compilations to merge into it. The target compilation will keep its name and ID, and all references will be updated."]
</MudAlert>
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2" Class="font-weight-bold">@L["Target Compilation"]</MudText>
<MudAutocomplete T="SoftwareCompilationDto"
Value="SelectedTarget"
ValueChanged="OnTargetChanged"
SearchFunc="SearchTargetAsync"
ToStringFunc="@(c => c?.Name ?? string.Empty)"
Label="@L["Select target compilation"]"
Variant="Variant.Outlined"
Clearable="true"
DebounceInterval="300"
MinCharacters="2"/>
</MudStack>
@if(SelectedTarget != null)
{
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2" Class="font-weight-bold">@L["Compilations to Merge"]</MudText>
<MudText Typo="Typo.caption">
@L["Search and add one or more compilations to merge into the target. The selected compilations will be deleted after merging their data."]
</MudText>
<MudAutocomplete T="SoftwareCompilationDto"
Value="_sourcePickerValue"
ValueChanged="OnSourcePicked"
SearchFunc="SearchSourcesAsync"
ToStringFunc="@(c => c?.Name ?? string.Empty)"
Label="@L["Search compilation to add"]"
Variant="Variant.Outlined"
Clearable="true"
DebounceInterval="300"
MinCharacters="2"/>
@if(SelectedSources.Count > 0)
{
<MudChipSet T="string" ReadOnly="false">
@foreach(var source in SelectedSources)
{
<MudChip T="string" Value="@source.Name" OnClose="@(() => RemoveSource(source))">@source.Name</MudChip>
}
</MudChipSet>
<MudAlert Severity="Severity.Warning">
@L["Will merge"] @SelectedSources.Count @L["compilation(s) into"] "<strong>@SelectedTarget.Name</strong>".
</MudAlert>
}
</MudStack>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Variant="Variant.Text">@L["Cancel"]</MudButton>
<MudButton Color="Color.Warning"
Variant="Variant.Filled"
OnClick="Merge"
Disabled="@(SelectedTarget == null || SelectedSources.Count == 0)">
@L["Merge Compilations"]
</MudButton>
</DialogActions>
</MudDialog>

View File

@@ -0,0 +1,67 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Marechai.ApiClient.Models;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace Marechai.Pages.Admin;
public partial class MergeSoftwareCompilationsDialog
{
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
SoftwareCompilationDto SelectedTarget { get; set; }
List<SoftwareCompilationDto> SelectedSources { get; set; } = [];
SoftwareCompilationDto _sourcePickerValue;
async Task<IEnumerable<SoftwareCompilationDto>> SearchTargetAsync(string value, CancellationToken ct)
{
if(string.IsNullOrWhiteSpace(value)) return [];
return await SoftwareCompilationsService.GetPagedAsync(0, 20, value);
}
async Task<IEnumerable<SoftwareCompilationDto>> SearchSourcesAsync(string value, CancellationToken ct)
{
if(string.IsNullOrWhiteSpace(value)) return [];
List<SoftwareCompilationDto> results = await SoftwareCompilationsService.GetPagedAsync(0, 20, value);
return results.Where(c => c.Id != SelectedTarget?.Id &&
SelectedSources.All(s => s.Id != c.Id));
}
void OnTargetChanged(SoftwareCompilationDto compilation)
{
SelectedTarget = compilation;
SelectedSources.Clear();
_sourcePickerValue = null;
}
void OnSourcePicked(SoftwareCompilationDto compilation)
{
if(compilation is null) return;
if(SelectedSources.All(s => s.Id != compilation.Id)) SelectedSources.Add(compilation);
_sourcePickerValue = null;
}
void RemoveSource(SoftwareCompilationDto compilation) => SelectedSources.Remove(compilation);
void Cancel() => MudDialog.Cancel();
void Merge()
{
var result = new MergeSoftwareCompilationsDialogResult
{
TargetId = (ulong)(SelectedTarget.Id ?? 0),
SourceIds = SelectedSources.Select(s => (ulong)(s.Id ?? 0)).ToList()
};
MudDialog.Close(DialogResult.Ok(result));
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace Marechai.Pages.Admin;
public class MergeSoftwareCompilationsDialogResult
{
public ulong TargetId { get; set; }
public List<ulong> SourceIds { get; set; }
}

View File

@@ -28,6 +28,7 @@
<MudPaper Elevation="2" Class="rounded-lg">
<MudToolBar Dense="true">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="OpenAddDialog">@L["Add Compilation"]</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.Merge" OnClick="OpenMergeDialog" Class="ml-2">@L["Merge Compilations"]</MudButton>
<MudSpacer/>
<MudTextField T="string" ValueChanged="OnSearch" Placeholder="@L["Search by name..."]"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search"

View File

@@ -142,6 +142,33 @@ public partial class SoftwareCompilations
}
}
async Task OpenMergeDialog()
{
IDialogReference dialog = await DialogService.ShowAsync<MergeSoftwareCompilationsDialog>(L["Merge Compilations"], new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true
});
DialogResult result = await dialog.Result;
if(result is { Canceled: false, Data: MergeSoftwareCompilationsDialogResult data })
{
(bool succeeded, string errorMessage) =
await SoftwareCompilationsService.MergeAsync(data.TargetId, data.SourceIds);
if(succeeded)
{
_successMessage = L["Compilations merged successfully."];
await _dataGrid.ReloadServerData();
}
else
{
_errorMessage = errorMessage;
}
}
}
async Task ConfirmDelete(SoftwareCompilationDto compilation)
{
DialogParameters<DeleteConfirmDialog> parameters = new()

View File

@@ -0,0 +1,95 @@
<?xml version='1.0' encoding='utf-8'?>
<root xmlns:ns1="urn:schemas-microsoft-com:xml-msdata" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:schema id="root">
<xs:import namespace="http://www.w3.org/XML/1998/namespace" />
<xs:element name="root" ns1:IsDataSet="true">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
<xs:element name="metadata">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" />
</xs:sequence>
<xs:attribute name="name" use="required" type="xsd:string" />
<xs:attribute name="type" type="xsd:string" />
<xs:attribute name="mimetype" type="xsd:string" />
<xs:attribute ref="xml:space" />
</xs:complexType>
</xs:element>
<xs:element name="assembly">
<xs:complexType>
<xs:attribute name="alias" type="xsd:string" />
<xs:attribute name="name" type="xsd:string" />
</xs:complexType>
</xs:element>
<xs:element name="data">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" ns1:Ordinal="1" />
<xs:element name="comment" type="xsd:string" minOccurs="0" ns1:Ordinal="2" />
</xs:sequence>
<xs:attribute name="name" type="xsd:string" use="required" ns1:Ordinal="1" />
<xs:attribute name="type" type="xsd:string" ns1:Ordinal="3" />
<xs:attribute name="mimetype" type="xsd:string" ns1:Ordinal="4" />
<xs:attribute ref="xml:space" />
</xs:complexType>
</xs:element>
<xs:element name="resheader">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" ns1:Ordinal="1" />
</xs:sequence>
<xs:attribute name="name" type="xsd:string" use="required" />
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<data name="Merge Compilations" xml:space="preserve">
<value>Kompilationen zusammenführen</value>
</data>
<data name="Compilations merged successfully." xml:space="preserve">
<value>Kompilationen erfolgreich zusammengeführt.</value>
</data>
<data name="Target Compilation" xml:space="preserve">
<value>Zielkompilation</value>
</data>
<data name="Select target compilation" xml:space="preserve">
<value>Zielkompilation auswählen</value>
</data>
<data name="Compilations to Merge" xml:space="preserve">
<value>Zu fusionierende Kompilationen</value>
</data>
<data name="Search and add one or more compilations to merge into the target. The selected compilations will be deleted after merging their data." xml:space="preserve">
<value>Suchen und fügen Sie eine oder mehrere Kompilationen hinzu, die in das Ziel zusammengeführt werden sollen. Die ausgewählten Kompilationen werden nach dem Zusammenführen ihrer Daten gelöscht.</value>
</data>
<data name="Will merge" xml:space="preserve">
<value>Wird zusammenführen</value>
</data>
<data name="compilation(s) into" xml:space="preserve">
<value>Kompilation(en) in</value>
</data>
<data name="Select a target compilation and the compilations to merge into it. The target compilation will keep its name and ID, and all references will be updated." xml:space="preserve">
<value>Wählen Sie eine Zielkompilation und die Kompilationen aus, die damit zusammengeführt werden sollen. Die Zielkompilation behält ihren Namen und ihre ID, und alle Referenzen werden aktualisiert.</value>
</data>
<data name="Search compilation to add" xml:space="preserve">
<value>Kompilation zum Hinzufügen suchen</value>
</data>
</root>

View File

@@ -0,0 +1,95 @@
<?xml version='1.0' encoding='utf-8'?>
<root xmlns:ns1="urn:schemas-microsoft-com:xml-msdata" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:schema id="root">
<xs:import namespace="http://www.w3.org/XML/1998/namespace" />
<xs:element name="root" ns1:IsDataSet="true">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
<xs:element name="metadata">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" />
</xs:sequence>
<xs:attribute name="name" use="required" type="xsd:string" />
<xs:attribute name="type" type="xsd:string" />
<xs:attribute name="mimetype" type="xsd:string" />
<xs:attribute ref="xml:space" />
</xs:complexType>
</xs:element>
<xs:element name="assembly">
<xs:complexType>
<xs:attribute name="alias" type="xsd:string" />
<xs:attribute name="name" type="xsd:string" />
</xs:complexType>
</xs:element>
<xs:element name="data">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" ns1:Ordinal="1" />
<xs:element name="comment" type="xsd:string" minOccurs="0" ns1:Ordinal="2" />
</xs:sequence>
<xs:attribute name="name" type="xsd:string" use="required" ns1:Ordinal="1" />
<xs:attribute name="type" type="xsd:string" ns1:Ordinal="3" />
<xs:attribute name="mimetype" type="xsd:string" ns1:Ordinal="4" />
<xs:attribute ref="xml:space" />
</xs:complexType>
</xs:element>
<xs:element name="resheader">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" ns1:Ordinal="1" />
</xs:sequence>
<xs:attribute name="name" type="xsd:string" use="required" />
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<data name="Merge Compilations" xml:space="preserve">
<value>Fusionar compilaciones</value>
</data>
<data name="Compilations merged successfully." xml:space="preserve">
<value>Compilaciones fusionadas correctamente.</value>
</data>
<data name="Target Compilation" xml:space="preserve">
<value>Compilación de destino</value>
</data>
<data name="Select target compilation" xml:space="preserve">
<value>Seleccionar compilación de destino</value>
</data>
<data name="Compilations to Merge" xml:space="preserve">
<value>Compilaciones a fusionar</value>
</data>
<data name="Search and add one or more compilations to merge into the target. The selected compilations will be deleted after merging their data." xml:space="preserve">
<value>Busque y añada una o más compilaciones para fusionar en el destino. Las compilaciones seleccionadas se eliminarán después de fusionar sus datos.</value>
</data>
<data name="Will merge" xml:space="preserve">
<value>Se fusionarán</value>
</data>
<data name="compilation(s) into" xml:space="preserve">
<value>compilación(es) en</value>
</data>
<data name="Select a target compilation and the compilations to merge into it. The target compilation will keep its name and ID, and all references will be updated." xml:space="preserve">
<value>Seleccione una compilación de destino y las compilaciones a fusionar en ella. La compilación de destino mantendrá su nombre e ID, y todas las referencias se actualizarán.</value>
</data>
<data name="Search compilation to add" xml:space="preserve">
<value>Buscar compilación para añadir</value>
</data>
</root>

View File

@@ -0,0 +1,95 @@
<?xml version='1.0' encoding='utf-8'?>
<root xmlns:ns1="urn:schemas-microsoft-com:xml-msdata" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:schema id="root">
<xs:import namespace="http://www.w3.org/XML/1998/namespace" />
<xs:element name="root" ns1:IsDataSet="true">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
<xs:element name="metadata">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" />
</xs:sequence>
<xs:attribute name="name" use="required" type="xsd:string" />
<xs:attribute name="type" type="xsd:string" />
<xs:attribute name="mimetype" type="xsd:string" />
<xs:attribute ref="xml:space" />
</xs:complexType>
</xs:element>
<xs:element name="assembly">
<xs:complexType>
<xs:attribute name="alias" type="xsd:string" />
<xs:attribute name="name" type="xsd:string" />
</xs:complexType>
</xs:element>
<xs:element name="data">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" ns1:Ordinal="1" />
<xs:element name="comment" type="xsd:string" minOccurs="0" ns1:Ordinal="2" />
</xs:sequence>
<xs:attribute name="name" type="xsd:string" use="required" ns1:Ordinal="1" />
<xs:attribute name="type" type="xsd:string" ns1:Ordinal="3" />
<xs:attribute name="mimetype" type="xsd:string" ns1:Ordinal="4" />
<xs:attribute ref="xml:space" />
</xs:complexType>
</xs:element>
<xs:element name="resheader">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" ns1:Ordinal="1" />
</xs:sequence>
<xs:attribute name="name" type="xsd:string" use="required" />
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<data name="Merge Compilations" xml:space="preserve">
<value>Fusionner les compilations</value>
</data>
<data name="Compilations merged successfully." xml:space="preserve">
<value>Compilations fusionnées avec succès.</value>
</data>
<data name="Target Compilation" xml:space="preserve">
<value>Compilation cible</value>
</data>
<data name="Select target compilation" xml:space="preserve">
<value>Sélectionner la compilation cible</value>
</data>
<data name="Compilations to Merge" xml:space="preserve">
<value>Compilations à fusionner</value>
</data>
<data name="Search and add one or more compilations to merge into the target. The selected compilations will be deleted after merging their data." xml:space="preserve">
<value>Recherchez et ajoutez une ou plusieurs compilations à fusionner dans la cible. Les compilations sélectionnées seront supprimées après la fusion de leurs données.</value>
</data>
<data name="Will merge" xml:space="preserve">
<value>Fusionnera</value>
</data>
<data name="compilation(s) into" xml:space="preserve">
<value>compilation(s) dans</value>
</data>
<data name="Select a target compilation and the compilations to merge into it. The target compilation will keep its name and ID, and all references will be updated." xml:space="preserve">
<value>Sélectionnez une compilation cible et les compilations à y fusionner. La compilation cible conservera son nom et son ID, et toutes les références seront mises à jour.</value>
</data>
<data name="Search compilation to add" xml:space="preserve">
<value>Rechercher une compilation à ajouter</value>
</data>
</root>

View File

@@ -0,0 +1,95 @@
<?xml version='1.0' encoding='utf-8'?>
<root xmlns:ns1="urn:schemas-microsoft-com:xml-msdata" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:schema id="root">
<xs:import namespace="http://www.w3.org/XML/1998/namespace" />
<xs:element name="root" ns1:IsDataSet="true">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
<xs:element name="metadata">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" />
</xs:sequence>
<xs:attribute name="name" use="required" type="xsd:string" />
<xs:attribute name="type" type="xsd:string" />
<xs:attribute name="mimetype" type="xsd:string" />
<xs:attribute ref="xml:space" />
</xs:complexType>
</xs:element>
<xs:element name="assembly">
<xs:complexType>
<xs:attribute name="alias" type="xsd:string" />
<xs:attribute name="name" type="xsd:string" />
</xs:complexType>
</xs:element>
<xs:element name="data">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" ns1:Ordinal="1" />
<xs:element name="comment" type="xsd:string" minOccurs="0" ns1:Ordinal="2" />
</xs:sequence>
<xs:attribute name="name" type="xsd:string" use="required" ns1:Ordinal="1" />
<xs:attribute name="type" type="xsd:string" ns1:Ordinal="3" />
<xs:attribute name="mimetype" type="xsd:string" ns1:Ordinal="4" />
<xs:attribute ref="xml:space" />
</xs:complexType>
</xs:element>
<xs:element name="resheader">
<xs:complexType>
<xs:sequence>
<xs:element name="value" type="xsd:string" minOccurs="0" ns1:Ordinal="1" />
</xs:sequence>
<xs:attribute name="name" type="xsd:string" use="required" />
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<data name="Merge Compilations" xml:space="preserve">
<value>Unisci compilazioni</value>
</data>
<data name="Compilations merged successfully." xml:space="preserve">
<value>Compilazioni unite con successo.</value>
</data>
<data name="Target Compilation" xml:space="preserve">
<value>Compilazione di destinazione</value>
</data>
<data name="Select target compilation" xml:space="preserve">
<value>Seleziona compilazione di destinazione</value>
</data>
<data name="Compilations to Merge" xml:space="preserve">
<value>Compilazioni da unire</value>
</data>
<data name="Search and add one or more compilations to merge into the target. The selected compilations will be deleted after merging their data." xml:space="preserve">
<value>Cerca e aggiungi una o più compilazioni da unire alla destinazione. Le compilazioni selezionate verranno eliminate dopo l'unione dei loro dati.</value>
</data>
<data name="Will merge" xml:space="preserve">
<value>Verranno unite</value>
</data>
<data name="compilation(s) into" xml:space="preserve">
<value>compilazione/i in</value>
</data>
<data name="Select a target compilation and the compilations to merge into it. The target compilation will keep its name and ID, and all references will be updated." xml:space="preserve">
<value>Selezionare una compilazione di destinazione e le compilazioni da unire ad essa. La compilazione di destinazione manterrà il proprio nome e ID, e tutti i riferimenti verranno aggiornati.</value>
</data>
<data name="Search compilation to add" xml:space="preserve">
<value>Cerca compilazione da aggiungere</value>
</data>
</root>

View File

@@ -137,6 +137,30 @@ public class SoftwareCompilationsService(Marechai.ApiClient.Client client)
}
}
public async Task<(bool succeeded, string error)> MergeAsync(ulong targetId, List<ulong> sourceIds)
{
try
{
var request = new MergeSoftwareCompilationsRequest
{
TargetId = (int?)targetId,
SourceIds = sourceIds.ConvertAll(sourceId => (int?)sourceId)
};
await client.SoftwareCompilations[(int)targetId].Merge.PostAsync(request);
return (true, null);
}
catch(ApiException ex)
{
return (false, ExtractDetail(ex));
}
catch(Exception ex)
{
return (false, ex.Message);
}
}
public async Task<List<SoftwareReleaseDto>> GetReleasesAsync(int id)
{
try