diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ab44d4c..fd97ad1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,10 +12,20 @@ jobs:
build-and-test:
runs-on: windows-latest
+ env:
+ NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ${{ github.workspace }}/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5d752c8..50a1223 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,10 +13,20 @@ jobs:
build-and-release:
runs-on: windows-latest
+ env:
+ NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ${{ github.workspace }}/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
- name: Extract version from tag
id: version
shell: pwsh
diff --git a/CollectionManager.App.Cli/CliConstants.cs b/CollectionManager.App.Cli/CliConstants.cs
new file mode 100644
index 0000000..b40f538
--- /dev/null
+++ b/CollectionManager.App.Cli/CliConstants.cs
@@ -0,0 +1,15 @@
+namespace CollectionManager.App.Cli;
+
+internal static class CliConstants
+{
+ ///
+ /// Standard separators for splitting CLI input values.
+ /// Used for parsing comma or whitespace separated lists.
+ ///
+ public static readonly char[] ValueSeparator = [' ', ',', '\n', '\r', '\t'];
+
+ ///
+ /// Separators for simple value lists (spaces and commas only).
+ ///
+ public static readonly char[] SimpleValueSeparator = [' ', ','];
+}
diff --git a/CollectionManager.App.Cli/CollectionManager.App.Cli.csproj b/CollectionManager.App.Cli/CollectionManager.App.Cli.csproj
index 6055428..5f88c0e 100644
--- a/CollectionManager.App.Cli/CollectionManager.App.Cli.csproj
+++ b/CollectionManager.App.Cli/CollectionManager.App.Cli.csproj
@@ -4,6 +4,7 @@
osu! Collection Manager CLI
Copyright © 2017-present Piotrekol
CollectionManager.App.Cli
+ enable
@@ -13,5 +14,9 @@
+
+
+
+
diff --git a/CollectionManager.App.Cli/Commands/ConvertCommand.cs b/CollectionManager.App.Cli/Commands/ConvertCommand.cs
new file mode 100644
index 0000000..66bb4e9
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/ConvertCommand.cs
@@ -0,0 +1,34 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.FileIo.FileCollections;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+
+[Verb("convert", HelpText = "Convert collection files between formats (.db/.osdb/.realm)")]
+internal sealed partial class ConvertCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Option('i', "input", Required = true, HelpText = "Input collection file (.db/.osdb/.realm)")]
+ public required string InputFile { get; init; }
+
+ [Option('o', "output", Required = true, HelpText = "Output .db/.osdb/.realm file")]
+ public override string? OutputFile { get; init; }
+ public override Task RunAsync(CollectionContext context)
+ {
+ _ = context.EnsureOsuDatabaseLoaded(this);
+ LogConvertingCollections();
+ CollectionLoadResult loaded = context.LoadCollectionsFromFile(InputFile);
+ LogLoadedCollections(loaded.Collections.Count, InputFile);
+
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Converting collections.")]
+ private partial void LogConvertingCollections();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Loaded {Count} collection(s) from {Path}")]
+ private partial void LogLoadedCollections(int count, string path);
+}
diff --git a/CollectionManager.App.Cli/Commands/CreateCommand.cs b/CollectionManager.App.Cli/Commands/CreateCommand.cs
new file mode 100644
index 0000000..6421f97
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/CreateCommand.cs
@@ -0,0 +1,116 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli;
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+
+[Verb("create", HelpText = "Create collection from beatmap IDs or hashes")]
+internal sealed partial class CreateCommand : PipelineOptions
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Option('i', "ids", Required = false, HelpText = "Comma or whitespace separated beatmap IDs. Can be path to file.")]
+ public required string BeatmapIds { get; init; }
+
+ [Option('h', "hashes", Required = false, HelpText = "Comma or whitespace separated beatmap hashes (MD5). Can be path to file.")]
+ public required string Hashes { get; init; }
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ string? input = GetInput();
+
+ if (input is null)
+ {
+ return Task.FromResult(1);
+ }
+
+ _ = context.EnsureOsuDatabaseLoaded(this);
+ LogCreatingCollections();
+ string[] idOrHashArray = input.Split(CliConstants.ValueSeparator, StringSplitOptions.RemoveEmptyEntries);
+ OsuCollection collection = !string.IsNullOrWhiteSpace(BeatmapIds)
+ ? ProcessBeatmapIds(context, idOrHashArray)
+ : ProcessHashes(context, idOrHashArray);
+
+ CollectionEditArgs args = CollectionEditArgs.AddCollections([collection]);
+ context.Manager.EditCollection(args);
+
+ string collectionIdentifier = CollectionLogger.FormatCollection(collection);
+ LogCreatedFromEntries(collectionIdentifier, idOrHashArray.Length);
+
+ return Task.FromResult(0);
+ }
+
+ private string? GetInput()
+ {
+ bool hasBeatmapIds = !string.IsNullOrWhiteSpace(BeatmapIds);
+ bool hasHashes = !string.IsNullOrWhiteSpace(Hashes);
+
+ if (!hasBeatmapIds && !hasHashes)
+ {
+ LogBeatmapIdsOrHashesRequired();
+ return default;
+ }
+
+ if (hasBeatmapIds && hasHashes)
+ {
+ LogBeatmapIdsAndHashesMutuallyExclusive();
+ return default;
+ }
+
+ if (hasBeatmapIds)
+ {
+ return BeatmapIds;
+ }
+
+ return Hashes;
+ }
+
+ private static OsuCollection ProcessBeatmapIds(CollectionContext context, string[] beatmapIdArray)
+ {
+ OsuCollection collection = new(context.LoadedMaps) { Name = "from mapIds" };
+
+ foreach (string beatmapId in beatmapIdArray)
+ {
+ if (int.TryParse(beatmapId.Trim(), out int id))
+ {
+ collection.AddBeatmapByMapId(id);
+ }
+ }
+
+ return collection;
+ }
+
+ private static OsuCollection ProcessHashes(CollectionContext context, string[] hashArray)
+ {
+ OsuCollection collection = new(context.LoadedMaps) { Name = "from hashes" };
+
+ foreach (string hash in hashArray)
+ {
+ string trimmedHash = hash.Trim();
+
+ if (!string.IsNullOrWhiteSpace(trimmedHash))
+ {
+ collection.AddBeatmapByHash(trimmedHash);
+ }
+ }
+
+ return collection;
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Either --ids or --hashes must be provided.")]
+ private partial void LogBeatmapIdsOrHashesRequired();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "--ids and --hashes cannot be used together.")]
+ private partial void LogBeatmapIdsAndHashesMutuallyExclusive();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Creating collections.")]
+ private partial void LogCreatingCollections();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Created collection {Collection} from {Count} entries.")]
+ private partial void LogCreatedFromEntries(string collection, int count);
+}
diff --git a/CollectionManager.App.Cli/Commands/DifferenceCommand.cs b/CollectionManager.App.Cli/Commands/DifferenceCommand.cs
new file mode 100644
index 0000000..4880f07
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/DifferenceCommand.cs
@@ -0,0 +1,62 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Linq;
+using System.Threading.Tasks;
+
+[Verb("difference", HelpText = "Find difference between collections (beatmaps that are present in only one collection).")]
+internal sealed partial class DifferenceCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Option('i', "ids", Required = true, HelpText = "Collection Ids to compare (comma or space separated).")]
+ public required IEnumerable Ids { get; init; }
+
+ [Option('n', "name", Required = true, HelpText = "Name for the created collection.")]
+ public required string NewName { get; init; }
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ List collectionIds = [.. Ids];
+
+ if (collectionIds.Count < 2)
+ {
+ LogAtLeastTwoIdsRequired();
+
+ return Task.FromResult(1);
+ }
+
+ IEnumerable collections = context.Manager.GetCollectionsById(collectionIds);
+ HashSet foundIds = [.. collections.Select(c => c.Id)];
+ List missingIds = [.. collectionIds.Where(id => !foundIds.Contains(id))];
+
+ if (missingIds.Count > 0)
+ {
+ LogCollectionIdsNotFound(string.Join(", ", missingIds));
+
+ return Task.FromResult(1);
+ }
+
+ List names = [.. collections.Select(c => c.Name)];
+ string newCollectionName = context.Manager.GetValidCollectionName(NewName);
+ CollectionEditArgs args = CollectionEditArgs.DifferenceCollections(names, newCollectionName);
+ context.Manager.EditCollection(args);
+
+ LogCollectionsDifferenced(names.Count, CollectionLogger.FormatCollection(context.Manager.GetCollectionByName(newCollectionName)));
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "At least 2 Ids are required for difference.")]
+ private partial void LogAtLeastTwoIdsRequired();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Collection Id(s) not found: {MissingIds}")]
+ private partial void LogCollectionIdsNotFound(string missingIds);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Found difference of {Count} collection(s) into {DifferenceCollection}")]
+ private partial void LogCollectionsDifferenced(int count, string differenceCollection);
+}
diff --git a/CollectionManager.App.Cli/Commands/DuplicateCommand.cs b/CollectionManager.App.Cli/Commands/DuplicateCommand.cs
new file mode 100644
index 0000000..83ccd6c
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/DuplicateCommand.cs
@@ -0,0 +1,50 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Linq;
+using System.Threading.Tasks;
+
+[Verb("duplicate", HelpText = "Duplicate a collection.")]
+internal sealed partial class DuplicateCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Option('i', "id", Required = true, HelpText = "Collection Id to duplicate.")]
+ public required int Id { get; init; }
+
+ [Option('n', "name", Required = true, HelpText = "Name for the duplicated collection.")]
+ public required string NewName { get; init; }
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ List collectionIds = [Id];
+ IEnumerable collections = context.Manager.GetCollectionsById(collectionIds);
+ HashSet foundIds = [.. collections.Select(c => c.Id)];
+
+ if (!foundIds.Contains(Id))
+ {
+ LogCollectionIdNotFound(Id);
+
+ return Task.FromResult(1);
+ }
+
+ IOsuCollection collection = collections.First();
+ string newCollectionName = context.Manager.GetValidCollectionName(NewName);
+ CollectionEditArgs args = CollectionEditArgs.DuplicateCollection(collection.Name, newCollectionName);
+ context.Manager.EditCollection(args);
+
+ LogCollectionDuplicated(CollectionLogger.FormatCollection(collection), CollectionLogger.FormatCollection(context.Manager.GetCollectionByName(newCollectionName)));
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Collection Id {Id} not found.")]
+ private partial void LogCollectionIdNotFound(int id);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Duplicated {OriginalCollection} to {NewCollection}")]
+ private partial void LogCollectionDuplicated(string originalCollection, string newCollection);
+}
diff --git a/CollectionManager.App.Cli/Commands/GenerateCommand.cs b/CollectionManager.App.Cli/Commands/GenerateCommand.cs
new file mode 100644
index 0000000..ce393c5
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/GenerateCommand.cs
@@ -0,0 +1,207 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CollectionManager.Extensions.DataTypes;
+using CollectionManager.Extensions.Modules.CollectionApiGenerator;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+[Verb("generate", HelpText = "Generate collections from user top scores using osu! API")]
+internal sealed partial class GenerateCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Option('u', "usernames", Required = true, HelpText = "Comma or whitespace separated list of usernames. Can also be path to a file.")]
+ public required string Usernames { get; init; }
+
+ [Option('k', "api-key", Required = true, HelpText = "osu! API key for accessing user data.")]
+ public required string ApiKey { get; init; }
+
+ [Option('p', "pattern", Required = false, HelpText = "Collection name format: {0}=username, {1}=mods. Default: \"{0} - {1}\"")]
+ public string CollectionNamePattern { get; init; } = "{0} - {1}";
+
+ [Option('g', "gamemode", Required = false, HelpText = "Game mode: 0=Osu, 1=Taiko, 2=Catch, 3=Mania. Default: 0")]
+ public int Gamemode { get; init; } = 0;
+
+ [Option("min-pp", Required = false, HelpText = "Minimum PP required for a score. Default: 0")]
+ public double MinimumPp { get; init; }
+
+ [Option("max-pp", Required = false, HelpText = "Maximum PP allowed for a score. Default: 5000")]
+ public double MaximumPp { get; init; } = 5000;
+
+ [Option("min-acc", Required = false, HelpText = "Minimum accuracy required (0-100). Default: 0")]
+ public double MinimumAcc { get; init; }
+
+ [Option("max-acc", Required = false, HelpText = "Maximum accuracy allowed (0-100). Default: 100")]
+ public double MaximumAcc { get; init; } = 100;
+
+ [Option('r', "ranks", Required = false, HelpText = "Rank filter: 0=S and better, 1=A and worse, 2=All. Default: 2")]
+ public int RankFilter { get; init; } = 2;
+
+ [Option('m', "mods", Required = false, HelpText = "Comma separated required mods (e.g., 'HD,HR'). Empty = all mods.")]
+ public required string Mods { get; init; }
+
+ public override async Task RunAsync(CollectionContext context)
+ {
+ List usernames = ParseUsernames();
+
+ if (usernames.Count == 0)
+ {
+ LogNoValidUsernames();
+ return 1;
+ }
+
+ _ = context.EnsureOsuDatabaseLoaded(this);
+ LogGeneratingCollections(usernames.Count);
+
+ CollectionGeneratorConfiguration configuration = CreateConfiguration(usernames);
+ bool success = await GenerateCollectionsAsync(context, configuration);
+
+ return success ? 0 : 1;
+ }
+
+ private List ParseUsernames()
+ {
+ string rawUsernames = Usernames;
+
+ if (File.Exists(rawUsernames))
+ {
+ rawUsernames = File.ReadAllText(rawUsernames);
+ }
+
+ return [.. rawUsernames.Split(CliConstants.ValueSeparator, StringSplitOptions.RemoveEmptyEntries)
+ .Select(username => username.Trim())
+ .Where(username => !string.IsNullOrWhiteSpace(username))];
+ }
+
+ private CollectionGeneratorConfiguration CreateConfiguration(List usernames)
+ {
+ List modList = [];
+
+ if (!string.IsNullOrWhiteSpace(Mods))
+ {
+ string[] modNames = Mods.Split(CliConstants.SimpleValueSeparator, StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (string modName in modNames)
+ {
+ if (Enum.TryParse(modName, ignoreCase: true, out Mods mod))
+ {
+ modList.Add(mod);
+ }
+ else
+ {
+ LogInvalidMod(modName);
+ }
+ }
+ }
+
+ return new CollectionGeneratorConfiguration
+ {
+ ApiKey = ApiKey,
+ Usernames = usernames,
+ CollectionNameSavePattern = CollectionNamePattern,
+ Gamemode = Gamemode,
+ ScoreSaveConditions = new ScoreSaveConditions
+ {
+ MinimumPp = MinimumPp,
+ MaximumPp = MaximumPp,
+ MinimumAcc = MinimumAcc,
+ MaximumAcc = MaximumAcc,
+ RanksToGet = (RankTypes)RankFilter,
+ ModCombinations = modList
+ }
+ };
+ }
+
+ private async Task GenerateCollectionsAsync(CollectionContext context, CollectionGeneratorConfiguration configuration)
+ {
+ using CollectionsApiGenerator generator = new(context.LoadedMaps);
+ using CancellationTokenSource cts = new();
+ Console.CancelKeyPress += cancelEventHandler;
+
+ try
+ {
+ generator.StatusUpdated += (s, e) =>
+ {
+ if (!string.IsNullOrWhiteSpace(generator.Status))
+ {
+ LogGeneratorStatus(generator.Status);
+ }
+ };
+
+ generator.GenerateCollection(configuration);
+
+ await Task.Run(async () =>
+ {
+ while (!cts.Token.IsCancellationRequested)
+ {
+ await Task.Delay(100);
+
+ if (generator.Collections != null && generator.Collections.Count > 0)
+ {
+ break;
+ }
+ }
+
+ if (cts.Token.IsCancellationRequested)
+ {
+ await generator.AbortAsync();
+ }
+ });
+
+ if (cts.Token.IsCancellationRequested || generator.Collections == null)
+ {
+ LogGenerationAborted();
+ return false;
+ }
+
+ // Add generated collections via manager to get proper Id assignment
+ CollectionEditArgs args = CollectionEditArgs.AddCollections(generator.Collections);
+ context.Manager.EditCollection(args);
+
+ LogGeneratedCollections(generator.Collections.Count);
+ return true;
+ }
+ finally
+ {
+ Console.CancelKeyPress -= cancelEventHandler;
+ }
+
+ void cancelEventHandler(object? s, ConsoleCancelEventArgs e)
+ {
+ e.Cancel = true;
+ LogAborting();
+ cts.Cancel();
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "No valid usernames provided.")]
+ private partial void LogNoValidUsernames();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Generating collections for {Count} user(s).")]
+ private partial void LogGeneratingCollections(int count);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Invalid mod '{ModName}' will be ignored.")]
+ private partial void LogInvalidMod(string modName);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Aborting...")]
+ private partial void LogAborting();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "{Status}")]
+ private partial void LogGeneratorStatus(string status);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Generation was aborted.")]
+ private partial void LogGenerationAborted();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Generated {Count} collection(s).")]
+ private partial void LogGeneratedCollections(int count);
+}
diff --git a/CollectionManager.App.Cli/Commands/InteractiveCommand.cs b/CollectionManager.App.Cli/Commands/InteractiveCommand.cs
new file mode 100644
index 0000000..1629fa4
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/InteractiveCommand.cs
@@ -0,0 +1,79 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Pipeline;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+
+[Verb("interactive", HelpText = "Enter interactive REPL mode. This can be used standalone or anywhere in a --then pipeline chain.")]
+internal sealed partial class InteractiveCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ public override async Task RunAsync(CollectionContext context)
+ {
+ LogWelcomeMessage();
+ LogHelpHint();
+
+ int commandCount = 0;
+
+ while (true)
+ {
+ commandCount++;
+ Console.Write($"[{commandCount}]> ");
+ string? input = Console.ReadLine();
+
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ continue;
+ }
+
+ string trimmedInput = input.Trim();
+
+ if (IsExitCommand(trimmedInput))
+ {
+ LogExiting();
+ return 0;
+ }
+
+ await ProcessInput(context, trimmedInput);
+ }
+ }
+
+ private async Task ProcessInput(CollectionContext context, string trimmedInput)
+ {
+ string[] args = PipelineParser.ParseLine(trimmedInput);
+
+ if (args.Length == 0)
+ {
+ return;
+ }
+
+ int result = await PipelineExecutor.ExecuteSingleCommandAsync(args, context);
+
+ if (result != 0)
+ {
+ LogCommandFailed(result);
+ }
+
+ return;
+ }
+
+ private static bool IsExitCommand(string input)
+ {
+ string lower = input.ToLowerInvariant();
+ return lower is "exit" or "quit" or "resume";
+ }
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "")]
+ private partial void LogWelcomeMessage();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Enter commands one at a time. Type 'exit', 'quit', or 'resume' to leave interactive mode.")]
+ private partial void LogHelpHint();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Exiting interactive mode.")]
+ private partial void LogExiting();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Command failed with exit code {ExitCode}")]
+ private partial void LogCommandFailed(int exitCode);
+}
diff --git a/CollectionManager.App.Cli/Commands/IntersectCommand.cs b/CollectionManager.App.Cli/Commands/IntersectCommand.cs
new file mode 100644
index 0000000..fe06aa6
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/IntersectCommand.cs
@@ -0,0 +1,62 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Linq;
+using System.Threading.Tasks;
+
+[Verb("intersect", HelpText = "Intersect collections (beatmaps present in all collections).")]
+internal sealed partial class IntersectCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Option('i', "ids", Required = true, HelpText = "Collection Ids to intersect.")]
+ public required IEnumerable Ids { get; init; }
+
+ [Option('n', "name", Required = true, HelpText = "Name for the intersected collection.")]
+ public required string NewName { get; init; }
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ List collectionIds = [.. Ids];
+
+ if (collectionIds.Count < 2)
+ {
+ LogAtLeastTwoIdsRequired();
+
+ return Task.FromResult(1);
+ }
+
+ IEnumerable collections = context.Manager.GetCollectionsById(collectionIds);
+ HashSet foundIds = [.. collections.Select(c => c.Id)];
+ List missingIds = [.. collectionIds.Where(id => !foundIds.Contains(id))];
+
+ if (missingIds.Count > 0)
+ {
+ LogCollectionIdsNotFound(string.Join(", ", missingIds));
+
+ return Task.FromResult(1);
+ }
+
+ List names = [.. collections.Select(c => c.Name)];
+ string newCollectionName = context.Manager.GetValidCollectionName(NewName);
+ CollectionEditArgs args = CollectionEditArgs.IntersectCollections(names, newCollectionName);
+ context.Manager.EditCollection(args);
+
+ LogCollectionsIntersected(names.Count, CollectionLogger.FormatCollection(context.Manager.GetCollectionByName(newCollectionName)));
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "At least 2 Ids are required for intersect.")]
+ private partial void LogAtLeastTwoIdsRequired();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Collection Id(s) not found: {MissingIds}")]
+ private partial void LogCollectionIdsNotFound(string missingIds);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Intersected {Count} collection(s) into {IntersectedCollection}")]
+ private partial void LogCollectionsIntersected(int count, string intersectedCollection);
+}
diff --git a/CollectionManager.App.Cli/Commands/InverseCommand.cs b/CollectionManager.App.Cli/Commands/InverseCommand.cs
new file mode 100644
index 0000000..7080ca5
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/InverseCommand.cs
@@ -0,0 +1,72 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Linq;
+using System.Threading.Tasks;
+
+[Verb("inverse", HelpText = "Inverse collection (loaded beatmaps not in the specified collections)")]
+internal sealed partial class InverseCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Option('i', "ids", Required = true, HelpText = "Collection Ids to inverse.")]
+ public required IEnumerable Ids { get; init; }
+
+ [Option('n', "name", Required = true, HelpText = "Name for the inverted collection.")]
+ public required string NewName { get; init; }
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ if (context.LoadedMaps.Beatmaps.Count == 0)
+ {
+ LogBeatmapsNotLoaded();
+
+ return Task.FromResult(1);
+ }
+
+ List collectionIds = [.. Ids];
+
+ if (collectionIds.Count < 1)
+ {
+ LogAtLeastOneIdRequired();
+
+ return Task.FromResult(1);
+ }
+
+ IEnumerable collections = context.Manager.GetCollectionsById(collectionIds);
+ HashSet foundIds = [.. collections.Select(c => c.Id)];
+ List missingIds = [.. collectionIds.Where(id => !foundIds.Contains(id))];
+
+ if (missingIds.Count > 0)
+ {
+ LogCollectionIdsNotFound(string.Join(", ", missingIds));
+
+ return Task.FromResult(1);
+ }
+
+ List names = [.. collections.Select(c => c.Name)];
+ string newCollectionName = context.Manager.GetValidCollectionName(NewName);
+ CollectionEditArgs args = CollectionEditArgs.InverseCollections(names, newCollectionName);
+ context.Manager.EditCollection(args);
+
+ LogCollectionsInversed(names.Count, CollectionLogger.FormatCollection(context.Manager.GetCollectionByName(newCollectionName)));
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Beatmaps not loaded. Use '{LoadMapsCommandName}' command first.")]
+ private partial void LogBeatmapsNotLoaded(string loadMapsCommandName = "load-maps");
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "At least 1 Id is required for inverse.")]
+ private partial void LogAtLeastOneIdRequired();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Collection Id(s) not found: {MissingIds}")]
+ private partial void LogCollectionIdsNotFound(string missingIds);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Inversed {Count} collection(s) into {InverseCollection}")]
+ private partial void LogCollectionsInversed(int count, string inverseCollection);
+}
diff --git a/CollectionManager.App.Cli/Commands/ListCommand.cs b/CollectionManager.App.Cli/Commands/ListCommand.cs
new file mode 100644
index 0000000..71c4c5b
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/ListCommand.cs
@@ -0,0 +1,45 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+
+[Verb("list", aliases: ["ls"], HelpText = "List loaded collections")]
+internal sealed partial class ListCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ if (context.Collections.Count == 0)
+ {
+ LogNoCollectionsLoaded();
+ return Task.FromResult(0);
+ }
+
+ int displayedCount = 0;
+
+ foreach (IOsuCollection collection in context.Collections)
+ {
+ int total = collection.NumberOfBeatmaps;
+ int missing = collection.NumberOfMissingBeatmaps;
+
+ string formatted = CollectionLogger.FormatCollection(collection, includeCounts: true);
+ LogCollectionEntry(formatted);
+
+ displayedCount++;
+ }
+
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "No collections loaded.")]
+ private partial void LogNoCollectionsLoaded();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "{Collection}")]
+ private partial void LogCollectionEntry(string collection);
+
+}
diff --git a/CollectionManager.App.Cli/Commands/LoadCommand.cs b/CollectionManager.App.Cli/Commands/LoadCommand.cs
new file mode 100644
index 0000000..0b2309f
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/LoadCommand.cs
@@ -0,0 +1,144 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.FileIo;
+using CollectionManager.Core.Modules.FileIo.FileCollections;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.IO;
+using System.Threading.Tasks;
+
+[Verb("load", aliases: ["open"], HelpText = "Load collections from file")]
+internal sealed partial class LoadCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Value(0, MetaName = "input", Required = false, HelpText = "Input .db/.osdb/.realm file (positional).")]
+ public string? InputFilePositional { get; init; }
+
+ [Option('i', "input", Required = false, HelpText = "Input .db/.osdb/.realm file")]
+ public string? InputFile { get; init; }
+
+ [Option('a', "auto", Required = false, HelpText = "Load collection from auto-detected osu! installation. This is assumed when --lazer or --stable is set.")]
+ public bool Auto { get; init; }
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ string? effectiveInputFile = InputFilePositional ?? InputFile;
+
+ if (Auto || PreferLazer || PreferStable)
+ {
+ effectiveInputFile = ResolveAutoCollectionPath();
+
+ if (effectiveInputFile == default)
+ {
+ return Task.FromResult(1);
+ }
+ }
+
+ if (string.IsNullOrEmpty(effectiveInputFile))
+ {
+ LogInputFileRequired();
+
+ return Task.FromResult(1);
+ }
+
+ if (!File.Exists(effectiveInputFile))
+ {
+ LogFileNotFound(effectiveInputFile);
+ return Task.FromResult(1);
+ }
+
+ try
+ {
+ CollectionLoadResult loaded = context.LoadCollectionsFromFile(effectiveInputFile);
+ LogLoadedCollections(loaded.Collections.Count, effectiveInputFile);
+
+ switch (loaded)
+ {
+ case RealmCollectionLoadResult realmLoaded:
+ LogLoadedRealmSchemaVersion((int)realmLoaded.RealmSchemaVersion, effectiveInputFile);
+ break;
+ case DbCollectionLoadResult dbLoaded:
+ LogLoadedDbFileVersion(dbLoaded.FileVersion, effectiveInputFile);
+ break;
+ }
+
+ return Task.FromResult(0);
+ }
+ catch (Exception ex)
+ {
+ LogErrorLoadingCollections(ex.Message);
+ return Task.FromResult(1);
+ }
+ }
+
+ private string? ResolveAutoCollectionPath()
+ {
+ OsuPathResult osuPath = OsuPathResolver.GetOsuOrLazerPath();
+
+ if (osuPath.Type is OsuType.None)
+ {
+ return default;
+ }
+
+ string path;
+ OsuType type;
+
+ if (PreferStable)
+ {
+ if (osuPath.StablePath == default)
+ {
+ LogPreferredInstallationNotFound("Stable");
+ return default;
+ }
+
+ path = osuPath.StablePath;
+ type = OsuType.Stable;
+ }
+ else if (PreferLazer)
+ {
+ if (osuPath.LazerPath == default)
+ {
+ LogPreferredInstallationNotFound("Lazer");
+ return default;
+ }
+
+ path = osuPath.LazerPath;
+ type = OsuType.Lazer;
+ }
+ else
+ {
+ path = osuPath.Path;
+ type = osuPath.Type;
+ }
+
+ return type switch
+ {
+ OsuType.Stable => Path.Combine(path, "collection.db"),
+ OsuType.Lazer => Path.Combine(path, "client.realm"),
+ _ => default
+ };
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Input file is required.")]
+ private partial void LogInputFileRequired();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "File not found: {Path}")]
+ private partial void LogFileNotFound(string path);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Could not find osu! {InstallationName} installation.")]
+ private partial void LogPreferredInstallationNotFound(string installationName);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Loaded collection file version: {Version} ({Path})")]
+ private partial void LogLoadedDbFileVersion(int version, string path);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Loaded realm schema version: {Version} ({Path})")]
+ private partial void LogLoadedRealmSchemaVersion(int version, string path);
+ [LoggerMessage(Level = LogLevel.Information, Message = "Loaded {Count} collection(s) from {Path}")]
+ private partial void LogLoadedCollections(int count, string path);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Error loading collections: {Message}")]
+ private partial void LogErrorLoadingCollections(string message);
+}
diff --git a/CollectionManager.App.Cli/Commands/LoadOsuDbCommand.cs b/CollectionManager.App.Cli/Commands/LoadOsuDbCommand.cs
new file mode 100644
index 0000000..1aeb6a0
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/LoadOsuDbCommand.cs
@@ -0,0 +1,12 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Pipeline;
+using CommandLine;
+using System.Threading.Tasks;
+
+[Verb("load-maps", HelpText = "Load osu! database for beatmap lookups")]
+internal sealed partial class LoadOsuDbCommand : PipelineOptions, IPipelineCommand
+{
+ public override Task RunAsync(CollectionContext context)
+ => Task.FromResult(context.EnsureOsuDatabaseLoaded(this) ? 0 : 1);
+}
diff --git a/CollectionManager.App.Cli/Commands/MergeCommand.cs b/CollectionManager.App.Cli/Commands/MergeCommand.cs
new file mode 100644
index 0000000..7299749
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/MergeCommand.cs
@@ -0,0 +1,66 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Linq;
+using System.Threading.Tasks;
+
+[Verb("merge", HelpText = "Merge collections into one")]
+internal sealed partial class MergeCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Option('i', "ids", Required = true, HelpText = "Collection Ids to merge (comma or space separated). Example: -i 1 2 3")]
+ public required IEnumerable Ids { get; init; }
+
+ [Option('n', "name", Required = true, HelpText = "Name for the merged collection")]
+ public required string NewName { get; init; }
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ List collectionIds = [.. Ids];
+
+ if (collectionIds.Count < 2)
+ {
+ LogAtLeastTwoIdsRequired();
+
+ return Task.FromResult(1);
+ }
+
+ IEnumerable collections = context.Manager.GetCollectionsById(collectionIds);
+ HashSet foundIds = [.. collections.Select(c => c.Id)];
+ List missingIds = [.. collectionIds.Where(id => !foundIds.Contains(id))];
+
+ if (missingIds.Count > 0)
+ {
+ LogCollectionIdsNotFound(string.Join(", ", missingIds));
+
+ return Task.FromResult(1);
+ }
+
+ List names = [.. collections.Select(c => c.Name)];
+
+ // Get valid name for merged collection
+ string mergedName = context.Manager.GetValidCollectionName(NewName);
+
+ // Execute merge
+ CollectionEditArgs args = CollectionEditArgs.MergeCollections(names, mergedName);
+ context.Manager.EditCollection(args);
+
+ LogCollectionsMerged(names.Count, CollectionLogger.FormatCollection(context.Manager.GetCollectionByName(mergedName)));
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "At least 2 Ids are required for merge.")]
+ private partial void LogAtLeastTwoIdsRequired();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Collection Id(s) not found: {MissingIds}")]
+ private partial void LogCollectionIdsNotFound(string missingIds);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Merged {Count} collection(s) into {MergedCollection}")]
+ private partial void LogCollectionsMerged(int count, string mergedCollection);
+}
diff --git a/CollectionManager.App.Cli/Commands/PipelineHelpCommand.cs b/CollectionManager.App.Cli/Commands/PipelineHelpCommand.cs
new file mode 100644
index 0000000..46ad0f8
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/PipelineHelpCommand.cs
@@ -0,0 +1,38 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Pipeline;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+
+[Verb("pipeline", HelpText = "Show help for pipeline mode (chaining commands with --then)")]
+internal sealed partial class PipelineHelpCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ LogPipelineHelp(@"
+Chain multiple commands using --then.
+
+Usage:
+ CollectionManager.App.Cli.exe [options] --then [options] [--then ...]
+
+Examples:
+
+ # Create, rename, and save
+ create -i ""1 2 3"" --then ls --then rename -i 0 -n ""another name"" --then save -o fromIds.osdb
+
+ # Generate and save
+ generate -u ""player"" -k ""API_KEY"" --then save -o output.osdb
+ # -o verb works on any command, so you can also do:
+ generate -u ""player"" -k ""API_KEY"" -o output.osdb
+
+ # Load collections and database from osu! stable installation, then save as osdb, as transferrable backup.
+ load --stable --then load-maps --stable --then convert -o C:\some\cloud\folder\backup.osdb");
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "{HelpText}")]
+ private partial void LogPipelineHelp(string helpText);
+}
diff --git a/CollectionManager.App.Cli/Commands/RemoveCommand.cs b/CollectionManager.App.Cli/Commands/RemoveCommand.cs
new file mode 100644
index 0000000..aeafca9
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/RemoveCommand.cs
@@ -0,0 +1,125 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Linq;
+using System.Threading.Tasks;
+
+[Verb("remove", aliases: ["rm"], HelpText = "Remove collection(s) by Id or name")]
+internal sealed partial class RemoveCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Value(0, Required = false, HelpText = "Space separated collection Ids to remove. Example: remove 1 2 3")]
+ public IEnumerable? PositionalIds { get; init; }
+
+ [Option('i', "ids", Required = false, HelpText = "Space separated collection Ids to remove. Example: -i 1 2 3")]
+ public required IEnumerable Ids { get; init; }
+
+ [Option('n', "names", Required = false, HelpText = "Space separated collection names to remove. Example: -n \"Collection 1\" \"Collection 2\"")]
+ public required IEnumerable CollectionNames { get; init; }
+
+ public override async Task RunAsync(CollectionContext context)
+ {
+ IEnumerable? effectiveIds = PositionalIds != null && PositionalIds.Any()
+ ? PositionalIds
+ : Ids;
+
+ bool hasIds = effectiveIds != null && effectiveIds.Any();
+ bool hasNames = CollectionNames != null && CollectionNames.Any();
+
+ if (!hasIds && !hasNames)
+ {
+ LogIdOrCollectionRequired();
+ return 1;
+ }
+
+ if (hasIds && hasNames)
+ {
+ LogIdsAndNamesMutuallyExclusive();
+ return 1;
+ }
+
+ List? collections;
+
+ if (hasIds)
+ {
+ collections = Process(
+ context,
+ effectiveIds!,
+ c => c.Id,
+ ids => LogCollectionIdsNotFound(string.Join(", ", ids)));
+ }
+ else
+ {
+ collections = Process(
+ context,
+ CollectionNames!,
+ c => c.Name,
+ names => LogCollectionNamesNotFound(string.Join(", ", names.Select(n => $"'{n}'"))));
+ }
+
+ if (collections is null)
+ {
+ return 1;
+ }
+
+ List names = [.. collections.Select(c => c.Name)];
+ CollectionEditArgs args = CollectionEditArgs.RemoveCollections(names);
+ context.Manager.EditCollection(args);
+
+ IEnumerable formatted = collections.Select(CollectionLogger.FormatCollection);
+ LogCollectionsRemoved(collections.Count, string.Join(", ", formatted));
+
+ return 0;
+ }
+
+ private static List? Process(
+ CollectionContext context,
+ IEnumerable identifiers,
+ Func idSelector,
+ Action> logMissing)
+ {
+ IEnumerable found = context.GetCollections(identifiers, idSelector);
+ HashSet foundIds = [.. found.Select(idSelector)];
+ List missing = [.. identifiers.Where(id => !foundIds.Contains(id))];
+
+ if (missing.Count > 0)
+ {
+ logMissing(missing);
+ return null;
+ }
+
+ List? collections = [];
+
+ foreach (IOsuCollection collection in found)
+ {
+ if (!collections.Contains(collection))
+ {
+ collections.Add(collection);
+ }
+ }
+
+ return collections;
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Either --id(s) or --names is required.")]
+ private partial void LogIdOrCollectionRequired();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "--id(s) and --names cannot be used together.")]
+ private partial void LogIdsAndNamesMutuallyExclusive();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Collection Id(s) not found: {MissingIds}")]
+ private partial void LogCollectionIdsNotFound(string missingIds);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Collection(s) not found: {MissingNames}")]
+ private partial void LogCollectionNamesNotFound(string missingNames);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Removed {Count} collection(s): {Names}")]
+ private partial void LogCollectionsRemoved(int count, string names);
+
+}
diff --git a/CollectionManager.App.Cli/Commands/RenameCommand.cs b/CollectionManager.App.Cli/Commands/RenameCommand.cs
new file mode 100644
index 0000000..8d88329
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/RenameCommand.cs
@@ -0,0 +1,93 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Types;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.Threading.Tasks;
+
+[Verb("rename", aliases: ["mv"], HelpText = "Rename a collection by Id or name")]
+internal sealed partial class RenameCommand : PipelineOptions, IPipelineCommand
+{
+ private readonly ILogger _logger = Program.Logger;
+
+ [Value(0, MetaName = "id", Required = false, HelpText = "Collection Id (positional).")]
+ public int? IdPositional { get; init; }
+
+ [Value(1, MetaName = "name", Required = false, HelpText = "New name for the collection (positional).")]
+ public string? NewNamePositional { get; init; }
+
+ [Option('i', "id", HelpText = "Existing collection Id. Takes precedence over --collection if both provided.")]
+ public int? Id { get; init; }
+
+ [Option('c', "collection", HelpText = "Existing collection name.")]
+ public string? CollectionName { get; init; }
+
+ [Option('n', "name", Required = false, HelpText = "New name for the collection.")]
+ public string? NewName { get; init; }
+
+ public override Task RunAsync(CollectionContext context)
+ {
+ int? effectiveId = IdPositional ?? Id;
+ string? effectiveNewName = NewNamePositional ?? NewName;
+
+ if (!effectiveId.HasValue && string.IsNullOrEmpty(CollectionName))
+ {
+ LogIdOrCollectionRequired();
+
+ return Task.FromResult(1);
+ }
+
+ if (string.IsNullOrWhiteSpace(effectiveNewName))
+ {
+ LogNewNameEmpty();
+
+ return Task.FromResult(1);
+ }
+
+ IOsuCollection? collection = context.GetCollection(effectiveId, CollectionName);
+
+ if (collection == default)
+ {
+ string identifier = effectiveId.HasValue ? $"Id {effectiveId.Value}" : $"'{CollectionName}'";
+ LogCollectionNotFound(identifier);
+
+ return Task.FromResult(1);
+ }
+
+ IOsuCollection? existingWithNewName = context.GetCollection(default, effectiveNewName);
+
+ if (existingWithNewName != default && existingWithNewName != collection)
+ {
+ LogCollectionNameExists(effectiveNewName);
+
+ return Task.FromResult(1);
+ }
+
+ string oldName = collection.Name;
+ CollectionEditArgs args = CollectionEditArgs.RenameCollection(collection, effectiveNewName);
+ context.Manager.EditCollection(args);
+
+ string collectionIdentifier = CollectionLogger.FormatCollection(collection.Id, oldName);
+ LogRenamed(collectionIdentifier, effectiveNewName);
+
+ return Task.FromResult(0);
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Either --id or --collection is required.")]
+ private partial void LogIdOrCollectionRequired();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "New name cannot be empty.")]
+ private partial void LogNewNameEmpty();
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Collection {Collection} not found.")]
+ private partial void LogCollectionNotFound(string collection);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "A collection named '{Collection}' already exists.")]
+ private partial void LogCollectionNameExists(string collection);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Renamed '{Collection}' to '{NewName}'.")]
+ private partial void LogRenamed(string collection, string newName);
+}
diff --git a/CollectionManager.App.Cli/Commands/SaveCommand.cs b/CollectionManager.App.Cli/Commands/SaveCommand.cs
new file mode 100644
index 0000000..95a27f1
--- /dev/null
+++ b/CollectionManager.App.Cli/Commands/SaveCommand.cs
@@ -0,0 +1,20 @@
+namespace CollectionManager.App.Cli.Commands;
+
+using CollectionManager.App.Cli.Pipeline;
+using CommandLine;
+using System.Threading.Tasks;
+
+[Verb("save", HelpText = "Save pipeline collections to file")]
+internal sealed class SaveCommand : PipelineOptions, IPipelineCommand
+{
+ [Value(0, MetaName = "output", Required = false, HelpText = "Output .db/.osdb/.realm file (positional).")]
+ public string? OutputFilePositional { get; init; }
+
+ [Option('o', "output", Required = false, HelpText = "Output .db/.osdb/.realm file")]
+ public override string? OutputFile { get => OutputFilePositional ?? field; init; }
+
+ public override Task RunAsync(CollectionContext context) =>
+ // No-op. Handled in pipeline executor for all commands.
+ // This is here only so there's an save-only command.
+ Task.FromResult(0);
+}
diff --git a/CollectionManager.App.Cli/Common/CommonOptions.cs b/CollectionManager.App.Cli/Common/CommonOptions.cs
deleted file mode 100644
index 0d917a2..0000000
--- a/CollectionManager.App.Cli/Common/CommonOptions.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace CollectionManager.App.Cli.Common;
-
-using CommandLine;
-
-internal abstract class CommonOptions
-{
- [Option('o', "Output", Required = true, HelpText = "Output filename with or without path.")]
- public string OutputFilePath { get; init; }
-
- [Option('l', "OsuLocation", Required = false, HelpText = "Location of your osu! or directory where valid osu!.db or client.realm can be found. If not provided, will be found automatically.")]
- public string OsuLocation { get; init; }
-
- [Option('s', "SkipOsuLocation", Required = false, HelpText = "Skip loading of osu! database.")]
- public bool SkipOsuLocation { get; init; }
-}
diff --git a/CollectionManager.App.Cli/Common/CommonOptionsExtensions.cs b/CollectionManager.App.Cli/Common/CommonOptionsExtensions.cs
deleted file mode 100644
index acb3387..0000000
--- a/CollectionManager.App.Cli/Common/CommonOptionsExtensions.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-namespace CollectionManager.App.Cli.Common;
-
-using CollectionManager.Core.Extensions;
-using CollectionManager.Core.Modules.FileIo;
-using CollectionManager.Core.Types;
-using System.IO;
-
-internal static class CommonOptionsExtensions
-{
- public static OsuFileIo LoadOsuDatabase(this CommonOptions options)
- {
- OsuFileIo osuFileIo = new(new BeatmapExtension());
-
- if (options.SkipOsuLocation)
- {
- return osuFileIo;
- }
-
- string osuLocation = ResolveOsuLocation(options);
-
- if (string.IsNullOrWhiteSpace(osuLocation))
- {
- throw new InvalidOperationException("Could not find osu!");
- }
-
- Console.WriteLine($"Using osu! database found at \"{osuLocation}\".");
- _ = osuFileIo.OsuDatabase.Load(osuLocation, progress: null, cancellationToken: default);
-
- return osuFileIo;
- }
-
- private static string ResolveOsuLocation(CommonOptions options)
- {
- string path = options.OsuLocation;
-
- if (string.IsNullOrWhiteSpace(path))
- {
- OsuPathResult osuPath = OsuPathResolver.GetOsuOrLazerPath();
-
- if (osuPath.Type is OsuType.None)
- {
- return null;
- }
-
- path = Path.Combine(osuPath.Path, osuPath.Type.GetDatabaseFileName());
- }
-
- if (Path.HasExtension(path))
- {
- return path;
- }
-
- if (OsuPathResolver.IsOsuStableDirectory(path))
- {
- return Path.Combine(path, OsuType.Stable.GetDatabaseFileName());
- }
-
- if (OsuPathResolver.IsOsuLazerDataDirectory(path))
- {
- return Path.Combine(path, OsuType.Lazer.GetDatabaseFileName());
- }
-
- return path;
- }
-}
diff --git a/CollectionManager.App.Cli/Convert/ConvertCommand.cs b/CollectionManager.App.Cli/Convert/ConvertCommand.cs
deleted file mode 100644
index dd21719..0000000
--- a/CollectionManager.App.Cli/Convert/ConvertCommand.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-namespace CollectionManager.App.Cli.Convert;
-
-using CollectionManager.App.Cli.Common;
-using CollectionManager.Core.Modules.FileIo;
-using CollectionManager.Core.Types;
-using CommandLine;
-using System.Threading.Tasks;
-
-[Verb("convert", HelpText = "Convert collection files between formats (.db/.osdb)")]
-internal sealed class ConvertCommand : CommonOptions
-{
- [Option('i', "Input", Required = true, HelpText = "Input db/osdb collection file.")]
- public string InputFilePath { get; init; }
-
- public Task RunAsync()
- {
- using OsuFileIo osuFileIo = this.LoadOsuDatabase();
- Console.WriteLine("Converting collections.");
- OsuCollections collections = osuFileIo.CollectionLoader.LoadCollection(InputFilePath);
- osuFileIo.CollectionLoader.SaveCollection(collections, OutputFilePath);
- Console.WriteLine("Done.");
-
- return Task.FromResult(0);
- }
-}
diff --git a/CollectionManager.App.Cli/Create/CreateCommand.cs b/CollectionManager.App.Cli/Create/CreateCommand.cs
deleted file mode 100644
index 0ada315..0000000
--- a/CollectionManager.App.Cli/Create/CreateCommand.cs
+++ /dev/null
@@ -1,118 +0,0 @@
-namespace CollectionManager.App.Cli.Create;
-
-using CollectionManager.App.Cli.Common;
-using CollectionManager.Core.Modules.FileIo;
-using CollectionManager.Core.Types;
-using CommandLine;
-using System.IO;
-using System.Threading.Tasks;
-
-[Verb("create", HelpText = "Create collection from beatmap IDs or hashes")]
-internal sealed class CreateCommand : CommonOptions
-{
- private static readonly char[] Separator = [' ', ',', '\n', '\r', '\t'];
-
- [Option('b', "BeatmapIds", Required = false, HelpText = "Comma or whitespace separated list of beatmap ids. Can be also path to the file.\nYou should have all beatmapIds mentioned available locally in order to generate ready-to-use collection file, otherwise after generating upload it to https://osustats.ppy.sh/collections to get remaining data.")]
- public string BeatmapIds { get; init; }
-
- [Option('h', "Hashes", Required = false, HelpText = "Comma or whitespace separated list of beatmap hashes (MD5). Can be also path to the file.\nYou should have all beatmaps mentioned available locally in order to generate ready-to-use collection file.")]
- public string Hashes { get; init; }
-
- public Task RunAsync()
- {
- if (!Validate())
- {
- return Task.FromResult(1);
- }
-
- using OsuFileIo osuFileIo = this.LoadOsuDatabase();
- Console.WriteLine("Creating collections.");
-
- int result = !string.IsNullOrWhiteSpace(BeatmapIds)
- ? ProcessBeatmapIds(osuFileIo)
- : ProcessHashes(osuFileIo);
-
- return Task.FromResult(result);
- }
-
- private bool Validate()
- {
- bool hasBeatmapIds = !string.IsNullOrWhiteSpace(BeatmapIds);
- bool hasHashes = !string.IsNullOrWhiteSpace(Hashes);
-
- if (!hasBeatmapIds && !hasHashes)
- {
- Console.WriteLine("Error: Either --BeatmapIds or --Hashes must be provided.");
- return false;
- }
-
- if (hasBeatmapIds && hasHashes)
- {
- Console.WriteLine("Error: --BeatmapIds and --Hashes cannot be used together. Use one or the other.");
- return false;
- }
-
- return true;
- }
-
- private int ProcessBeatmapIds(OsuFileIo osuFileIo)
- {
- string rawBeatmapIds = BeatmapIds;
-
- if (File.Exists(rawBeatmapIds))
- {
- rawBeatmapIds = File.ReadAllText(rawBeatmapIds);
- }
-
- string[] beatmapIdArray = rawBeatmapIds.Split(Separator, StringSplitOptions.RemoveEmptyEntries);
- OsuCollection collection = new(osuFileIo.LoadedMaps) { Name = "from mapIds" };
-
- foreach (string beatmapId in beatmapIdArray)
- {
- if (int.TryParse(beatmapId.Trim(), out int id))
- {
- collection.AddBeatmapByMapId(id);
- }
- }
-
- string outputPath = GetOutputPath();
- osuFileIo.CollectionLoader.SaveCollection([collection], outputPath);
- Console.WriteLine($"Done. Created collection from {beatmapIdArray.Length} beatmap IDs.");
-
- return 0;
- }
-
- private int ProcessHashes(OsuFileIo osuFileIo)
- {
- string rawHashes = Hashes;
-
- if (File.Exists(rawHashes))
- {
- rawHashes = File.ReadAllText(rawHashes);
- }
-
- string[] hashArray = rawHashes.Split(Separator, StringSplitOptions.RemoveEmptyEntries);
- OsuCollection collection = new(osuFileIo.LoadedMaps) { Name = "from hashes" };
-
- foreach (string hash in hashArray)
- {
- string trimmedHash = hash.Trim();
-
- if (!string.IsNullOrWhiteSpace(trimmedHash))
- {
- collection.AddBeatmapByHash(trimmedHash);
- }
- }
-
- string outputPath = GetOutputPath();
- osuFileIo.CollectionLoader.SaveCollection([collection], outputPath);
- Console.WriteLine($"Done. Created collection from {hashArray.Length} hashes.");
-
- return 0;
- }
-
- private string GetOutputPath()
- => Path.HasExtension(OutputFilePath)
- ? OutputFilePath
- : $"{OutputFilePath}.osdb";
-}
diff --git a/CollectionManager.App.Cli/Generate/GenerateCommand.cs b/CollectionManager.App.Cli/Generate/GenerateCommand.cs
deleted file mode 100644
index 075fa17..0000000
--- a/CollectionManager.App.Cli/Generate/GenerateCommand.cs
+++ /dev/null
@@ -1,191 +0,0 @@
-namespace CollectionManager.App.Cli.Generate;
-
-using CollectionManager.App.Cli.Common;
-using CollectionManager.Core.Modules.FileIo;
-using CollectionManager.Core.Types;
-using CollectionManager.Extensions.DataTypes;
-using CollectionManager.Extensions.Modules.CollectionApiGenerator;
-using CommandLine;
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-
-[Verb("generate", HelpText = "Generate collections from user top scores using osu! API")]
-internal sealed class GenerateCommand : CommonOptions
-{
- private static readonly char[] Separator = [' ', ',', '\n', '\r', '\t'];
-
- [Option('u', "Usernames", Required = true, HelpText = "Comma or whitespace separated list of usernames. Can be also path to the file.")]
- public string Usernames { get; init; }
-
- [Option('k', "ApiKey", Required = true, HelpText = "osu! API key for accessing user data.")]
- public string ApiKey { get; init; }
-
- [Option('p', "CollectionNamePattern", Required = false, HelpText = "Collection name format pattern. {0}=username, {1}=mods. Default: \"{0} - {1}\"")]
- public string CollectionNamePattern { get; init; } = "{0} - {1}";
-
- [Option('g', "Gamemode", Required = false, HelpText = "Game mode: 0=Osu, 1=Taiko, 2=Catch, 3=Mania. Default: 0")]
- public int Gamemode { get; init; } = 0;
-
- [Option("MinPp", Required = false, HelpText = "Minimum PP required for a score. Default: 0")]
- public double MinimumPp { get; init; }
-
- [Option("MaxPp", Required = false, HelpText = "Maximum PP allowed for a score. Default: 5000")]
- public double MaximumPp { get; init; } = 5000;
-
- [Option("MinAcc", Required = false, HelpText = "Minimum accuracy required for a score (0-100). Default: 0")]
- public double MinimumAcc { get; init; }
-
- [Option("MaxAcc", Required = false, HelpText = "Maximum accuracy allowed for a score (0-100). Default: 100")]
- public double MaximumAcc { get; init; } = 100;
-
- [Option('r', "Ranks", Required = false, HelpText = "Rank filter: 0=S and better, 1=A and worse, 2=All. Default: 2")]
- public int RankFilter { get; init; } = 2;
-
- [Option('m', "Mods", Required = false, HelpText = "Comma separated list of required mods (e.g., 'Hd,Hr'). If empty, all mods are included.")]
- public string Mods { get; init; }
-
- public Task RunAsync()
- {
- List usernames = ParseUsernames();
-
- if (usernames.Count == 0)
- {
- Console.WriteLine("Error: No valid usernames provided.");
- return Task.FromResult(1);
- }
-
- using OsuFileIo osuFileIo = this.LoadOsuDatabase();
- Console.WriteLine($"Generating collections for {usernames.Count} user(s).");
-
- try
- {
- CollectionGeneratorConfiguration configuration = CreateConfiguration(usernames);
- return GenerateCollections(osuFileIo, configuration);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Error: {ex.Message}");
- return Task.FromResult(1);
- }
- }
-
- private List ParseUsernames()
- {
- string rawUsernames = Usernames;
-
- if (File.Exists(rawUsernames))
- {
- rawUsernames = File.ReadAllText(rawUsernames);
- }
-
- return [.. rawUsernames.Split(Separator, StringSplitOptions.RemoveEmptyEntries)
- .Select(username => username.Trim())
- .Where(username => !string.IsNullOrWhiteSpace(username))];
- }
-
- private CollectionGeneratorConfiguration CreateConfiguration(List usernames)
- {
- List modList = [];
-
- if (!string.IsNullOrWhiteSpace(Mods))
- {
- string[] modNames = Mods.Split([' ', ','], StringSplitOptions.RemoveEmptyEntries);
-
- foreach (string modName in modNames)
- {
- if (Enum.TryParse(modName, ignoreCase: true, out Mods mod))
- {
- modList.Add(mod);
- }
- else
- {
- Console.WriteLine($"Warning: Invalid mod '{modName}' will be ignored.");
- }
- }
- }
-
- return new CollectionGeneratorConfiguration
- {
- ApiKey = ApiKey,
- Usernames = usernames,
- CollectionNameSavePattern = CollectionNamePattern,
- Gamemode = Gamemode,
- ScoreSaveConditions = new ScoreSaveConditions
- {
- MinimumPp = MinimumPp,
- MaximumPp = MaximumPp,
- MinimumAcc = MinimumAcc,
- MaximumAcc = MaximumAcc,
- RanksToGet = (RankTypes)RankFilter,
- ModCombinations = modList
- }
- };
- }
-
- private async Task GenerateCollections(OsuFileIo osuFileIo, CollectionGeneratorConfiguration configuration)
- {
- using CollectionsApiGenerator generator = new(osuFileIo.LoadedMaps);
- using CancellationTokenSource cts = new();
-
- Console.CancelKeyPress += (s, e) =>
- {
- e.Cancel = true;
- Console.WriteLine("\nAborting...");
- cts.Cancel();
- };
-
- // Subscribe to status updates
- generator.StatusUpdated += (s, e) =>
- {
- if (!string.IsNullOrWhiteSpace(generator.Status))
- {
- Console.WriteLine(generator.Status);
- }
- };
-
- // Start generation
- generator.GenerateCollection(configuration);
-
- // Wait for completion
- await Task.Run(async () =>
- {
- while (!cts.Token.IsCancellationRequested)
- {
- await Task.Delay(100);
-
- // Check if task completed
- if (generator.Collections != null && generator.Collections.Count > 0)
- {
- break;
- }
- }
-
- if (cts.Token.IsCancellationRequested)
- {
- await generator.AbortAsync();
- }
- });
-
- if (cts.Token.IsCancellationRequested || generator.Collections == null)
- {
- Console.WriteLine("Generation was aborted.");
- return 1;
- }
-
- // Save collections
- string outputPath = GetOutputPath();
- osuFileIo.CollectionLoader.SaveCollection(generator.Collections, outputPath);
- Console.WriteLine($"Done. Generated {generator.Collections.Count} collection(s).");
-
- return 0;
- }
-
- private string GetOutputPath()
- => Path.HasExtension(OutputFilePath)
- ? OutputFilePath
- : $"{OutputFilePath}.osdb";
-}
diff --git a/CollectionManager.App.Cli/Logging/CollectionLogger.cs b/CollectionManager.App.Cli/Logging/CollectionLogger.cs
new file mode 100644
index 0000000..b6ec2b3
--- /dev/null
+++ b/CollectionManager.App.Cli/Logging/CollectionLogger.cs
@@ -0,0 +1,30 @@
+namespace CollectionManager.App.Cli.Logging;
+
+using CollectionManager.Core.Types;
+
+internal static class CollectionLogger
+{
+ public static string FormatCollection(IOsuCollection collection)
+ => FormatCollection(collection.Id, collection.Name);
+
+ public static string FormatCollection(int id, string name)
+ => $"[Id:{id}] {name}";
+
+ public static string FormatCollectionWithCounts(IOsuCollection collection)
+ {
+ int total = collection.NumberOfBeatmaps;
+ int missing = collection.NumberOfMissingBeatmaps;
+
+ if (missing > 0)
+ {
+ return $"{FormatCollection(collection)} ({total} maps, {missing} missing)";
+ }
+
+ return $"{FormatCollection(collection)} ({total} maps)";
+ }
+
+ public static string FormatCollection(IOsuCollection collection, bool includeCounts)
+ => includeCounts
+ ? FormatCollectionWithCounts(collection)
+ : FormatCollection(collection);
+}
diff --git a/CollectionManager.App.Cli/Logging/IndentationEnricher.cs b/CollectionManager.App.Cli/Logging/IndentationEnricher.cs
new file mode 100644
index 0000000..b695055
--- /dev/null
+++ b/CollectionManager.App.Cli/Logging/IndentationEnricher.cs
@@ -0,0 +1,37 @@
+namespace CollectionManager.App.Cli.Logging;
+
+using Serilog.Core;
+using Serilog.Events;
+using System.Threading;
+
+///
+/// Enricher that adds indentation to log events within command execution scopes.
+///
+internal sealed class IndentationEnricher : ILogEventEnricher
+{
+ public const string IndentationProperty = "Indentation";
+ private const string SingleIndent = " ";
+
+ private static readonly AsyncLocal ScopeDepth = new();
+
+ public static IDisposable BeginCommandScope()
+ {
+ ScopeDepth.Value++;
+ return new CommandScopeDisposable();
+ }
+
+ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
+ {
+ int depth = ScopeDepth.Value;
+ string indentation = depth > 0
+ ? SingleIndent
+ : string.Empty;
+
+ logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(IndentationProperty, indentation));
+ }
+
+ private sealed class CommandScopeDisposable : IDisposable
+ {
+ public void Dispose() => ScopeDepth.Value--;
+ }
+}
diff --git a/CollectionManager.App.Cli/Pipeline/CollectionContext.cs b/CollectionManager.App.Cli/Pipeline/CollectionContext.cs
new file mode 100644
index 0000000..bb63e1a
--- /dev/null
+++ b/CollectionManager.App.Cli/Pipeline/CollectionContext.cs
@@ -0,0 +1,170 @@
+namespace CollectionManager.App.Cli.Pipeline;
+using CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+
+using CollectionManager.Core.Extensions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.FileIo.FileCollections;
+using CollectionManager.Core.Modules.FileIo;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Microsoft.Extensions.Logging;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+///
+/// Shared state container for pipeline commands.
+///
+internal sealed partial class CollectionContext : IDisposable
+{
+ private static readonly ILogger Logger = Program.Logger;
+ private readonly OsuFileIo _fileIo;
+
+ public CollectionsManagerWithCounts Manager { get; }
+
+ public OsuCollections Collections => Manager.LoadedCollections;
+ public MapCacher LoadedMaps => _fileIo.LoadedMaps;
+
+ public CollectionContext()
+ {
+ _fileIo = new OsuFileIo(new BeatmapExtension());
+ Manager = new(_fileIo.LoadedMaps);
+ }
+
+ public bool EnsureOsuDatabaseLoaded(PipelineOptions options)
+ => EnsureOsuDatabaseLoaded(options.OsuLocation, options.SkipOsu, options.PreferStable, options.PreferLazer);
+
+ private bool EnsureOsuDatabaseLoaded(string? explicitPath = default, bool skip = false, bool preferStable = false, bool preferLazer = false)
+ {
+ bool mapsAlreadyLoaded = LoadedMaps.Beatmaps.Count > 0;
+
+ if (mapsAlreadyLoaded || skip)
+ {
+ return mapsAlreadyLoaded;
+ }
+
+ string? path = ResolveOsuLocation(explicitPath, preferStable, preferLazer);
+
+ if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
+ {
+ LogOsuDatabaseFound(path);
+ _ = _fileIo.OsuDatabase.Load(path, progress: null, cancellationToken: default);
+ StableOsuDatabaseData dbData = _fileIo.OsuDatabase.StableOsuDatabaseData;
+ int beatmapCount = LoadedMaps.Beatmaps.Count;
+ int beatmapSetCount = dbData?.FolderCount ?? LoadedMaps.Beatmaps.Select(b => b.MapSetId).Distinct().Count();
+ LogOsuDatabaseLoaded(beatmapCount, beatmapSetCount);
+
+ return true;
+ }
+
+ if (explicitPath != default)
+ {
+ LogOsuDatabaseNotFound(explicitPath);
+ }
+
+ return false;
+ }
+
+ private static string? ResolveOsuLocation(string? path, bool preferStable, bool preferLazer)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ OsuPathResult osuPath = OsuPathResolver.GetOsuOrLazerPath();
+
+ if (osuPath.Type is OsuType.None)
+ {
+ return null;
+ }
+
+ OsuType selectedType = osuPath.Type;
+ if (preferStable && osuPath.StablePath != default)
+ {
+ selectedType = OsuType.Stable;
+ path = osuPath.StablePath;
+ }
+ else if (preferLazer && osuPath.LazerPath != default)
+ {
+ selectedType = OsuType.Lazer;
+ path = osuPath.LazerPath;
+ }
+ else
+ {
+ path = osuPath.Path;
+ }
+
+ return Path.Combine(path, selectedType.GetDatabaseFileName());
+ }
+
+ if (Path.HasExtension(path))
+ {
+ return path;
+ }
+
+ if (OsuPathResolver.IsOsuStableDirectory(path))
+ {
+ return Path.Combine(path, OsuType.Stable.GetDatabaseFileName());
+ }
+
+ if (OsuPathResolver.IsOsuLazerDataDirectory(path))
+ {
+ return Path.Combine(path, OsuType.Lazer.GetDatabaseFileName());
+ }
+
+ return path;
+ }
+
+ public CollectionLoadResult LoadCollectionsFromFile(string path)
+ {
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"Collection file not found: {path}");
+ }
+
+ CollectionLoadResult loaded = _fileIo.CollectionLoader.LoadCollection(path);
+
+ if (loaded.Collections.Count > 0)
+ {
+ CollectionEditArgs args = CollectionEditArgs.AddCollections(loaded.Collections);
+ Manager.EditCollection(args);
+ }
+
+ return loaded;
+ }
+
+ public void SaveCollectionsToFile(string path, LazerRealmSchemaVersion targetSchemaVersion)
+ => _fileIo.CollectionLoader.SaveCollection(Collections, path, targetSchemaVersion);
+
+ public StableOsuDatabaseData? GetStableOsuDatabaseData() => _fileIo.OsuDatabase.StableOsuDatabaseData;
+
+ public IOsuCollection? GetCollection(int? id = default, string? name = default)
+ {
+ if (id.HasValue)
+ {
+ return Manager.GetCollectionById(id.Value);
+ }
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ return Manager.GetCollectionByName(name);
+ }
+
+ return null;
+ }
+
+ public IEnumerable GetCollections(IEnumerable identifiers, Func selector)
+ {
+ HashSet identifierSet = [.. identifiers];
+ return Collections.Where(c => identifierSet.Contains(selector(c)));
+ }
+
+ public void Dispose() => _fileIo?.Dispose();
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Using osu! database found at \"{Path}\".")]
+ private partial void LogOsuDatabaseFound(string path);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "osu! database loaded successfully ({BeatmapCount} beatmaps, {BeatmapSetCount} beatmap sets).")]
+ private partial void LogOsuDatabaseLoaded(int beatmapCount, int beatmapSetCount);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Could not find osu! database at \"{Path}\".")]
+ private partial void LogOsuDatabaseNotFound(string path);
+}
diff --git a/CollectionManager.App.Cli/Pipeline/IPipelineCommand.cs b/CollectionManager.App.Cli/Pipeline/IPipelineCommand.cs
new file mode 100644
index 0000000..3142567
--- /dev/null
+++ b/CollectionManager.App.Cli/Pipeline/IPipelineCommand.cs
@@ -0,0 +1,8 @@
+namespace CollectionManager.App.Cli.Pipeline;
+
+using System.Threading.Tasks;
+
+internal interface IPipelineCommand
+{
+ Task RunAsync(CollectionContext context);
+}
diff --git a/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs b/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs
new file mode 100644
index 0000000..45a526f
--- /dev/null
+++ b/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs
@@ -0,0 +1,136 @@
+namespace CollectionManager.App.Cli.Pipeline;
+
+using CollectionManager.App.Cli.Commands;
+using CollectionManager.App.Cli.Logging;
+using CommandLine;
+using Microsoft.Extensions.Logging;
+using System.IO;
+using System.Threading.Tasks;
+///
+/// Executes pipeline command chains with shared context.
+///
+internal static partial class PipelineExecutor
+{
+ private static readonly ILogger Logger = Program.Logger;
+
+ ///
+ /// Executes a single command using a shared context.
+ ///
+ public static async Task ExecuteSingleAsync(string[] args)
+ {
+ using CollectionContext context = new();
+ return await ExecuteCommandAsync(args, context);
+ }
+
+ ///
+ /// Executes a single command using an existing context (for interactive mode).
+ ///
+ public static async Task ExecuteSingleCommandAsync(string[] args, CollectionContext context)
+ => await ExecuteCommandAsync(args, context);
+
+ ///
+ /// Executes a series of commands in sequence using a shared CollectionContext.
+ ///
+ public static async Task ExecuteAsync(List commandArgsList)
+ {
+ using CollectionContext context = new();
+
+ for (int i = 0; i < commandArgsList.Count; i++)
+ {
+ string[] args = commandArgsList[i];
+ Logger.LogPipelineStep(i + 1, commandArgsList.Count, string.Join(' ', args));
+
+ int result = await ExecuteCommandAsync(args, context);
+
+ if (result != 0)
+ {
+ Logger.LogPipelineStepFailed(i + 1, result);
+ return result;
+ }
+ }
+
+ Logger.LogPipelineCompleted();
+ return 0;
+ }
+
+ private static async Task ExecuteCommandAsync(string[] args, CollectionContext context)
+ => await Parser.Default
+ .ParseArguments<
+ ConvertCommand,
+ CreateCommand,
+ DifferenceCommand,
+ DuplicateCommand,
+ GenerateCommand,
+ InteractiveCommand,
+ IntersectCommand,
+ InverseCommand,
+ LoadCommand,
+ LoadOsuDbCommand,
+ SaveCommand,
+ ListCommand,
+ RenameCommand,
+ MergeCommand,
+ RemoveCommand,
+ PipelineHelpCommand
+ >(args)
+ .MapResult(
+ (ConvertCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (CreateCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (DifferenceCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (DuplicateCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (GenerateCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (InteractiveCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (IntersectCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (InverseCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (LoadCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (LoadOsuDbCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (SaveCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (ListCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (RenameCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (MergeCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (RemoveCommand cmd) => ExecuteCommandAsync(cmd, context),
+ (PipelineHelpCommand cmd) => ExecuteCommandAsync(cmd, context),
+ errors =>
+ // commandline already logs errors, do nothing.
+ Task.FromResult(1));
+
+ private static async Task ExecuteCommandAsync(PipelineOptions cmd, CollectionContext context)
+ {
+ using IDisposable _ = IndentationEnricher.BeginCommandScope();
+ int result = await cmd.RunAsync(context);
+
+ if (result is 0 && !string.IsNullOrWhiteSpace(cmd.OutputFile))
+ {
+ if (context.Collections.Count is 0)
+ {
+ Logger.LogNoCollectionsToSave();
+
+ return 1;
+ }
+
+ string path = GetOutputPath(cmd.OutputFile);
+ context.SaveCollectionsToFile(path, cmd.RealmVersion);
+ Logger.LogSavedToFile(path);
+ }
+
+ return result;
+ }
+
+ private static string GetOutputPath(string path)
+ => Path.HasExtension(path) ? path : $"{path}.osdb";
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Executing step {StepIndex}/{TotalSteps}: {Command}")]
+ public static partial void LogPipelineStep(this ILogger logger, int stepIndex, int totalSteps, string command);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Step {StepIndex} failed with exit code {ExitCode}")]
+ public static partial void LogPipelineStepFailed(this ILogger logger, int stepIndex, int exitCode);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "All steps completed successfully.")]
+ public static partial void LogPipelineCompleted(this ILogger logger);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Saved to {Path}")]
+ public static partial void LogSavedToFile(this ILogger logger, string path);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "No collections to save. Load, create or generate collections first.")]
+ private static partial void LogNoCollectionsToSave(this ILogger logger);
+}
diff --git a/CollectionManager.App.Cli/Pipeline/PipelineOptions.cs b/CollectionManager.App.Cli/Pipeline/PipelineOptions.cs
new file mode 100644
index 0000000..266c73e
--- /dev/null
+++ b/CollectionManager.App.Cli/Pipeline/PipelineOptions.cs
@@ -0,0 +1,30 @@
+namespace CollectionManager.App.Cli.Pipeline;
+
+using CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+using CommandLine;
+
+///
+/// Common options for all pipeline commands.
+///
+internal abstract class PipelineOptions : IPipelineCommand
+{
+ [Option('o', "output", HelpText = "Output file. If provided, collections are saved after this command.")]
+ public virtual string? OutputFile { get; init; }
+
+ [Option("realm-version", Required = false, HelpText = "osu!lazer realm schema version for newly created .realm files (LastLoaded, Latest, V51, V52). Existing files keep their version. Default: LastLoaded.")]
+ public LazerRealmSchemaVersion RealmVersion { get; init; } = LazerRealmSchemaVersion.LastLoaded;
+
+ [Option('l', "osu-location", HelpText = "Location of osu! directory or osu!.db/client.realm. Auto-detected if not provided.")]
+ public string? OsuLocation { get; init; }
+
+ [Option('s', "skip-osu", HelpText = "Skip loading osu! database.")]
+ public bool SkipOsu { get; init; }
+
+ [Option("stable", HelpText = "Prefer osu! Stable during install auto-detecting.")]
+ public bool PreferStable { get; init; }
+
+ [Option("lazer", HelpText = "Prefer osu! Lazer during install auto-detecting.")]
+ public bool PreferLazer { get; init; }
+
+ public abstract Task RunAsync(CollectionContext context);
+}
diff --git a/CollectionManager.App.Cli/Pipeline/PipelineParser.cs b/CollectionManager.App.Cli/Pipeline/PipelineParser.cs
new file mode 100644
index 0000000..05239ea
--- /dev/null
+++ b/CollectionManager.App.Cli/Pipeline/PipelineParser.cs
@@ -0,0 +1,89 @@
+namespace CollectionManager.App.Cli.Pipeline;
+
+using System.Collections.Generic;
+
+public static class PipelineParser
+{
+ private const string ThenDelimiter = "--then";
+
+ ///
+ /// Groups arguments into a list of command segments.
+ /// Each segment is an array of arguments for a single command.
+ ///
+ public static List GroupArgs(string[] args)
+ {
+ List result = [];
+ List current = [];
+
+ foreach (string arg in args)
+ {
+ if (arg == ThenDelimiter)
+ {
+ if (current.Count > 0)
+ {
+ result.Add([.. current]);
+ current = [];
+ }
+ }
+ else
+ {
+ current.Add(arg);
+ }
+ }
+
+ if (current.Count > 0)
+ {
+ result.Add([.. current]);
+ }
+
+ return result;
+ }
+
+ public static string[] ParseLine(string line)
+ {
+ List args = [];
+ char? currentQuoteChar = null;
+ string currentArg = "";
+
+ foreach (char c in line)
+ {
+ if (c is '"' or '\'')
+ {
+ if (currentQuoteChar == null)
+ {
+ // opening quote
+ currentQuoteChar = c;
+ }
+ else if (currentQuoteChar == c)
+ {
+ // closing quote of the same type
+ currentQuoteChar = null;
+ }
+ else
+ {
+ // ignore different quote chars
+ currentArg += c;
+ }
+ }
+ else if (c == ' ' && currentQuoteChar == null)
+ {
+ if (currentArg.Length > 0)
+ {
+ args.Add(currentArg);
+ currentArg = "";
+ }
+ }
+ else
+ {
+ currentArg += c;
+ }
+ }
+
+ if (currentArg.Length > 0)
+ {
+ args.Add(currentArg);
+ }
+
+ return [.. args];
+ }
+}
diff --git a/CollectionManager.App.Cli/Program.cs b/CollectionManager.App.Cli/Program.cs
index 89f6f9b..29373f3 100644
--- a/CollectionManager.App.Cli/Program.cs
+++ b/CollectionManager.App.Cli/Program.cs
@@ -1,19 +1,45 @@
namespace CollectionManager.App.Cli;
-using CollectionManager.App.Cli.Convert;
-using CollectionManager.App.Cli.Create;
-using CollectionManager.App.Cli.Generate;
-using CommandLine;
+using CollectionManager.App.Cli.Logging;
+using CollectionManager.App.Cli.Pipeline;
+using Microsoft.Extensions.Logging;
+using Serilog;
+using System.Globalization;
using System.Threading.Tasks;
+using ILogger = Microsoft.Extensions.Logging.ILogger;
internal static class Program
{
+ internal static ILogger Logger { get; private set; } = default!;
+
private static async Task Main(string[] args)
- => await Parser.Default.ParseArguments(args)
- .MapResult(
- (ConvertCommand cmd) => cmd.RunAsync(),
- (CreateCommand cmd) => cmd.RunAsync(),
- (GenerateCommand cmd) => cmd.RunAsync(),
- _ => Task.FromResult(1)
- );
+ {
+ if (args.Length == 0)
+ {
+ args = ["--help"];
+ }
+
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .Enrich.With()
+ .WriteTo.Console(
+ outputTemplate: $$"""{{{IndentationEnricher.IndentationProperty}}}{Message:lj}{NewLine}""",
+ formatProvider: CultureInfo.InvariantCulture)
+ .CreateLogger();
+
+ using ILoggerFactory loggerFactory = LoggerFactory
+ .Create(builder => builder.AddSerilog(Log.Logger, dispose: false));
+
+ Logger = loggerFactory.CreateLogger("CollectionManager.App.Cli");
+
+ try
+ {
+ List commands = PipelineParser.GroupArgs(args);
+ return await PipelineExecutor.ExecuteAsync(commands);
+ }
+ finally
+ {
+ await Log.CloseAndFlushAsync();
+ }
+ }
}
diff --git a/CollectionManager.App.Cli/Properties/launchSettings.json b/CollectionManager.App.Cli/Properties/launchSettings.json
new file mode 100644
index 0000000..085db5a
--- /dev/null
+++ b/CollectionManager.App.Cli/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "CollectionManager.App.Cli": {
+ "commandName": "Project",
+ "commandLineArgs": "interactive"
+ }
+ }
+}
\ No newline at end of file
diff --git a/CollectionManager.App.Shared/Initalizer.cs b/CollectionManager.App.Shared/Initalizer.cs
index 2ad820b..90aa28b 100644
--- a/CollectionManager.App.Shared/Initalizer.cs
+++ b/CollectionManager.App.Shared/Initalizer.cs
@@ -66,7 +66,7 @@ public virtual async Task RunGui(string[] args)
{
if (File.Exists(args[0]))
{
- CollectionsManager.EditCollection(CollectionEditArgs.AddCollections(OsuFileIo.CollectionLoader.LoadCollection(args[0])));
+ CollectionsManager.EditCollection(CollectionEditArgs.AddCollections(OsuFileIo.CollectionLoader.LoadCollection(args[0]).Collections));
}
}
diff --git a/CollectionManager.App.Shared/Misc/SidePanelActions/LoadCollectionHandler.cs b/CollectionManager.App.Shared/Misc/SidePanelActions/LoadCollectionHandler.cs
index 7f26675..c168531 100644
--- a/CollectionManager.App.Shared/Misc/SidePanelActions/LoadCollectionHandler.cs
+++ b/CollectionManager.App.Shared/Misc/SidePanelActions/LoadCollectionHandler.cs
@@ -32,7 +32,7 @@ public async Task HandleAsync(object sender, object data)
try
{
collections = data is string fileLocation
- ? _osuFileIo.CollectionLoader.LoadCollection(fileLocation)
+ ? _osuFileIo.CollectionLoader.LoadCollection(fileLocation).Collections
: await _osuFileIo.CollectionLoader.LoadCollectionFileAsync(_userDialogs);
}
catch (CorruptedFileException ex)
diff --git a/CollectionManager.App.Shared/Misc/SidePanelActions/SidePanelActionHelpers.cs b/CollectionManager.App.Shared/Misc/SidePanelActions/SidePanelActionHelpers.cs
index 73fbd5d..e09e363 100644
--- a/CollectionManager.App.Shared/Misc/SidePanelActions/SidePanelActionHelpers.cs
+++ b/CollectionManager.App.Shared/Misc/SidePanelActions/SidePanelActionHelpers.cs
@@ -25,7 +25,7 @@ public static async Task LoadCollectionsAsync(OsuFileIo osuFileIo, ICollectionEd
{
try
{
- collections.AddRange(osuFileIo.CollectionLoader.LoadCollection(fileLocation));
+ collections.AddRange(osuFileIo.CollectionLoader.LoadCollection(fileLocation).Collections);
}
catch (CorruptedFileException ex)
{
diff --git a/CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj b/CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj
index 2e4d4b0..0421418 100644
--- a/CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj
+++ b/CollectionManager.App.WinForms/CollectionManager.App.WinForms.csproj
@@ -22,7 +22,6 @@
-
diff --git a/CollectionManager.Core.Tests/CollectionManager.Core.Tests.csproj b/CollectionManager.Core.Tests/CollectionManager.Core.Tests.csproj
index 05dce2f..31bc5e5 100644
--- a/CollectionManager.Core.Tests/CollectionManager.Core.Tests.csproj
+++ b/CollectionManager.Core.Tests/CollectionManager.Core.Tests.csproj
@@ -15,6 +15,7 @@
+
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/CollectionManager.Core.Tests/Extensions/StringExtensionsTests.cs b/CollectionManager.Core.Tests/Extensions/StringExtensionsTests.cs
index 8a1e818..6811411 100644
--- a/CollectionManager.Core.Tests/Extensions/StringExtensionsTests.cs
+++ b/CollectionManager.Core.Tests/Extensions/StringExtensionsTests.cs
@@ -1,7 +1,7 @@
namespace CollectionManager.Core.Tests.Extensions;
using CollectionManager.Core.Extensions;
-using FluentAssertions;
+using AwesomeAssertions;
using Xunit;
public class StringExtensionsTests
diff --git a/CollectionManager.Core.Tests/FodyWeavers.xml b/CollectionManager.Core.Tests/FodyWeavers.xml
new file mode 100644
index 0000000..cc07b89
--- /dev/null
+++ b/CollectionManager.Core.Tests/FodyWeavers.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/CollectionManager.Core.Tests/FodyWeavers.xsd b/CollectionManager.Core.Tests/FodyWeavers.xsd
new file mode 100644
index 0000000..f526bdd
--- /dev/null
+++ b/CollectionManager.Core.Tests/FodyWeavers.xsd
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.
+
+
+
+
+ A comma-separated list of error codes that can be safely ignored in assembly verification.
+
+
+
+
+ 'false' to turn off automatic generation of the XML Schema file.
+
+
+
+
+
\ No newline at end of file
diff --git a/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/CollectionNameExistsTests.cs b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/CollectionNameExistsTests.cs
new file mode 100644
index 0000000..fcc320e
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/CollectionNameExistsTests.cs
@@ -0,0 +1,64 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.CollectionsManager;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class CollectionNameExistsTests
+{
+ private readonly MapCacher _maps = new();
+
+ private CollectionsManagerWithCounts ManagerWith(params string[] names)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach (string name in names)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name });
+ }
+
+ return manager;
+ }
+
+ [Fact]
+ public void WhenNameExistsThenReturnsTrue()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Favorites");
+
+ _ = manager.CollectionNameExists("Favorites").Should().BeTrue();
+ }
+
+ [Fact]
+ public void WhenNameDoesNotExistThenReturnsFalse()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Favorites");
+
+ _ = manager.CollectionNameExists("Pending").Should().BeFalse();
+ }
+
+ [Fact]
+ public void WhenNoCollectionsLoadedThenReturnsFalse()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.CollectionNameExists("Anything").Should().BeFalse();
+ }
+
+ [Fact]
+ public void WhenNameIsSubstringOfExistingThenReturnsFalse()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Keeper");
+
+ _ = manager.CollectionNameExists("Keep").Should().BeFalse();
+ }
+
+ [Fact]
+ public void WhenNameDiffersOnlyByCaseThenReturnsFalse()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Favorites");
+
+ _ = manager.CollectionNameExists("favorites").Should().BeFalse();
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionByIdTests.cs b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionByIdTests.cs
new file mode 100644
index 0000000..5e0a97e
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionByIdTests.cs
@@ -0,0 +1,60 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.CollectionsManager;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class GetCollectionByIdTests
+{
+ private readonly MapCacher _maps = new();
+
+ private CollectionsManagerWithCounts ManagerWith(params (string Name, int Id)[] entries)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach ((string name, int collectionId) in entries)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name, Id = collectionId });
+ }
+
+ return manager;
+ }
+
+ [Fact]
+ public void WhenIdMatchesThenReturnsThatCollection()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith(("Favorites", 5));
+
+ IOsuCollection result = manager.GetCollectionById(5);
+
+ _ = result.Should().NotBeNull();
+ _ = result.Name.Should().Be("Favorites");
+ _ = result.Id.Should().Be(5);
+ }
+
+ [Fact]
+ public void WhenIdDoesNotMatchThenReturnsNull()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith(("Favorites", 5));
+
+ _ = manager.GetCollectionById(99).Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenNoCollectionsLoadedThenReturnsNull()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.GetCollectionById(0).Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenMultipleCollectionsShareIdThenReturnsFirstRegistered()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith(("First", 7), ("Second", 7));
+
+ _ = manager.GetCollectionById(7).Should().BeSameAs(manager.LoadedCollections[0]);
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionByNameTests.cs b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionByNameTests.cs
new file mode 100644
index 0000000..cb6134f
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionByNameTests.cs
@@ -0,0 +1,70 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.CollectionsManager;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class GetCollectionByNameTests
+{
+ private readonly MapCacher _maps = new();
+
+ private CollectionsManagerWithCounts ManagerWith(params string[] names)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach (string name in names)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name });
+ }
+
+ return manager;
+ }
+
+ [Fact]
+ public void WhenNameMatchesExactlyThenReturnsThatCollection()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Favorites");
+
+ IOsuCollection result = manager.GetCollectionByName("Favorites");
+
+ _ = result.Should().NotBeNull();
+ _ = result.Name.Should().Be("Favorites");
+ }
+
+ [Fact]
+ public void WhenNameDoesNotMatchThenReturnsNull()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Favorites");
+
+ _ = manager.GetCollectionByName("Pending").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenNoCollectionsLoadedThenReturnsNull()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.GetCollectionByName("Anything").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenNameIsSubstringOfAnotherThenReturnsExactMatchOnly()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Keep", "Keeper");
+
+ IOsuCollection result = manager.GetCollectionByName("Keep");
+
+ _ = result.Should().NotBeNull();
+ _ = result.Name.Should().Be("Keep");
+ }
+
+ [Fact]
+ public void WhenMultipleCollectionsShareNameThenReturnsFirstRegistered()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Dup", "Dup");
+
+ _ = manager.GetCollectionByName("Dup").Should().BeSameAs(manager.LoadedCollections[0]);
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionsByIdTests.cs b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionsByIdTests.cs
new file mode 100644
index 0000000..0e48f01
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionsByIdTests.cs
@@ -0,0 +1,69 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.CollectionsManager;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using System.Collections.Generic;
+using Xunit;
+
+public sealed class GetCollectionsByIdTests
+{
+ private readonly MapCacher _maps = new();
+
+ private CollectionsManagerWithCounts ManagerWith(params (string Name, int Id)[] entries)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach ((string name, int collectionId) in entries)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name, Id = collectionId });
+ }
+
+ return manager;
+ }
+
+ [Fact]
+ public void WhenAllIdsMatchThenReturnsCollectionsInRequestedOrder()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith(("A", 0), ("B", 1), ("C", 2));
+
+ List result = manager.GetCollectionsById([2, 0, 1]);
+
+ _ = result.Should().HaveCount(3);
+ _ = result[0].Name.Should().Be("C");
+ _ = result[1].Name.Should().Be("A");
+ _ = result[2].Name.Should().Be("B");
+ }
+
+ [Fact]
+ public void WhenSomeIdsAreMissingThenNullEntriesPreservePosition()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith(("A", 0));
+
+ List result = manager.GetCollectionsById([0, 99]);
+
+ _ = result.Should().HaveCount(2);
+ _ = result[0].Name.Should().Be("A");
+ _ = result[1].Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenIdListIsEmptyThenReturnsEmptyList()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.GetCollectionsById([]).Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenNoCollectionsLoadedThenEveryEntryIsNull()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ List result = manager.GetCollectionsById([1, 2]);
+
+ _ = result.Should().HaveCount(2);
+ _ = result.Should().OnlyContain(collection => collection == null);
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetValidCollectionNameTests.cs b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetValidCollectionNameTests.cs
new file mode 100644
index 0000000..2c0a081
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetValidCollectionNameTests.cs
@@ -0,0 +1,80 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.CollectionsManager;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class GetValidCollectionNameTests
+{
+ private readonly MapCacher _maps = new();
+
+ private CollectionsManagerWithCounts ManagerWith(params string[] names)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach (string name in names)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name });
+ }
+
+ return manager;
+ }
+
+ [Fact]
+ public void WhenNameIsUniqueThenReturnedAsIs()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.GetValidCollectionName("Fresh").Should().Be("Fresh");
+ }
+
+ [Fact]
+ public void WhenNameCollidesWithLoadedCollectionThenAppendsZeroSuffix()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Dup");
+
+ _ = manager.GetValidCollectionName("Dup").Should().Be("Dup_0");
+ }
+
+ [Fact]
+ public void WhenSuffixedNamesAlreadyExistThenIncrementsUntilUnique()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Dup", "Dup_0");
+
+ _ = manager.GetValidCollectionName("Dup").Should().Be("Dup_1");
+ }
+
+ [Fact]
+ public void WhenNameIsInReservedNamesThenAppendsZeroSuffix()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.GetValidCollectionName("Fresh", ["Fresh"]).Should().Be("Fresh_0");
+ }
+
+ [Fact]
+ public void WhenSuffixedCandidateIsReservedThenContinuesIncrementing()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.GetValidCollectionName("Fresh", ["Fresh", "Fresh_0"]).Should().Be("Fresh_1");
+ }
+
+ [Fact]
+ public void WhenReservedNamesIsNullThenTreatedAsEmpty()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.GetValidCollectionName("Fresh", null).Should().Be("Fresh");
+ }
+
+ [Fact]
+ public void WhenDesiredNameIsEmptyThenLoopProducesSuffixedCandidate()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.GetValidCollectionName("").Should().Be("_0");
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/IsCollectionNameValidTests.cs b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/IsCollectionNameValidTests.cs
new file mode 100644
index 0000000..89bdb59
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/IsCollectionNameValidTests.cs
@@ -0,0 +1,64 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.CollectionsManager;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class IsCollectionNameValidTests
+{
+ private readonly MapCacher _maps = new();
+
+ private CollectionsManagerWithCounts ManagerWith(params string[] names)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach (string name in names)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name });
+ }
+
+ return manager;
+ }
+
+ [Fact]
+ public void WhenNameIsUniqueAndNonEmptyThenReturnsTrue()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.IsCollectionNameValid("Fresh").Should().BeTrue();
+ }
+
+ [Fact]
+ public void WhenNameIsEmptyThenReturnsFalse()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.IsCollectionNameValid("").Should().BeFalse();
+ }
+
+ [Fact]
+ public void WhenNameIsNullThenReturnsFalse()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ _ = manager.IsCollectionNameValid(null).Should().BeFalse();
+ }
+
+ [Fact]
+ public void WhenNameAlreadyExistsThenReturnsFalse()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Favorites");
+
+ _ = manager.IsCollectionNameValid("Favorites").Should().BeFalse();
+ }
+
+ [Fact]
+ public void WhenNameDiffersOnlyByCaseFromExistingThenReturnsTrue()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Favorites");
+
+ _ = manager.IsCollectionNameValid("favorites").Should().BeTrue();
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddBeatmapsStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddBeatmapsStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..8765f20
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddBeatmapsStrategy/ExecuteTests.cs
@@ -0,0 +1,120 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.AddBeatmapsStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5, int mapId) => new() { Md5 = md5, MapId = mapId };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ [Fact]
+ public void WhenAddingBeatmapToExistingCollectionThenBeatmapIsAdded()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "My");
+ AddBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddBeatmaps("My", [BeatmapWith("h1", 1)]));
+
+ _ = collection.NumberOfBeatmaps.Should().Be(1);
+ _ = collection.BeatmapHashes.Should().ContainSingle().Which.Should().Be("h1");
+ }
+
+ [Fact]
+ public void WhenAddingMultipleBeatmapsThenAllAreAdded()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "My");
+ AddBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddBeatmaps("My", [BeatmapWith("h1", 1), BeatmapWith("h2", 2)]));
+
+ _ = collection.NumberOfBeatmaps.Should().Be(2);
+ _ = collection.BeatmapHashes.Should().BeEquivalentTo(["h1", "h2"]);
+ }
+
+ [Fact]
+ public void WhenAppendingToCollectionWithExistingBeatmapsThenAppendsPreservingExisting()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "My");
+ collection.AddBeatmapByHash("existing");
+ AddBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddBeatmaps("My", [BeatmapWith("h1", 1)]));
+
+ _ = collection.NumberOfBeatmaps.Should().Be(2);
+ _ = collection.BeatmapHashes.Should().BeEquivalentTo(["existing", "h1"]);
+ }
+
+ [Fact]
+ public void WhenAddingBeatmapAlreadyPresentByHashThenStaysUnique()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "My");
+ AddBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddBeatmaps("My", [BeatmapWith("h1", 1)]));
+ strategy.Execute(manager, CollectionEditArgs.AddBeatmaps("My", [BeatmapWith("h1", 1)]));
+
+ _ = collection.NumberOfBeatmaps.Should().Be(1);
+ _ = collection.BeatmapHashes.Should().ContainSingle().Which.Should().Be("h1");
+ }
+
+ [Fact]
+ public void WhenAddingToMissingCollectionThenIsNoOp()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection keep = Register(manager, "Keep");
+ AddBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddBeatmaps("Missing", [BeatmapWith("h1", 1)]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle();
+ _ = manager.GetCollectionByName("Missing").Should().BeNull();
+ _ = keep.NumberOfBeatmaps.Should().Be(0);
+ }
+
+ [Fact]
+ public void WhenAddingEmptyBeatmapListThenIsNoOp()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "My");
+ collection.AddBeatmapByHash("existing");
+ AddBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddBeatmaps("My", []));
+
+ _ = collection.NumberOfBeatmaps.Should().Be(1);
+ _ = collection.BeatmapHashes.Should().ContainSingle().Which.Should().Be("existing");
+ }
+
+ [Fact]
+ public void WhenAddingMd5VersionForExistingMapIdOnlyBeatmapThenKeepsBothEntries()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "Favorites");
+ collection.AddBeatmapByMapId(15);
+
+ AddBeatmapsStrategy strategy = new();
+ strategy.Execute(manager, CollectionEditArgs.AddBeatmaps("Favorites", [new BeatmapExtension { Md5 = "realhash", MapId = 15 }]));
+
+ _ = collection.AllBeatmaps().Should().HaveCount(2);
+ _ = collection.AllBeatmaps().Select(beatmap => beatmap.MapId).Should().OnlyContain(mapId => mapId == 15);
+ _ = collection.AllBeatmaps().Select(beatmap => beatmap.Md5)
+ .Should().BeEquivalentTo(["manually-added|15|0", "realhash"]);
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddOrMergeIfExistsStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddOrMergeIfExistsStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..68d98fd
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddOrMergeIfExistsStrategy/ExecuteTests.cs
@@ -0,0 +1,166 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.AddOrMergeIfExistsStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5, int mapId) => new() { Md5 = md5, MapId = mapId };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ [Fact]
+ public void WhenNameIsNewThenCollectionIsAddedWithItsBeatmaps()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ AddOrMergeIfExistsStrategy strategy = new();
+
+ OsuCollection input = new(_maps) { Name = "BrandNew" };
+ input.AddBeatmap(BeatmapWith("hash1", 11));
+ input.AddBeatmap(BeatmapWith("hash2", 12));
+
+ strategy.Execute(manager, CollectionEditArgs.AddOrMergeCollections([input]));
+
+ IOsuCollection added = manager.GetCollectionByName("BrandNew");
+ _ = added.Should().NotBeNull();
+ _ = added.Id.Should().Be(0);
+ _ = added.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["hash1", "hash2"]);
+ }
+
+ [Fact]
+ public void WhenNameAlreadyExistsThenMergesBeatmapsIntoExistingMaster()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection master = Register(manager, "Existing");
+ master.AddBeatmap(BeatmapWith("masterHash", 11));
+ AddOrMergeIfExistsStrategy strategy = new();
+
+ OsuCollection input = new(_maps) { Name = "Existing" };
+ input.AddBeatmap(BeatmapWith("addedHash", 12));
+
+ strategy.Execute(manager, CollectionEditArgs.AddOrMergeCollections([input]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle()
+ .Which.Name.Should().Be("Existing");
+ _ = master.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["masterHash", "addedHash"]);
+ }
+
+ [Fact]
+ public void WhenBatchMixesNewAndExistingNamesThenAddsNewAndMergesExisting()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection master = Register(manager, "Existing");
+ master.AddBeatmap(BeatmapWith("masterHash", 11));
+ AddOrMergeIfExistsStrategy strategy = new();
+
+ OsuCollection existingInput = new(_maps) { Name = "Existing" };
+ existingInput.AddBeatmap(BeatmapWith("mergedHash", 12));
+ OsuCollection newInput = new(_maps) { Name = "Fresh" };
+ newInput.AddBeatmap(BeatmapWith("freshHash", 13));
+
+ strategy.Execute(manager, CollectionEditArgs.AddOrMergeCollections([existingInput, newInput]));
+
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ IOsuCollection existing = manager.GetCollectionByName("Existing");
+ IOsuCollection fresh = manager.GetCollectionByName("Fresh");
+ _ = existing.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["masterHash", "mergedHash"]);
+ _ = fresh.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["freshHash"]);
+ }
+
+ [Fact]
+ public void WhenInputSharesBeatmapHashWithMasterThenKeepsSingleCopy()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection master = Register(manager, "Existing");
+ master.AddBeatmap(BeatmapWith("sharedHash", 11));
+ AddOrMergeIfExistsStrategy strategy = new();
+
+ OsuCollection input = new(_maps) { Name = "Existing" };
+ input.AddBeatmap(BeatmapWith("sharedHash", 11));
+ input.AddBeatmap(BeatmapWith("extraHash", 12));
+
+ strategy.Execute(manager, CollectionEditArgs.AddOrMergeCollections([input]));
+
+ _ = master.AllBeatmaps().Should().HaveCount(2);
+ _ = master.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["sharedHash", "extraHash"]);
+ }
+
+ [Fact]
+ public void WhenTwoInputsShareSameNewNameThenSecondMergesIntoFirst()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ AddOrMergeIfExistsStrategy strategy = new();
+
+ OsuCollection first = new(_maps) { Name = "Shared" };
+ first.AddBeatmap(BeatmapWith("firstHash", 11));
+ OsuCollection second = new(_maps) { Name = "Shared" };
+ second.AddBeatmap(BeatmapWith("secondHash", 12));
+
+ strategy.Execute(manager, CollectionEditArgs.AddOrMergeCollections([first, second]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle()
+ .Which.Name.Should().Be("Shared");
+ _ = manager.GetCollectionByName("Shared_0").Should().BeNull();
+ _ = manager.GetCollectionByName("Shared").AllBeatmaps().Select(beatmap => beatmap.Md5)
+ .Should().BeEquivalentTo(["firstHash", "secondHash"]);
+ }
+
+ [Fact]
+ public void WhenInputListIsEmptyThenLeavesLoadedCollectionsUnchanged()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection master = Register(manager, "Existing");
+ master.AddBeatmap(BeatmapWith("keepHash", 11));
+ AddOrMergeIfExistsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddOrMergeCollections([]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle()
+ .Which.Name.Should().Be("Existing");
+ _ = master.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["keepHash"]);
+ }
+
+ [Fact]
+ public void WhenMergingInputWithNoBeatmapsThenExistingMasterIsUnchanged()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection master = Register(manager, "Existing");
+ master.AddBeatmap(BeatmapWith("keepHash", 11));
+ AddOrMergeIfExistsStrategy strategy = new();
+
+ OsuCollection input = new(_maps) { Name = "Existing" };
+
+ strategy.Execute(manager, CollectionEditArgs.AddOrMergeCollections([input]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle();
+ _ = master.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["keepHash"]);
+ }
+
+ [Fact]
+ public void WhenInputNameIsSubstringOfExistingThenTreatedAsNewCollection()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _ = Register(manager, "Existing");
+ AddOrMergeIfExistsStrategy strategy = new();
+
+ OsuCollection input = new(_maps) { Name = "Exist" };
+ input.AddBeatmap(BeatmapWith("hash", 11));
+
+ strategy.Execute(manager, CollectionEditArgs.AddOrMergeCollections([input]));
+
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ _ = manager.GetCollectionByName("Exist").Should().NotBeNull();
+ _ = manager.GetCollectionByName("Existing").Should().NotBeNull();
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..260cc1b
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/AddStrategy/ExecuteTests.cs
@@ -0,0 +1,87 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.AddStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ [Fact]
+ public void WhenAddingCollectionsThenAssignsSequentialIdsAndPreservesUniqueNames()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ AddStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddCollections([
+ new OsuCollection(_maps) { Name = "First" },
+ new OsuCollection(_maps) { Name = "Second" }
+ ]));
+
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ _ = manager.LoadedCollections[0].Name.Should().Be("First");
+ _ = manager.LoadedCollections[0].Id.Should().Be(0);
+ _ = manager.LoadedCollections[1].Name.Should().Be("Second");
+ _ = manager.LoadedCollections[1].Id.Should().Be(1);
+ }
+
+ [Fact]
+ public void WhenAddingDuplicateNameAcrossBatchesThenSecondGetsDisambiguated()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ AddStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddCollections([new OsuCollection(_maps) { Name = "Dup" }]));
+ strategy.Execute(manager, CollectionEditArgs.AddCollections([new OsuCollection(_maps) { Name = "Dup" }]));
+
+ _ = manager.GetCollectionByName("Dup").Should().NotBeNull();
+ _ = manager.GetCollectionByName("Dup_0").Should().NotBeNull();
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void WhenAddingDuplicateNamesWithinSingleBatchThenEachGetsIncrementingSuffix()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ AddStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddCollections([
+ new OsuCollection(_maps) { Name = "Dup" },
+ new OsuCollection(_maps) { Name = "Dup" },
+ new OsuCollection(_maps) { Name = "Dup" }
+ ]));
+
+ _ = manager.GetCollectionByName("Dup").Should().NotBeNull();
+ _ = manager.GetCollectionByName("Dup_0").Should().NotBeNull();
+ _ = manager.GetCollectionByName("Dup_1").Should().NotBeNull();
+ _ = manager.LoadedCollections.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public void WhenInvokedMultipleTimesThenIdsContinueIncrementingAcrossExecutes()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ AddStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddCollections([new OsuCollection(_maps) { Name = "First" }]));
+ strategy.Execute(manager, CollectionEditArgs.AddCollections([new OsuCollection(_maps) { Name = "Second" }]));
+
+ _ = manager.LoadedCollections[0].Id.Should().Be(0);
+ _ = manager.LoadedCollections[1].Id.Should().Be(1);
+ }
+
+ [Fact]
+ public void WhenAddingNoCollectionsThenLeavesLoadedCollectionsUnchanged()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ AddStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.AddCollections([]));
+
+ _ = manager.LoadedCollections.Should().BeEmpty();
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/ClearStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/ClearStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..5f58dde
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/ClearStrategy/ExecuteTests.cs
@@ -0,0 +1,89 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.ClearStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private CollectionsManagerWithCounts ManagerWith(params string[] names)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach (string name in names)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name });
+ }
+
+ return manager;
+ }
+
+ [Fact]
+ public void WhenClearingThenRemovesAllLoadedCollections()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("First", "Second", "Third");
+ ClearStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.ClearCollections());
+
+ _ = manager.LoadedCollections.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenClearingThenAllPreviouslyReachableCollectionsBecomeUnreachable()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Keep", "Drop");
+ ClearStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.ClearCollections());
+
+ _ = manager.LoadedCollections.Should().BeEmpty();
+ _ = manager.GetCollectionByName("Keep").Should().BeNull();
+ _ = manager.GetCollectionByName("Drop").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenClearingCollectionsHoldingBeatmapsThenAllCollectionsAreRemoved()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = new(_maps) { Name = "Filled" };
+ collection.AddBeatmap(new BeatmapExtension { Md5 = "hash1", MapId = 1 });
+ collection.AddBeatmap(new BeatmapExtension { Md5 = "hash2", MapId = 2 });
+ manager.LoadedCollections.Add(collection);
+ ClearStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.ClearCollections());
+
+ _ = manager.LoadedCollections.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenNoCollectionsAreLoadedThenClearingDoesNotThrow()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ ClearStrategy strategy = new();
+
+ Action act = () => strategy.Execute(manager, CollectionEditArgs.ClearCollections());
+
+ _ = act.Should().NotThrow();
+ _ = manager.LoadedCollections.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenClearingTwiceThenSecondClearDoesNotThrow()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Only");
+ ClearStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.ClearCollections());
+ Action act = () => strategy.Execute(manager, CollectionEditArgs.ClearCollections());
+
+ _ = act.Should().NotThrow();
+ _ = manager.LoadedCollections.Should().BeEmpty();
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/DifferenceStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/DifferenceStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..b557d32
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/DifferenceStrategy/ExecuteTests.cs
@@ -0,0 +1,170 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.DifferenceStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5, int mapId) => new() { Md5 = md5, MapId = mapId };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ private IOsuCollection RunDifference(CollectionsManagerWithCounts manager, IReadOnlyList names)
+ {
+ new DifferenceStrategy(_maps).Execute(manager, CollectionEditArgs.DifferenceCollections(names, "Diff"));
+ return manager.GetCollectionByName("Diff");
+ }
+
+ [Fact]
+ public void WhenDifferencingTwoOverlappingCollectionsThenExcludesBeatmapsPresentInBoth()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("shared", 1));
+ firstCollection.AddBeatmap(BeatmapWith("onlyA", 2));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("shared", 1));
+ secondCollection.AddBeatmap(BeatmapWith("onlyB", 3));
+
+ IOsuCollection result = RunDifference(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["onlyA", "onlyB"]);
+ }
+
+ [Fact]
+ public void WhenDifferencingThreeCollectionsThenKeepsBeatmapsInExactlyOneCollection()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("shared", 1));
+ firstCollection.AddBeatmap(BeatmapWith("onlyA", 2));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("shared", 1));
+ secondCollection.AddBeatmap(BeatmapWith("onlyB", 3));
+ OsuCollection thirdCollection = Register(manager, "C");
+ thirdCollection.AddBeatmap(BeatmapWith("shared", 1));
+ thirdCollection.AddBeatmap(BeatmapWith("onlyC", 4));
+
+ IOsuCollection result = RunDifference(manager, ["A", "B", "C"]);
+
+ _ = result.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["onlyA", "onlyB", "onlyC"]);
+ }
+
+ [Fact]
+ public void WhenCollectionsShareNoBeatmapsThenResultKeepsAllBeatmaps()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("onlyA", 1));
+ firstCollection.AddBeatmap(BeatmapWith("alsoA", 2));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("onlyB", 3));
+ secondCollection.AddBeatmap(BeatmapWith("alsoB", 4));
+
+ IOsuCollection result = RunDifference(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["onlyA", "alsoA", "onlyB", "alsoB"]);
+ }
+
+ [Fact]
+ public void WhenCollectionsFullyOverlapThenResultIsEmpty()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("shared", 1));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("shared", 1));
+
+ IOsuCollection result = RunDifference(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenDifferencingSingleCollectionThenKeepsAllOfItsBeatmaps()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("first", 1));
+ firstCollection.AddBeatmap(BeatmapWith("second", 2));
+
+ IOsuCollection result = RunDifference(manager, ["A"]);
+
+ _ = result.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["first", "second"]);
+ }
+
+ [Fact]
+ public void WhenAllCollectionsAreEmptyThenResultIsEmpty()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _ = Register(manager, "A");
+ _ = Register(manager, "B");
+
+ IOsuCollection result = RunDifference(manager, ["A", "B"]);
+
+ _ = result.Should().NotBeNull();
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenSameMapAppearsViaDifferentRepresentationsThenItIsTreatedAsShared()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmapByMapId(15);
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(new BeatmapExtension { Md5 = "realhash", MapId = 15 });
+
+ IOsuCollection result = RunDifference(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenDifferencingThenPreservesSourceCollectionsAndAddsResult()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("onlyA", 1));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("onlyB", 2));
+
+ IOsuCollection result = RunDifference(manager, ["A", "B"]);
+
+ _ = result.Should().NotBeNull();
+ _ = manager.LoadedCollections.Should().HaveCount(3);
+ _ = manager.GetCollectionByName("A").Should().NotBeNull();
+ _ = manager.GetCollectionByName("B").Should().NotBeNull();
+ _ = manager.GetCollectionByName("Diff").Should().NotBeNull();
+ _ = manager.GetCollectionByName("A").AllBeatmaps().Should().ContainSingle()
+ .Which.Md5.Should().Be("onlyA");
+ _ = manager.GetCollectionByName("B").AllBeatmaps().Should().ContainSingle()
+ .Which.Md5.Should().Be("onlyB");
+ }
+
+ [Fact]
+ public void WhenBeatmapIsDuplicatedWithinASingleCollectionThenItIsKeptOnce()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("dup", 1));
+ firstCollection.AddBeatmap(BeatmapWith("dup", 1));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("onlyB", 2));
+
+ IOsuCollection result = RunDifference(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Select(beatmap => beatmap.Md5).Should().BeEquivalentTo(["dup", "onlyB"]);
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/DuplicateStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/DuplicateStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..13b74f8
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/DuplicateStrategy/ExecuteTests.cs
@@ -0,0 +1,127 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.DuplicateStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5, int mapId) => new() { Md5 = md5, MapId = mapId };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ private IOsuCollection Duplicate(CollectionsManagerWithCounts manager, string source, string newName)
+ {
+ new DuplicateStrategy(_maps).Execute(manager, CollectionEditArgs.DuplicateCollection(source, newName));
+ return manager.LoadedCollections[^1];
+ }
+
+ [Fact]
+ public void WhenDuplicatingCollectionThenCopiesAllBeatmapsToNewNamedCollection()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("a", 1));
+ source.AddBeatmap(BeatmapWith("b", 2));
+
+ IOsuCollection copy = Duplicate(manager, "Source", "Copy");
+
+ _ = copy.Name.Should().Be("Copy");
+ _ = copy.AllBeatmaps().Should().HaveCount(2)
+ .And.Contain(beatmap => beatmap.Md5 == "a")
+ .And.Contain(beatmap => beatmap.Md5 == "b");
+ }
+
+ [Fact]
+ public void WhenDuplicatingCollectionThenSourceCollectionRemainsUnchanged()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("a", 1));
+ source.AddBeatmap(BeatmapWith("b", 2));
+
+ _ = Duplicate(manager, "Source", "Copy");
+
+ _ = source.AllBeatmaps().Should().HaveCount(2);
+ _ = manager.GetCollectionByName("Source").Should().BeSameAs(source);
+ }
+
+ [Fact]
+ public void WhenNewNameIsUniqueThenKeepsRequestedName()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("a", 1));
+
+ IOsuCollection copy = Duplicate(manager, "Source", "Fresh");
+
+ _ = copy.Name.Should().Be("Fresh");
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void WhenNewNameCollidesWithExistingCollectionThenNameIsDisambiguated()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _ = Register(manager, "Copy");
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("a", 1));
+
+ IOsuCollection copy = Duplicate(manager, "Source", "Copy");
+
+ _ = copy.Name.Should().Be("Copy_0");
+ _ = manager.GetCollectionByName("Copy").Should().NotBeNull();
+ _ = manager.GetCollectionByName("Copy_0").Should().NotBeNull();
+ }
+
+ [Fact]
+ public void WhenNewNameEqualsSourceNameThenNameIsDisambiguated()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection source = Register(manager, "Orig");
+ source.AddBeatmap(BeatmapWith("a", 1));
+
+ IOsuCollection copy = Duplicate(manager, "Orig", "Orig");
+
+ _ = copy.Name.Should().Be("Orig_0");
+ _ = manager.GetCollectionByName("Orig").Should().NotBeNull();
+ _ = manager.GetCollectionByName("Orig_0").Should().NotBeNull();
+ }
+
+ [Fact]
+ public void WhenDuplicatingEmptyCollectionThenYieldsEmptyNamedCollection()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _ = Register(manager, "Empty");
+
+ IOsuCollection copy = Duplicate(manager, "Empty", "Copy");
+
+ _ = copy.Name.Should().Be("Copy");
+ _ = copy.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenDuplicatingTwiceThenYieldsIncrementingDisambiguatedNames()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("a", 1));
+
+ IOsuCollection first = Duplicate(manager, "Source", "Source");
+ IOsuCollection second = Duplicate(manager, "Source", "Source");
+
+ _ = first.Name.Should().Be("Source_0");
+ _ = second.Name.Should().Be("Source_1");
+ _ = manager.LoadedCollections.Should().HaveCount(3);
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/IntersectStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/IntersectStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..63afaef
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/IntersectStrategy/ExecuteTests.cs
@@ -0,0 +1,135 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.IntersectStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5, int mapId) => new() { Md5 = md5, MapId = mapId };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ private IOsuCollection RunIntersect(CollectionsManagerWithCounts manager, IReadOnlyList names)
+ {
+ new IntersectStrategy(_maps).Execute(manager, CollectionEditArgs.IntersectCollections(names, "Intersect"));
+ return manager.GetCollectionByName("Intersect");
+ }
+
+ [Fact]
+ public void WhenIntersectingTwoOverlappingCollectionsThenKeepsOnlySharedBeatmaps()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("shared", 1));
+ firstCollection.AddBeatmap(BeatmapWith("onlyInA", 2));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("shared", 1));
+ secondCollection.AddBeatmap(BeatmapWith("onlyInB", 3));
+
+ IOsuCollection result = RunIntersect(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Should().ContainSingle()
+ .Which.Md5.Should().Be("shared");
+ }
+
+ [Fact]
+ public void WhenThereAreNoMapsToIntersectThenProducesEmptyResultCollection()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _ = Register(manager, "A");
+ _ = Register(manager, "B");
+
+ IOsuCollection result = RunIntersect(manager, ["A", "B"]);
+
+ _ = result.Should().NotBeNull();
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenCollectionsShareNoBeatmapsThenResultIsEmpty()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("onlyInA", 1));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("onlyInB", 2));
+
+ IOsuCollection result = RunIntersect(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenBeatmapsShareValidMapIdButDifferInHashThenTheyAreIntersected()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("hashA", 15));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("hashB", 15));
+
+ IOsuCollection result = RunIntersect(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Should().ContainSingle();
+ }
+
+ [Fact]
+ public void WhenBeatmapsShareHashWithPlaceholderMapIdsThenTheyAreIntersected()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("same", 0));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("same", 0));
+
+ IOsuCollection result = RunIntersect(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Should().ContainSingle()
+ .Which.Md5.Should().Be("same");
+ }
+
+ [Fact]
+ public void WhenBeatmapsDifferInBothHashAndValidMapIdThenNotIntersected()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("hashA", 15));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("hashB", 16));
+
+ IOsuCollection result = RunIntersect(manager, ["A", "B"]);
+
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenIntersectingMoreThanTwoCollectionsThenKeepsBeatmapsPresentInAll()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("shared", 1));
+ firstCollection.AddBeatmap(BeatmapWith("onlyA", 2));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("shared", 1));
+ secondCollection.AddBeatmap(BeatmapWith("onlyB", 3));
+ OsuCollection thirdCollection = Register(manager, "C");
+ thirdCollection.AddBeatmap(BeatmapWith("shared", 1));
+ thirdCollection.AddBeatmap(BeatmapWith("onlyC", 4));
+
+ IOsuCollection result = RunIntersect(manager, ["A", "B", "C"]);
+
+ _ = result.AllBeatmaps().Should().ContainSingle()
+ .Which.Md5.Should().Be("shared");
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/InverseStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/InverseStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..53e0a28
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/InverseStrategy/ExecuteTests.cs
@@ -0,0 +1,131 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.InverseStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5, int mapId) => new() { Md5 = md5, MapId = mapId };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ private IOsuCollection RunInverse(CollectionsManagerWithCounts manager, IReadOnlyList names)
+ {
+ new InverseStrategy(_maps).Execute(manager, CollectionEditArgs.InverseCollections(names, "Inverse"));
+ return manager.GetCollectionByName("Inverse");
+ }
+
+ [Fact]
+ public void WhenInvertingSingleCollectionThenKeepsLoadedMapsNotInIt()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _maps.StoreBeatmap(BeatmapWith("a", 11));
+ _maps.StoreBeatmap(BeatmapWith("b", 12));
+ _maps.StoreBeatmap(BeatmapWith("c", 13));
+
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("a", 11));
+
+ IOsuCollection result = RunInverse(manager, ["Source"]);
+
+ _ = result.AllBeatmaps().Should().HaveCount(2)
+ .And.Contain(beatmap => beatmap.Md5 == "b")
+ .And.Contain(beatmap => beatmap.Md5 == "c")
+ .And.NotContain(beatmap => beatmap.Md5 == "a");
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void WhenInvertingMultipleCollectionsThenExcludesUnionOfTheirBeatmaps()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _maps.StoreBeatmap(BeatmapWith("a", 11));
+ _maps.StoreBeatmap(BeatmapWith("b", 12));
+ _maps.StoreBeatmap(BeatmapWith("c", 13));
+ _maps.StoreBeatmap(BeatmapWith("d", 14));
+
+ OsuCollection first = Register(manager, "First");
+ first.AddBeatmap(BeatmapWith("a", 11));
+ OsuCollection second = Register(manager, "Second");
+ second.AddBeatmap(BeatmapWith("c", 13));
+
+ IOsuCollection result = RunInverse(manager, ["First", "Second"]);
+
+ _ = result.AllBeatmaps().Should().HaveCount(2)
+ .And.Contain(beatmap => beatmap.Md5 == "b")
+ .And.Contain(beatmap => beatmap.Md5 == "d");
+ }
+
+ [Fact]
+ public void WhenNoLoadedMapsThenProducesEmptyResultCollection()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("a", 11));
+
+ IOsuCollection result = RunInverse(manager, ["Source"]);
+
+ _ = result.Should().NotBeNull();
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenAllLoadedMapsAreInTheCollectionThenResultIsEmpty()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _maps.StoreBeatmap(BeatmapWith("a", 11));
+ _maps.StoreBeatmap(BeatmapWith("b", 12));
+
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("a", 11));
+ source.AddBeatmap(BeatmapWith("b", 12));
+
+ IOsuCollection result = RunInverse(manager, ["Source"]);
+
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenCollectionHoldsMapByValidMapIdOnlyThenLoadedMapIsExcluded()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _maps.StoreBeatmap(BeatmapWith("loaded", 15));
+ _maps.StoreBeatmap(BeatmapWith("other", 16));
+
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmapByMapId(15);
+
+ IOsuCollection result = RunInverse(manager, ["Source"]);
+
+ _ = result.AllBeatmaps().Should().ContainSingle()
+ .Which.Md5.Should().Be("other");
+ }
+
+ [Fact]
+ public void WhenCollectionHoldsMapByHashWithPlaceholderMapIdThenLoadedMapIsExcluded()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _maps.StoreBeatmap(BeatmapWith("same", 5));
+ _maps.StoreBeatmap(BeatmapWith("other", 16));
+
+ OsuCollection source = Register(manager, "Source");
+ source.AddBeatmap(BeatmapWith("same", 5));
+
+ IOsuCollection result = RunInverse(manager, ["Source"]);
+
+ _ = result.AllBeatmaps().Should().ContainSingle()
+ .Which.Md5.Should().Be("other");
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/MergeStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/MergeStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..1092b28
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/MergeStrategy/ExecuteTests.cs
@@ -0,0 +1,171 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.MergeStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5, int mapId) => new() { Md5 = md5, MapId = mapId };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ private CollectionsManagerWithCounts ManagerWith(params string[] names)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach (string name in names)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name });
+ }
+
+ return manager;
+ }
+
+ private static void RunMerge(CollectionsManagerWithCounts manager, IReadOnlyList names, string newName)
+ => new MergeStrategy().Execute(manager, CollectionEditArgs.MergeCollections(names, newName));
+
+ [Fact]
+ public void WhenMergingTwoCollectionsThenUnionsBeatmapsIntoRenamedResultAndRemovesSources()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith();
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("h1", 1));
+ firstCollection.AddBeatmap(BeatmapWith("h2", 2));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("h3", 3));
+
+ RunMerge(manager, ["A", "B"], "Merged");
+
+ _ = manager.LoadedCollections.Should().ContainSingle();
+ IOsuCollection result = manager.GetCollectionByName("Merged");
+ _ = result.Should().NotBeNull();
+ _ = result.AllBeatmaps().Should().HaveCount(3);
+ _ = manager.GetCollectionByName("A").Should().BeNull();
+ _ = manager.GetCollectionByName("B").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenMergingOverlappingBeatmapsThenDeduplicatesByCanonicalIdentity()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith();
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("hashA", 15));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("hashB", 15));
+
+ RunMerge(manager, ["A", "B"], "Merged");
+
+ IOsuCollection result = manager.GetCollectionByName("Merged");
+ _ = result.AllBeatmaps().Should().ContainSingle();
+ }
+
+ [Fact]
+ public void WhenMergingThreeOrMoreCollectionsThenUnionsAllIntoRenamedResult()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith();
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("h1", 1));
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("h2", 2));
+ OsuCollection thirdCollection = Register(manager, "C");
+ thirdCollection.AddBeatmap(BeatmapWith("h3", 3));
+ thirdCollection.AddBeatmap(BeatmapWith("h4", 4));
+
+ RunMerge(manager, ["A", "B", "C"], "Merged");
+
+ _ = manager.LoadedCollections.Should().ContainSingle();
+ IOsuCollection result = manager.GetCollectionByName("Merged");
+ _ = result.AllBeatmaps().Should().HaveCount(4);
+ _ = manager.GetCollectionByName("A").Should().BeNull();
+ _ = manager.GetCollectionByName("B").Should().BeNull();
+ _ = manager.GetCollectionByName("C").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenMergingSingleCollectionThenRenamesAndPreservesBeatmaps()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith();
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("h1", 1));
+ firstCollection.AddBeatmap(BeatmapWith("h2", 2));
+
+ RunMerge(manager, ["A"], "Renamed");
+
+ _ = manager.LoadedCollections.Should().ContainSingle();
+ IOsuCollection result = manager.GetCollectionByName("Renamed");
+ _ = result.Should().NotBeNull();
+ _ = result.AllBeatmaps().Should().HaveCount(2);
+ _ = manager.GetCollectionByName("A").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenNewNameAlreadyTakenThenResultNameIsDisambiguated()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith();
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmap(BeatmapWith("h1", 1));
+ _ = Register(manager, "Merged");
+
+ RunMerge(manager, ["A"], "Merged");
+
+ IOsuCollection result = manager.GetCollectionByName("Merged_0");
+ _ = result.Should().NotBeNull();
+ _ = result.AllBeatmaps().Should().ContainSingle();
+ _ = manager.GetCollectionByName("Merged").Should().NotBeNull();
+ _ = manager.GetCollectionByName("A").Should().BeNull();
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void WhenNameListIsEmptyThenIsNoOp()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("A", "B");
+
+ RunMerge(manager, [], "Merged");
+
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ _ = manager.GetCollectionByName("Merged").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenAllSourcesAreEmptyThenResultIsEmpty()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith();
+ _ = Register(manager, "A");
+ _ = Register(manager, "B");
+
+ RunMerge(manager, ["A", "B"], "Merged");
+
+ _ = manager.LoadedCollections.Should().ContainSingle();
+ IOsuCollection result = manager.GetCollectionByName("Merged");
+ _ = result.Should().NotBeNull();
+ _ = result.AllBeatmaps().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WhenBeatmapsShareMapIdAcrossRepresentationsThenMergeToSingleBeatmap()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith();
+ OsuCollection firstCollection = Register(manager, "A");
+ firstCollection.AddBeatmapByMapId(15);
+ OsuCollection secondCollection = Register(manager, "B");
+ secondCollection.AddBeatmap(BeatmapWith("realhash", 15));
+
+ RunMerge(manager, ["A", "B"], "Merged");
+
+ IOsuCollection result = manager.GetCollectionByName("Merged");
+ _ = result.AllBeatmaps().Should().ContainSingle()
+ .Which.MapId.Should().Be(15);
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/RemoveBeatmapsStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/RemoveBeatmapsStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..c710c16
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/RemoveBeatmapsStrategy/ExecuteTests.cs
@@ -0,0 +1,115 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.RemoveBeatmapsStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5) => new() { Md5 = md5 };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ [Fact]
+ public void WhenRemovingAnExistingBeatmapThenItIsGoneButCollectionRemains()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "C");
+ collection.AddBeatmapByHash("aaa");
+ collection.AddBeatmapByHash("bbb");
+ RemoveBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveBeatmaps("C", [BeatmapWith("aaa")]));
+
+ _ = collection.BeatmapHashes.Should().ContainSingle()
+ .Which.Should().Be("bbb");
+ _ = manager.LoadedCollections.Should().ContainSingle();
+ }
+
+ [Fact]
+ public void WhenRemovingEveryBeatmapThenCollectionIsEmptiedButStillLoaded()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "C");
+ collection.AddBeatmapByHash("aaa");
+ collection.AddBeatmapByHash("bbb");
+ RemoveBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveBeatmaps("C", [BeatmapWith("aaa"), BeatmapWith("bbb")]));
+
+ _ = collection.BeatmapHashes.Should().BeEmpty();
+ _ = collection.AllBeatmaps().Should().BeEmpty();
+ _ = manager.LoadedCollections.Should().ContainSingle()
+ .Which.Name.Should().Be("C");
+ }
+
+ [Fact]
+ public void WhenRemovingMultipleBeatmapsInOneCallThenOnlyThoseAreRemoved()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "C");
+ collection.AddBeatmapByHash("aaa");
+ collection.AddBeatmapByHash("bbb");
+ collection.AddBeatmapByHash("ccc");
+ RemoveBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveBeatmaps("C", [BeatmapWith("aaa"), BeatmapWith("ccc")]));
+
+ _ = collection.BeatmapHashes.Should().ContainSingle()
+ .Which.Should().Be("bbb");
+ }
+
+ [Fact]
+ public void WhenRemovingABeatmapThatIsNotPresentThenCollectionIsUnchanged()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "C");
+ collection.AddBeatmapByHash("aaa");
+ RemoveBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveBeatmaps("C", [BeatmapWith("zzz")]));
+
+ _ = collection.BeatmapHashes.Should().ContainSingle()
+ .Which.Should().Be("aaa");
+ }
+
+ [Fact]
+ public void WhenCollectionIsMissingThenOperationIsNoOp()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection keep = Register(manager, "Keep");
+ keep.AddBeatmapByHash("aaa");
+ RemoveBeatmapsStrategy strategy = new();
+
+ Action act = () => strategy.Execute(manager, CollectionEditArgs.RemoveBeatmaps("Ghost", [BeatmapWith("aaa")]));
+
+ _ = act.Should().NotThrow();
+ _ = keep.BeatmapHashes.Should().ContainSingle()
+ .Which.Should().Be("aaa");
+ _ = manager.LoadedCollections.Should().ContainSingle();
+ }
+
+ [Fact]
+ public void WhenBeatmapListIsEmptyThenCollectionIsUnchanged()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "C");
+ collection.AddBeatmapByHash("aaa");
+ RemoveBeatmapsStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveBeatmaps("C", []));
+
+ _ = collection.BeatmapHashes.Should().ContainSingle()
+ .Which.Should().Be("aaa");
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/RemoveStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/RemoveStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..73d3a42
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/RemoveStrategy/ExecuteTests.cs
@@ -0,0 +1,85 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.RemoveStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private CollectionsManagerWithCounts ManagerWith(params string[] names)
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ foreach (string name in names)
+ {
+ manager.LoadedCollections.Add(new OsuCollection(_maps) { Name = name });
+ }
+
+ return manager;
+ }
+
+ [Fact]
+ public void WhenRemovingNamedCollectionThenLeavesOthersIntact()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Keep", "Drop");
+ RemoveStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveCollections(["Drop"]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle()
+ .Which.Name.Should().Be("Keep");
+ _ = manager.GetCollectionByName("Drop").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenRemovingUnknownNameThenDoesNotThrow()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Keep");
+ RemoveStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveCollections(["DoesNotExist"]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle()
+ .Which.Name.Should().Be("Keep");
+ }
+
+ [Fact]
+ public void WhenRemovingMultipleNamesThenRemovesAll()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("A", "B", "C");
+ RemoveStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveCollections(["A", "C"]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle()
+ .Which.Name.Should().Be("B");
+ }
+
+ [Fact]
+ public void WhenNameIsSubstringOfAnotherThenOnlyExactMatchIsRemoved()
+ {
+ CollectionsManagerWithCounts manager = ManagerWith("Keep", "Keeper");
+ RemoveStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveCollections(["Keep"]));
+
+ _ = manager.LoadedCollections.Should().ContainSingle()
+ .Which.Name.Should().Be("Keeper");
+ }
+
+ [Fact]
+ public void WhenNoCollectionsAreLoadedThenRemovingDoesNotThrow()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ RemoveStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.RemoveCollections(["Anything"]));
+
+ _ = manager.LoadedCollections.Should().BeEmpty();
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/RenameStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/RenameStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..11d24f8
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/RenameStrategy/ExecuteTests.cs
@@ -0,0 +1,104 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.RenameStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private static BeatmapExtension BeatmapWith(string md5, int mapId) => new() { Md5 = md5, MapId = mapId };
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ private static void RunRename(CollectionsManagerWithCounts manager, string oldName, string newName)
+ => new RenameStrategy().Execute(manager, CollectionEditArgs.RenameCollection(oldName, newName));
+
+ [Fact]
+ public void WhenRenamingCollectionThenUpdatesNameAndPreservesBeatmapsIdAndPosition()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "Favorites");
+ collection.Id = 7;
+ collection.AddBeatmap(BeatmapWith("hash1", 100));
+ collection.AddBeatmap(BeatmapWith("hash2", 101));
+
+ RunRename(manager, "Favorites", "Archive");
+
+ _ = collection.Name.Should().Be("Archive");
+ _ = collection.Id.Should().Be(7);
+ _ = collection.AllBeatmaps().Should().HaveCount(2)
+ .And.Contain(beatmap => beatmap.Md5 == "hash1")
+ .And.Contain(beatmap => beatmap.Md5 == "hash2");
+ _ = manager.LoadedCollections.Should().HaveCount(1);
+ _ = manager.LoadedCollections[0].Should().BeSameAs(collection);
+ _ = manager.GetCollectionByName("Favorites").Should().BeNull();
+ _ = manager.GetCollectionByName("Archive").Should().BeSameAs(collection);
+ }
+
+ [Fact]
+ public void WhenRenamingToExistingDifferentCollectionNameThenDisambiguatesWithSuffix()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "Favorites");
+ _ = Register(manager, "Archive");
+
+ RunRename(manager, "Favorites", "Archive");
+
+ _ = collection.Name.Should().Be("Archive_0");
+ _ = manager.GetCollectionByName("Archive").Should().NotBeNull();
+ _ = manager.GetCollectionByName("Favorites").Should().BeNull();
+ _ = manager.LoadedCollections.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void WhenBothDesiredAndSuffixedNamesTakenThenIncrementsSuffix()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "Favorites");
+ _ = Register(manager, "Archive");
+ _ = Register(manager, "Archive_0");
+
+ RunRename(manager, "Favorites", "Archive");
+
+ _ = collection.Name.Should().Be("Archive_1");
+ _ = manager.LoadedCollections.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public void WhenRenamingCollectionToItsOwnCurrentNameThenIsNoOp()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection collection = Register(manager, "Favorites");
+ collection.Id = 5;
+
+ RunRename(manager, "Favorites", "Favorites");
+
+ _ = collection.Name.Should().Be("Favorites");
+ _ = collection.Id.Should().Be(5);
+ _ = manager.LoadedCollections.Should().HaveCount(1);
+ _ = manager.GetCollectionByName("Favorites").Should().BeSameAs(collection);
+ _ = manager.GetCollectionByName("Favorites_0").Should().BeNull();
+ }
+
+ [Fact]
+ public void WhenRenamingMissingCollectionThenIsNoOpAndDoesNotThrow()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+
+ Action renameAction = () => RunRename(manager, "Ghost", "Anything");
+
+ _ = renameAction.Should().NotThrow();
+ _ = manager.LoadedCollections.Should().BeEmpty();
+ _ = manager.GetCollectionByName("Anything").Should().BeNull();
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/Collection/Strategies/ReorderStrategy/ExecuteTests.cs b/CollectionManager.Core.Tests/Modules/Collection/Strategies/ReorderStrategy/ExecuteTests.cs
new file mode 100644
index 0000000..86cf8df
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/Collection/Strategies/ReorderStrategy/ExecuteTests.cs
@@ -0,0 +1,125 @@
+namespace CollectionManager.Core.Tests.Modules.Collection.Strategies.ReorderStrategy;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Enums;
+using CollectionManager.Core.Modules.Collection;
+using CollectionManager.Core.Modules.Collection.Strategies;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Xunit;
+
+public sealed class ExecuteTests
+{
+ private readonly MapCacher _maps = new();
+
+ private OsuCollection Register(CollectionsManagerWithCounts manager, string name)
+ {
+ OsuCollection collection = new(_maps) { Name = name };
+ manager.LoadedCollections.Add(collection);
+ return collection;
+ }
+
+ [Fact]
+ public void WhenReorderingAscendingWhileMovingAfterAnchorThenAssignsSequentialRankPrefixesAndKeepsListOrder()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection banana = Register(manager, "Banana");
+ OsuCollection apple = Register(manager, "Apple");
+ OsuCollection cherry = Register(manager, "Cherry");
+ OsuCollection date = Register(manager, "Date");
+ ReorderStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.ReorderCollections(["Cherry"], "Apple", placeBefore: false, "Name", SortOrder.Ascending));
+ _ = manager.LoadedCollections.Should().HaveCount(4);
+ _ = manager.LoadedCollections[0].Should().BeSameAs(banana);
+ _ = manager.LoadedCollections[1].Should().BeSameAs(apple);
+ _ = manager.LoadedCollections[2].Should().BeSameAs(cherry);
+ _ = manager.LoadedCollections[3].Should().BeSameAs(date);
+ _ = apple.Name.Should().Be("0| Apple");
+ _ = cherry.Name.Should().Be("1| Cherry");
+ _ = banana.Name.Should().Be("2| Banana");
+ _ = date.Name.Should().Be("3| Date");
+ }
+
+ [Fact]
+ public void WhenReorderingDescendingWhileMovingBeforeAnchorThenAssignsRankPrefixesInDescendingOrder()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection banana = Register(manager, "Banana");
+ OsuCollection apple = Register(manager, "Apple");
+ OsuCollection cherry = Register(manager, "Cherry");
+ OsuCollection date = Register(manager, "Date");
+ ReorderStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.ReorderCollections(["Banana"], "Cherry", placeBefore: true, "Name", SortOrder.Descending));
+
+ _ = manager.LoadedCollections.Should().HaveCount(4);
+ _ = manager.LoadedCollections[0].Should().BeSameAs(banana);
+ _ = manager.LoadedCollections[1].Should().BeSameAs(apple);
+ _ = manager.LoadedCollections[2].Should().BeSameAs(cherry);
+ _ = manager.LoadedCollections[3].Should().BeSameAs(date);
+ _ = date.Name.Should().Be("0| Date");
+ _ = banana.Name.Should().Be("1| Banana");
+ _ = cherry.Name.Should().Be("2| Cherry");
+ _ = apple.Name.Should().Be("3| Apple");
+ }
+
+ [Fact]
+ public void WhenCollectionAlreadyHasRankPrefixThenStripsItBeforeApplyingNewPrefix()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection apple = Register(manager, "5| Apple");
+ OsuCollection banana = Register(manager, "5| Banana");
+ OsuCollection cherry = Register(manager, "5| Cherry");
+ ReorderStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.ReorderCollections(["5| Banana"], "5| Cherry", placeBefore: false, "Name", SortOrder.Ascending));
+ _ = apple.Name.Should().Be("0| Apple");
+ _ = cherry.Name.Should().Be("1| Cherry");
+ _ = banana.Name.Should().Be("2| Banana");
+ }
+
+ [Fact]
+ public void WhenMovingNoCollectionsWhileSortingThenAssignsRankPrefixesBySortOrder()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ OsuCollection banana = Register(manager, "Banana");
+ OsuCollection apple = Register(manager, "Apple");
+ OsuCollection cherry = Register(manager, "Cherry");
+ ReorderStrategy strategy = new();
+
+ strategy.Execute(manager, CollectionEditArgs.ReorderCollections([], "Apple", placeBefore: false, "Name", SortOrder.Ascending));
+
+ _ = manager.LoadedCollections.Should().HaveCount(3);
+ _ = manager.LoadedCollections[0].Should().BeSameAs(banana);
+ _ = manager.LoadedCollections[1].Should().BeSameAs(apple);
+ _ = manager.LoadedCollections[2].Should().BeSameAs(cherry);
+ _ = apple.Name.Should().Be("0| Apple");
+ _ = banana.Name.Should().Be("1| Banana");
+ _ = cherry.Name.Should().Be("2| Cherry");
+ }
+
+ [Fact]
+ public void WhenGivenArgsAreNotReorderArgsThenThrowsInvalidOperationException()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _ = Register(manager, "Apple");
+ ReorderStrategy strategy = new();
+
+ Action act = () => strategy.Execute(manager, CollectionEditArgs.ClearCollections());
+
+ _ = act.Should().Throw();
+ }
+
+ [Fact]
+ public void WhenSortColumnIsUnrecognizedThenThrowsInvalidOperationException()
+ {
+ CollectionsManagerWithCounts manager = new(_maps);
+ _ = Register(manager, "Apple");
+ ReorderStrategy strategy = new();
+
+ Action act = () => strategy.Execute(manager, CollectionEditArgs.ReorderCollections(["Apple"], "Apple", placeBefore: true, "Nonexistent", SortOrder.Ascending));
+
+ _ = act.Should().Throw();
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/FileIo/OsuRealmReaderTests.cs b/CollectionManager.Core.Tests/Modules/FileIo/OsuRealmReaderTests.cs
new file mode 100644
index 0000000..022b343
--- /dev/null
+++ b/CollectionManager.Core.Tests/Modules/FileIo/OsuRealmReaderTests.cs
@@ -0,0 +1,275 @@
+namespace CollectionManager.Core.Tests.Modules.FileIo;
+
+using AwesomeAssertions;
+using CollectionManager.Core.Interfaces;
+using CollectionManager.Core.Modules.FileIo;
+using CollectionManager.Core.Modules.FileIo.FileCollections;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+using CollectionManager.Core.Types;
+using CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+using NSubstitute;
+using Realms;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Xunit;
+
+public partial class OsuRealmReaderTests : IDisposable
+{
+ [Explicit, MapTo("RealmOnlineAsset")]
+ public partial class RealmOnlineAssetFixture
+ : IRealmObject
+ {
+ [PrimaryKey] public string Hash { get; set; } = string.Empty;
+ }
+
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "cm-realm-tests", Guid.NewGuid().ToString("N"));
+
+ private static readonly DateTimeOffset SeededLastModified = new(2020, 1, 2, 3, 4, 5, TimeSpan.FromHours(6));
+
+ [Fact]
+ public void LoadReadsScoresAndBeatmapsFromSchema51File()
+ {
+ string realmFilePath = CreateRealmFile(51, withNewerStreamTable: false);
+ IMapDataManager mapDataManager = Substitute.For();
+ IScoreDataManager scoreDataManager = Substitute.For();
+ _ = scoreDataManager.Scores.Returns([]);
+
+ new OsuLazerDatabase(mapDataManager, scoreDataManager).Load(realmFilePath, null, TestContext.Current.CancellationToken);
+
+ scoreDataManager.Received(1).Store(Arg.Any());
+ mapDataManager.Received(1).StoreBeatmap(Arg.Any());
+ }
+
+ [Fact]
+ public void LoadReadsScoresAndBeatmapsFromSchema52File()
+ {
+ string realmFilePath = CreateRealmFile(52, withNewerStreamTable: true);
+ IMapDataManager mapDataManager = Substitute.For();
+ IScoreDataManager scoreDataManager = Substitute.For();
+ _ = scoreDataManager.Scores.Returns([]);
+
+ new OsuLazerDatabase(mapDataManager, scoreDataManager).Load(realmFilePath, null, TestContext.Current.CancellationToken);
+
+ scoreDataManager.Received(1).Store(Arg.Any());
+ mapDataManager.Received(1).StoreBeatmap(Arg.Any());
+ }
+
+ [Theory]
+ [InlineData(51, false)]
+ [InlineData(52, true)]
+ public void CollectionRoundtripWorksOnBothSchemaVersions(ulong schemaVersion, bool withNewerStreamTable)
+ {
+ string realmFilePath = CreateRealmFile(schemaVersion, withNewerStreamTable);
+ LazerCollectionHandler handler = new();
+
+ OsuCollections read = [.. handler.Read(realmFilePath, new MapCacher())];
+
+ _ = read.Should().HaveCount(1);
+ _ = read[0].Name.Should().Be("test collection");
+ _ = read[0].LazerId.Should().NotBe(Guid.Empty);
+
+ handler.Write(read, realmFilePath);
+
+ OsuCollections reread = [.. handler.Read(realmFilePath, new MapCacher())];
+
+ _ = reread.Should().HaveCount(1);
+ _ = reread[0].Name.Should().Be("test collection");
+ _ = reread[0].AllBeatmaps().Should().HaveCount(read[0].AllBeatmaps().Count());
+ _ = reread[0].LazerId.Should().Be(read[0].LazerId, "saving must keep existing collection IDs");
+
+ using LazerRealm opened = TestRealmReader.Open(realmFilePath);
+ _ = opened.Realm.All().First().LastModified.Should().Be(SeededLastModified,
+ "an unchanged collection must keep its LastModified");
+ }
+
+ [Fact]
+ public void LoadUnknownSchemaVersionThrowsWithActualVersion()
+ {
+ string realmFilePath = CreateRealmFile(99, withNewerStreamTable: false);
+ IMapDataManager mapDataManager = Substitute.For();
+ IScoreDataManager scoreDatabase = Substitute.For();
+
+ Action load = () => new OsuLazerDatabase(mapDataManager, scoreDatabase).Load(realmFilePath, null, default);
+
+ _ = load.Should().Throw()
+ .WithMessage("*Supported schema versions: '*'*got: '99'*");
+ }
+
+ [Fact]
+ public void WriteNewFileDefaultsToLastLoadedVersion()
+ {
+ LazerCollectionHandler handler = new();
+
+ string source51 = CreateRealmFile(51, withNewerStreamTable: false);
+ OsuCollections collections51 = [.. handler.Read(source51, new MapCacher())];
+ string newFrom51 = Path.Combine(_directory, "new-from-51.realm");
+ handler.Write(collections51, newFrom51);
+ _ = TestRealmReader.Open(newFrom51).SchemaVersion.Should().Be(LazerRealmSchemaVersion.V51);
+
+ string source52 = CreateRealmFile(52, withNewerStreamTable: true);
+ OsuCollections collections52 = [.. handler.Read(source52, new MapCacher())];
+ string newFrom52 = Path.Combine(_directory, "new-from-52.realm");
+ handler.Write(collections52, newFrom52);
+ _ = TestRealmReader.Open(newFrom52).SchemaVersion.Should().Be(LazerRealmSchemaVersion.V52);
+ }
+
+ [Fact]
+ public void WriteNewFileExplicitTargetVersionOverridesLastLoaded()
+ {
+ string source51 = CreateRealmFile(51, withNewerStreamTable: false);
+ LazerCollectionHandler handler = new();
+ OsuCollections collections = [.. handler.Read(source51, new MapCacher())];
+
+ string explicitV52Path = Path.Combine(_directory, "explicit-v52.realm");
+ handler.Write(collections, explicitV52Path, LazerRealmSchemaVersion.V52);
+ _ = TestRealmReader.Open(explicitV52Path).SchemaVersion.Should().Be(LazerRealmSchemaVersion.V52);
+
+ string latestPath = Path.Combine(_directory, "latest.realm");
+ handler.Write(collections, latestPath, LazerRealmSchemaVersion.Latest);
+ _ = TestRealmReader.Open(latestPath).SchemaVersion.Should().Be(LazerRealmSchemaVersion.V52);
+ }
+
+ [Fact]
+ public void WriteExistingFileKeepsItsVersionRegardlessOfTarget()
+ {
+ string file51 = CreateRealmFile(51, withNewerStreamTable: false);
+ string file52 = CreateRealmFile(52, withNewerStreamTable: true);
+ LazerCollectionHandler handler = new();
+
+ OsuCollections collections51 = [.. handler.Read(file51, new MapCacher())];
+ handler.Write(collections51, file51, LazerRealmSchemaVersion.V52);
+ _ = TestRealmReader.Open(file51).SchemaVersion.Should().Be(LazerRealmSchemaVersion.V51);
+
+ OsuCollections collections52 = [.. handler.Read(file52, new MapCacher())];
+ handler.Write(collections52, file52, LazerRealmSchemaVersion.V51);
+ _ = TestRealmReader.Open(file52).SchemaVersion.Should().Be(LazerRealmSchemaVersion.V52);
+ }
+
+ [Fact]
+ public void LoadAfterWriteToNewFileDoesNotCrash()
+ {
+ string source51 = CreateRealmFile(51, withNewerStreamTable: false);
+ LazerCollectionHandler handler = new();
+ OsuCollections collections = [.. handler.Read(source51, new MapCacher())];
+
+ string exportedPath = Path.Combine(_directory, "exported.realm");
+ handler.Write(collections, exportedPath);
+
+ IMapDataManager mapDataManager = Substitute.For();
+ IScoreDataManager scoreDataManager = Substitute.For();
+ _ = scoreDataManager.Scores.Returns([]);
+
+ // Loading enumerates Score/BeatmapSet, so a newly created file must contain those tables.
+ // Realm kills the process with a native access violation otherwise.
+ new OsuLazerDatabase(mapDataManager, scoreDataManager).Load(exportedPath, null, TestContext.Current.CancellationToken);
+
+ scoreDataManager.DidNotReceive().Store(Arg.Any());
+ mapDataManager.DidNotReceive().StoreBeatmap(Arg.Any());
+ }
+
+ [Fact]
+ public void WriteWhileReadIteratorHoldsFileOpenThrowsClearError()
+ {
+ string realmFilePath = CreateRealmFile(51, withNewerStreamTable: false);
+ LazerCollectionHandler handler = new();
+
+ using IEnumerator heldOpenRead = handler.Read(realmFilePath, new MapCacher()).GetEnumerator();
+ _ = heldOpenRead.MoveNext();
+
+ Action write = () => handler.Write([], realmFilePath);
+
+ _ = write.Should().Throw()
+ .WithMessage("*already open in this process*");
+ }
+
+ private sealed class TestRealmReader
+ : OsuRealmReader
+ {
+ public static LazerRealm Open(string realmFilePath)
+ => OpenRealm(realmFilePath);
+ }
+
+ private string CreateRealmFile(ulong schemaVersion, bool withNewerStreamTable)
+ {
+ _ = Directory.CreateDirectory(_directory);
+ string path = Path.Combine(_directory, $"client_{schemaVersion}{(withNewerStreamTable ? "-b" : string.Empty)}.realm");
+ List types = [.. new LazerRealmAdapter51().ObjectTypes];
+ if (withNewerStreamTable)
+ {
+ types.Add(typeof(RealmOnlineAssetFixture));
+ }
+
+ RealmConfiguration config = new(path) { SchemaVersion = schemaVersion, Schema = types.ToArray() };
+ using Realm realm = Realm.GetInstance(config);
+
+ realm.Write(() =>
+ {
+ RulesetInfo ruleset = new("osu", "osu!", "osu.Game.Rulesets.Osu", 0);
+
+ BeatmapSetInfo beatmapSet = new()
+ {
+ OnlineID = 1,
+ Beatmaps = { NewBeatmap(ruleset) },
+ Files = { new RealmNamedFileUsage { File = new RealmFile { Hash = "filehash1" }, Filename = "audio.mp3" } },
+ };
+ beatmapSet.Beatmaps[0].BeatmapSet = beatmapSet;
+
+ ScoreInfo score = new()
+ {
+ Ruleset = ruleset,
+ RealmUser = new RealmUser { Username = "testuser" },
+ BeatmapHash = beatmapSet.Beatmaps[0].MD5Hash,
+ TotalScore = 123_456,
+ ClientVersion = "2026.1.1.0",
+ Hash = "scorehash1",
+ RankInt = 5, // ScoreRank.SH
+ };
+
+ BeatmapCollection collection = new() { ID = Guid.NewGuid(), Name = "test collection", LastModified = SeededLastModified };
+ collection.BeatmapMD5Hashes.Add(beatmapSet.Beatmaps[0].MD5Hash);
+
+ _ = realm.Add(beatmapSet);
+ _ = realm.Add(score);
+ _ = realm.Add(collection);
+ });
+
+ return path;
+ }
+
+ private static BeatmapInfo NewBeatmap(RulesetInfo ruleset)
+ => new()
+ {
+ MD5Hash = "md5hash1",
+ Hash = "hash1",
+ DifficultyName = "Normal",
+ OnlineID = 2,
+ Ruleset = ruleset,
+ Metadata = new BeatmapMetadata
+ {
+ Title = "Title",
+ Artist = "Artist",
+ Author = new RealmUser { Username = "mapper" },
+ AudioFile = "audio.mp3",
+ BackgroundFile = "bg.jpg",
+ },
+ Difficulty = new BeatmapDifficulty(),
+ UserSettings = new BeatmapUserSettings(),
+ };
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_directory, true);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+}
diff --git a/CollectionManager.Core.Tests/Modules/ModParserTests.cs b/CollectionManager.Core.Tests/Modules/ModParserTests.cs
index ca26f92..5489c03 100644
--- a/CollectionManager.Core.Tests/Modules/ModParserTests.cs
+++ b/CollectionManager.Core.Tests/Modules/ModParserTests.cs
@@ -2,7 +2,7 @@
using CollectionManager.Core.Modules.Mod;
using CollectionManager.Core.Types;
-using FluentAssertions;
+using AwesomeAssertions;
using System.Linq;
using Xunit;
diff --git a/CollectionManager.Core/CollectionManager.Core.csproj b/CollectionManager.Core/CollectionManager.Core.csproj
index e999484..1aaf5bc 100644
--- a/CollectionManager.Core/CollectionManager.Core.csproj
+++ b/CollectionManager.Core/CollectionManager.Core.csproj
@@ -15,4 +15,7 @@
+
+
+
\ No newline at end of file
diff --git a/CollectionManager.Core/Modules/Collection/BeatmapIdentityComparer.cs b/CollectionManager.Core/Modules/Collection/BeatmapIdentityComparer.cs
new file mode 100644
index 0000000..fba49ae
--- /dev/null
+++ b/CollectionManager.Core/Modules/Collection/BeatmapIdentityComparer.cs
@@ -0,0 +1,21 @@
+namespace CollectionManager.Core.Modules.Collection;
+
+using CollectionManager.Core.Types;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using System.Collections.Generic;
+
+///
+/// Beatmap identity comparer which compares beatmaps by their MapId when above the or its Hash.
+///
+internal sealed class BeatmapIdentityComparer : IEqualityComparer
+{
+ public static BeatmapIdentityComparer Instance { get; } = new();
+
+ public bool Equals(BeatmapExtension x, BeatmapExtension y) =>
+ x is not null && y is not null && KeyOf(x) == KeyOf(y);
+
+ public int GetHashCode(BeatmapExtension obj) => KeyOf(obj).GetHashCode();
+
+ internal static string KeyOf(BeatmapExtension beatmap) =>
+ beatmap.MapId > MapCacher.InvalidMapIdThreshold ? $"id:{beatmap.MapId}" : $"h:{beatmap.Hash}";
+}
diff --git a/CollectionManager.Core/Modules/Collection/CollectionBeatmapComparer.cs b/CollectionManager.Core/Modules/Collection/CollectionBeatmapComparer.cs
deleted file mode 100644
index ff0d20c..0000000
--- a/CollectionManager.Core/Modules/Collection/CollectionBeatmapComparer.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-namespace CollectionManager.Core.Modules.Collection;
-
-using CollectionManager.Core.Types;
-using System.Collections.Generic;
-
-internal class CollectionBeatmapComparer : IEqualityComparer
-{
- public bool Equals(BeatmapExtension x, BeatmapExtension y) => x != null && y != null && x.Md5 == y.Md5 && x.MapId == y.MapId;
-
- public int GetHashCode(BeatmapExtension obj)
- {
- unchecked
- {
- int hash = 17;
- hash = (hash * 23) + obj.Md5.GetHashCode();
- return (hash * 23) + obj.MapId.GetHashCode();
- }
- }
-}
\ No newline at end of file
diff --git a/CollectionManager.Core/Modules/Collection/CollectionsManager.cs b/CollectionManager.Core/Modules/Collection/CollectionsManager.cs
index 12dc4a8..9cf415c 100644
--- a/CollectionManager.Core/Modules/Collection/CollectionsManager.cs
+++ b/CollectionManager.Core/Modules/Collection/CollectionsManager.cs
@@ -95,6 +95,12 @@ public OsuCollections GetCollectionsForBeatmaps(Beatmaps beatmaps)
public IOsuCollection GetCollectionByName(string collectionName) =>
LoadedCollections.FirstOrDefault(c => c.Name == collectionName);
+ public IOsuCollection GetCollectionById(int collectionId) =>
+ LoadedCollections.FirstOrDefault(c => c.Id == collectionId);
+
+ public List GetCollectionsById(IEnumerable collectionIds) =>
+ [.. collectionIds.Select(GetCollectionById)];
+
public List GetCollectionByNames(IReadOnlyList collectionNames) =>
[.. collectionNames.Select(GetCollectionByName)];
diff --git a/CollectionManager.Core/Modules/Collection/Strategies/DifferenceStrategy.cs b/CollectionManager.Core/Modules/Collection/Strategies/DifferenceStrategy.cs
index 55d75fb..fc32702 100644
--- a/CollectionManager.Core/Modules/Collection/Strategies/DifferenceStrategy.cs
+++ b/CollectionManager.Core/Modules/Collection/Strategies/DifferenceStrategy.cs
@@ -17,23 +17,33 @@ public void Execute(CollectionsManager manager, CollectionEditArgs args)
{
List argCollections = manager.GetCollectionByNames(args.CollectionNames);
OsuCollection targetCollection = new(_mapCacher) { Name = args.NewName };
- IOsuCollection mainCollection = argCollections[0];
- argCollections.RemoveAt(0);
- IEnumerable beatmaps = mainCollection.AllBeatmaps();
- foreach (IOsuCollection collection in argCollections)
+ Dictionary> collectionsPerKey = [];
+ Dictionary samplePerKey = [];
+
+ for (int i = 0; i < argCollections.Count; i++)
{
- beatmaps = beatmaps.Concat(collection.AllBeatmaps());
+ foreach (BeatmapExtension beatmap in argCollections[i].AllBeatmaps())
+ {
+ string key = BeatmapIdentityComparer.KeyOf(beatmap);
+
+ if (collectionsPerKey.TryGetValue(key, out HashSet? collections))
+ {
+ _ = collections.Add(i);
+ }
+ else
+ {
+ collectionsPerKey[key] = [i];
+ samplePerKey[key] = beatmap;
+ }
+ }
}
- List differenceMd5 = beatmaps.GroupBy(x => x.Md5).Where(group => group.Count() == 1).Select(group => group.Key).ToList();
- List differenceMapId = beatmaps.GroupBy(x => x.MapId).Where(group => group.Count() == 1).Select(group => group.Key).ToList();
-
- foreach (BeatmapExtension beatmap in beatmaps)
+ foreach (KeyValuePair> entry in collectionsPerKey)
{
- if (differenceMd5.Contains(beatmap.Md5) || differenceMapId.Contains(beatmap.MapId))
+ if (entry.Value.Count == 1)
{
- targetCollection.AddBeatmap(beatmap);
+ targetCollection.AddBeatmap(samplePerKey[entry.Key]);
}
}
diff --git a/CollectionManager.Core/Modules/Collection/Strategies/IntersectStrategy.cs b/CollectionManager.Core/Modules/Collection/Strategies/IntersectStrategy.cs
index 37df5e8..e582a34 100644
--- a/CollectionManager.Core/Modules/Collection/Strategies/IntersectStrategy.cs
+++ b/CollectionManager.Core/Modules/Collection/Strategies/IntersectStrategy.cs
@@ -23,7 +23,7 @@ public void Execute(CollectionsManager manager, CollectionEditArgs args)
foreach (IOsuCollection collection in argCollections)
{
- beatmaps = beatmaps.Intersect(collection.AllBeatmaps(), new CollectionBeatmapComparer()).ToList();
+ beatmaps = beatmaps.Intersect(collection.AllBeatmaps(), BeatmapIdentityComparer.Instance).ToList();
}
foreach (BeatmapExtension beatmap in beatmaps)
diff --git a/CollectionManager.Core/Modules/Collection/Strategies/InverseStrategy.cs b/CollectionManager.Core/Modules/Collection/Strategies/InverseStrategy.cs
index 504cae1..a3862fb 100644
--- a/CollectionManager.Core/Modules/Collection/Strategies/InverseStrategy.cs
+++ b/CollectionManager.Core/Modules/Collection/Strategies/InverseStrategy.cs
@@ -21,7 +21,7 @@ public void Execute(CollectionsManager manager, CollectionEditArgs args)
foreach (IOsuCollection collection in argCollections)
{
- beatmaps = beatmaps.Except(collection.AllBeatmaps(), new CollectionBeatmapComparer());
+ beatmaps = beatmaps.Except(collection.AllBeatmaps(), BeatmapIdentityComparer.Instance);
}
foreach (BeatmapExtension beatmap in beatmaps)
diff --git a/CollectionManager.Core/Modules/Collection/Strategies/MergeStrategy.cs b/CollectionManager.Core/Modules/Collection/Strategies/MergeStrategy.cs
index 30541e7..38a714d 100644
--- a/CollectionManager.Core/Modules/Collection/Strategies/MergeStrategy.cs
+++ b/CollectionManager.Core/Modules/Collection/Strategies/MergeStrategy.cs
@@ -13,6 +13,7 @@ public void Execute(CollectionsManager manager, CollectionEditArgs args)
if (argCollections.Count > 0)
{
IOsuCollection masterCollection = argCollections[0];
+ HashSet masterKeys = [.. masterCollection.AllBeatmaps().Select(BeatmapIdentityComparer.KeyOf)];
for (int i = 1; i < argCollections.Count; i++)
{
@@ -20,7 +21,10 @@ public void Execute(CollectionsManager manager, CollectionEditArgs args)
foreach (BeatmapExtension beatmap in collectionToMerge.AllBeatmaps())
{
- masterCollection.AddBeatmap(beatmap);
+ if (masterKeys.Add(BeatmapIdentityComparer.KeyOf(beatmap)))
+ {
+ masterCollection.AddBeatmap(beatmap);
+ }
}
manager.LoadedCollections.SilentRemove(collectionToMerge);
diff --git a/CollectionManager.Core/Modules/Collection/Strategies/RenameStrategy.cs b/CollectionManager.Core/Modules/Collection/Strategies/RenameStrategy.cs
index 4acf8d0..13ac14c 100644
--- a/CollectionManager.Core/Modules/Collection/Strategies/RenameStrategy.cs
+++ b/CollectionManager.Core/Modules/Collection/Strategies/RenameStrategy.cs
@@ -8,6 +8,12 @@ public class RenameStrategy : ICollectionEditStrategy
public void Execute(CollectionsManager manager, CollectionEditArgs args)
{
IOsuCollection collection = manager.GetCollectionByName(args.CollectionNames[0]);
+
+ if (collection is null || collection.Name == args.NewName)
+ {
+ return;
+ }
+
collection.Name = manager.GetValidCollectionName(args.NewName);
}
}
\ No newline at end of file
diff --git a/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoadResult.cs b/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoadResult.cs
new file mode 100644
index 0000000..1a68df5
--- /dev/null
+++ b/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoadResult.cs
@@ -0,0 +1,5 @@
+namespace CollectionManager.Core.Modules.FileIo.FileCollections;
+
+using CollectionManager.Core.Types;
+
+public abstract record CollectionLoadResult(OsuCollections Collections);
diff --git a/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoader.cs b/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoader.cs
index 06d7f7e..bd82199 100644
--- a/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoader.cs
+++ b/CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoader.cs
@@ -1,5 +1,7 @@
namespace CollectionManager.Core.Modules.FileIo.FileCollections;
+using CollectionManager.Core.Modules.FileIo;
+using CollectionManager.Core.Modules.FileIo.OsuLazerDb;
using CollectionManager.Core.Modules.FileIo.OsuDb;
using CollectionManager.Core.Types;
using System.IO;
@@ -17,38 +19,38 @@ public CollectionLoader(MapCacher mapCacher)
public OsuCollections LoadOsuCollection(string fileLocation) => OsuCollectionHandler.LoadCollections(fileLocation, _mapCacher);
- public OsuCollections LoadOsuLazerCollection(string fileLocation)
+ public RealmCollectionLoadResult LoadOsuLazerCollection(string fileLocation)
{
OsuCollections collections = [.. LazerCollectionHandler.Read(fileLocation, _mapCacher)];
- return collections;
+ return new(collections, OsuRealmReader.LastLoadedSchemaVersion);
}
- public OsuCollections LoadOsdbCollections(string fileLocation)
- {
- OsuCollections collections = [.. OsdbCollectionHandler.ReadOsdb(fileLocation, _mapCacher)];
- return collections;
- }
+ public DbCollectionLoadResult LoadOsdbCollections(string fileLocation)
+ => OsdbCollectionHandler.ReadOsdb(fileLocation, _mapCacher);
public void SaveOsuCollection(OsuCollections collections, string saveLocation) => OsuCollectionHandler.SaveCollections(collections, saveLocation);
public void SaveOsdbCollection(OsuCollections collections, string saveLocation, string editorUsername = "N/A") => OsdbCollectionHandler.WriteOsdb(collections, saveLocation, editorUsername);
- public void SaveOsuLazerCollection(OsuCollections collections, string saveLocation) => LazerCollectionHandler.Write(collections, saveLocation);
+ public void SaveOsuLazerCollection(OsuCollections collections, string saveLocation)
+ => SaveOsuLazerCollection(collections, saveLocation, LazerRealmSchemaVersion.LastLoaded);
- public OsuCollections LoadCollection(string fileLocation)
+ public void SaveOsuLazerCollection(OsuCollections collections, string saveLocation, LazerRealmSchemaVersion schemaVersion)
+ => LazerCollectionHandler.Write(collections, saveLocation, schemaVersion);
+ public CollectionLoadResult LoadCollection(string fileLocation)
{
string ext = Path.GetExtension(fileLocation);
return ext.ToLower(System.Globalization.CultureInfo.CurrentCulture) switch
{
- ".db" => LoadOsuCollection(fileLocation),
+ ".db" => new DbCollectionLoadResult(LoadOsuCollection(fileLocation) ?? [], OsuCollectionHandler.LastfileDate),
".osdb" => LoadOsdbCollections(fileLocation),
".realm" => LoadOsuLazerCollection(fileLocation),
- _ => null,
+ _ => throw new InvalidOperationException($"Provided file path did not contain valid file extension. filePath: `{fileLocation}`"),
};
}
- public void SaveCollection(OsuCollections collections, string filePath)
+ public void SaveCollection(OsuCollections collections, string filePath, LazerRealmSchemaVersion schemaVersion = LazerRealmSchemaVersion.LastLoaded)
{
string ext = Path.GetExtension(filePath);
@@ -61,7 +63,7 @@ public void SaveCollection(OsuCollections collections, string filePath)
SaveOsdbCollection(collections, filePath);
break;
case ".realm":
- SaveOsuLazerCollection(collections, filePath);
+ SaveOsuLazerCollection(collections, filePath, schemaVersion);
break;
default:
throw new InvalidOperationException($"Provided file path did not contain valid file extension. filePath: `{filePath}`");
@@ -82,7 +84,7 @@ public OsuCollections LoadCollections(params string[] fileLocations)
foreach (string fileLocation in fileLocations.Where(File.Exists))
{
- collections.AddRange(LoadCollection(fileLocation));
+ collections.AddRange(LoadCollection(fileLocation).Collections);
}
return collections;
diff --git a/CollectionManager.Core/Modules/FileIO/FileCollections/DbCollectionLoadResult.cs b/CollectionManager.Core/Modules/FileIO/FileCollections/DbCollectionLoadResult.cs
new file mode 100644
index 0000000..852a66e
--- /dev/null
+++ b/CollectionManager.Core/Modules/FileIO/FileCollections/DbCollectionLoadResult.cs
@@ -0,0 +1,6 @@
+namespace CollectionManager.Core.Modules.FileIo.FileCollections;
+
+using CollectionManager.Core.Types;
+
+public sealed record DbCollectionLoadResult(OsuCollections Collections, int FileVersion)
+ : CollectionLoadResult(Collections);
diff --git a/CollectionManager.Core/Modules/FileIO/FileCollections/LazerCollectionHandler.cs b/CollectionManager.Core/Modules/FileIO/FileCollections/LazerCollectionHandler.cs
index 6c9afbc..6e8a2c4 100644
--- a/CollectionManager.Core/Modules/FileIO/FileCollections/LazerCollectionHandler.cs
+++ b/CollectionManager.Core/Modules/FileIO/FileCollections/LazerCollectionHandler.cs
@@ -1,10 +1,8 @@
-namespace CollectionManager.Core.Modules.FileIo.FileCollections;
+namespace CollectionManager.Core.Modules.FileIo.FileCollections;
using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Modules.FileIo.OsuLazerDb;
using CollectionManager.Core.Types;
-using CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
-using Realms;
-using System;
using System.Collections.Generic;
public class LazerCollectionHandler
@@ -12,49 +10,17 @@ public class LazerCollectionHandler
{
public IEnumerable Read(string realmFilePath, MapCacher mapCacher)
{
- using Realm localRealm = GetRealm(realmFilePath);
- IRealmCollection allLazerCollections = localRealm.All().AsRealmCollection();
+ using LazerRealm lazerRealm = OpenRealm(realmFilePath);
- foreach (BeatmapCollection lazerCollection in allLazerCollections)
+ foreach (OsuCollection collection in lazerRealm.Adapter.ReadCollections(lazerRealm.Realm, mapCacher))
{
- OsuCollection collection = new(mapCacher)
- {
- Name = lazerCollection.Name,
- LazerId = lazerCollection.ID
- };
-
- foreach (string hash in lazerCollection.BeatmapMD5Hashes)
- {
- collection.AddBeatmapByHash(hash);
- }
-
yield return collection;
}
}
- public void Write(OsuCollections collections, string realmFilePath)
+ public void Write(OsuCollections collections, string realmFilePath, LazerRealmSchemaVersion schemaVersion = LazerRealmSchemaVersion.LastLoaded)
{
- using Realm localRealm = GetRealm(realmFilePath, false);
-
- localRealm.Write(() =>
- {
- localRealm.RemoveRange(localRealm.All());
-
- foreach (IOsuCollection cmCollection in collections)
- {
- BeatmapCollection realmCollection = new()
- {
- ID = Guid.NewGuid(),
- Name = cmCollection.Name
- };
-
- foreach (BeatmapExtension beatmap in cmCollection.AllBeatmaps())
- {
- realmCollection.BeatmapMD5Hashes.Add(beatmap.Md5);
- }
-
- _ = localRealm.Add(realmCollection);
- }
- });
+ using LazerRealm lazerRealm = OpenRealm(realmFilePath, false, schemaVersion);
+ lazerRealm.Adapter.WriteCollections(lazerRealm.Realm, collections);
}
}
diff --git a/CollectionManager.Core/Modules/FileIO/FileCollections/OsdbCollectionHandler.cs b/CollectionManager.Core/Modules/FileIO/FileCollections/OsdbCollectionHandler.cs
index 325769c..778b090 100644
--- a/CollectionManager.Core/Modules/FileIO/FileCollections/OsdbCollectionHandler.cs
+++ b/CollectionManager.Core/Modules/FileIO/FileCollections/OsdbCollectionHandler.cs
@@ -132,7 +132,7 @@ private static void WriteOsdb(OsuCollections collections, BinaryWriter _binWrite
_binWriter.Write("By Piotrekol");
}
- public static IEnumerable ReadOsdb(string fullFileDir, MapCacher mapCacher)
+ public static DbCollectionLoadResult ReadOsdb(string fullFileDir, MapCacher mapCacher)
{
BinaryReader reader;
using (FileStream fileStream = new(fullFileDir, FileMode.Open, FileAccess.Read))
@@ -144,7 +144,7 @@ public static IEnumerable ReadOsdb(string fullFileDir, MapCacher
_ = reader.BaseStream.Seek(0, SeekOrigin.Begin);
int fileVersion = -1;
-
+ OsuCollections collections = [];
string versionString = reader.ReadString();
//check header
if (_versions.TryGetValue(versionString, out int value))
@@ -235,7 +235,7 @@ public static IEnumerable ReadOsdb(string fullFileDir, MapCacher
}
}
- yield return collection;
+ collections.Add(collection);
}
}
@@ -251,6 +251,8 @@ public static IEnumerable ReadOsdb(string fullFileDir, MapCacher
reader.Dispose();
}
}
+
+ return new DbCollectionLoadResult(collections, fileVersion);
}
private static BinaryReader StartReadingFirstFileInArchive(Stream baseStream)
diff --git a/CollectionManager.Core/Modules/FileIO/FileCollections/RealmCollectionLoadResult.cs b/CollectionManager.Core/Modules/FileIO/FileCollections/RealmCollectionLoadResult.cs
new file mode 100644
index 0000000..cc69f03
--- /dev/null
+++ b/CollectionManager.Core/Modules/FileIO/FileCollections/RealmCollectionLoadResult.cs
@@ -0,0 +1,7 @@
+namespace CollectionManager.Core.Modules.FileIo.FileCollections;
+
+using CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+using CollectionManager.Core.Types;
+
+public sealed record RealmCollectionLoadResult(OsuCollections Collections, LazerRealmSchemaVersion RealmSchemaVersion)
+ : CollectionLoadResult(Collections);
diff --git a/CollectionManager.Core/Modules/FileIO/LazerRealm.cs b/CollectionManager.Core/Modules/FileIO/LazerRealm.cs
new file mode 100644
index 0000000..d3b5659
--- /dev/null
+++ b/CollectionManager.Core/Modules/FileIO/LazerRealm.cs
@@ -0,0 +1,11 @@
+namespace CollectionManager.Core.Modules.FileIo;
+
+using CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+using Realms;
+using System;
+
+internal sealed record LazerRealm(Realm Realm, LazerRealmAdapter Adapter, LazerRealmSchemaVersion SchemaVersion)
+ : IDisposable
+{
+ public void Dispose() => Realm.Dispose();
+}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuBinaryReader.cs b/CollectionManager.Core/Modules/FileIO/OsuBinaryReader.cs
index eaa3b33..26eb09f 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuBinaryReader.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuBinaryReader.cs
@@ -14,7 +14,12 @@ public OsuBinaryReader([NotNull] Stream input, [NotNull] Encoding encoding) : ba
{
}
- public override string ReadString() => ReadByte() == 11 ? base.ReadString() : null;
+ public override string ReadString() => ReadRawOsuString() ?? string.Empty;
+
+ ///
+ /// Reads an osu! string field: its value when the string-marker byte is set, otherwise null.
+ ///
+ protected string ReadRawOsuString() => ReadByte() == 11 ? base.ReadString() : null;
public DateTime ReadDateTime()
{
diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/MapCacher.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/MapCacher.cs
index e1c360b..153190e 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuDb/MapCacher.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuDb/MapCacher.cs
@@ -47,8 +47,17 @@ private void UpdateLookupDicts(Beatmap map, bool recalculate = false)
}
- _ = LoadedBeatmapsHashDict.TryAdd(map.Md5, map);
- _ = LoadedBeatmapsHashDict.TryAdd(map.Hash, map);
+ // Md5/Hash are null only in write-back (preserveNullStrings) mode; default mode yields "".
+ if (map.Md5 is not null)
+ {
+ _ = LoadedBeatmapsHashDict.TryAdd(map.Md5, map);
+ }
+
+ if (map.Hash is not null)
+ {
+ _ = LoadedBeatmapsHashDict.TryAdd(map.Hash, map);
+ }
+
_ = LoadedBeatmapsMapIdDict.TryAdd(map.MapId, map);
}
}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseReader.cs b/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseReader.cs
index 9229379..47acd8a 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseReader.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuDb/StableOsuDatabaseReader.cs
@@ -10,7 +10,7 @@ public sealed class StableOsuDatabaseReader
{
public const int LatestOsuDbVersion = 20191105;
- public static StableOsuDatabaseData ReadDatabase(string filePath, CancellationToken cancellationToken, IProgress progress = null)
+ public static StableOsuDatabaseData ReadDatabase(string filePath, CancellationToken cancellationToken, IProgress progress = null, bool preserveNullStrings = false)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
@@ -19,12 +19,14 @@ public static StableOsuDatabaseData ReadDatabase(string filePath, CancellationTo
using FileStream fileStream = new(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
- return ReadDatabase(fileStream, cancellationToken, progress);
+ return ReadDatabase(fileStream, cancellationToken, progress, preserveNullStrings);
}
- public static StableOsuDatabaseData ReadDatabase(Stream inputStream, CancellationToken cancellationToken, IProgress progress = null)
+ public static StableOsuDatabaseData ReadDatabase(Stream inputStream, CancellationToken cancellationToken, IProgress progress = null, bool preserveNullStrings = false)
{
- using OsuBinaryReader binaryReader = new(inputStream);
+ using OsuBinaryReader binaryReader = preserveNullStrings
+ ? new WriteBackOsuBinaryReader(inputStream)
+ : new OsuBinaryReader(inputStream);
StableOsuDatabaseData stableDatabaseData = ReadDatabaseHeader(binaryReader);
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmAdapter.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmAdapter.cs
new file mode 100644
index 0000000..3bf37f4
--- /dev/null
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmAdapter.cs
@@ -0,0 +1,25 @@
+namespace CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+
+using CollectionManager.Core.Interfaces;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using Realms;
+using System;
+using System.Collections.Generic;
+
+internal abstract class LazerRealmAdapter
+{
+ public abstract Type[] ObjectTypes { get; }
+
+ public abstract int CountScores(Realm realm);
+
+ public abstract IEnumerable LoadScores(Realm realm);
+
+ public abstract int CountBeatmapSets(Realm realm);
+
+ public abstract IEnumerable> LoadBeatmapSets(Realm realm, IScoreDataManager scoreDatabase);
+
+ public abstract IEnumerable ReadCollections(Realm realm, MapCacher mapCacher);
+
+ public abstract void WriteCollections(Realm realm, OsuCollections collections);
+}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmAdapter51.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmAdapter51.cs
new file mode 100644
index 0000000..f2ac519
--- /dev/null
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmAdapter51.cs
@@ -0,0 +1,114 @@
+namespace CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+
+using CollectionManager.Core.Extensions;
+using CollectionManager.Core.Interfaces;
+using CollectionManager.Core.Modules.FileIo.OsuDb;
+using CollectionManager.Core.Types;
+using CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+using Realms;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+internal sealed class LazerRealmAdapter51
+ : LazerRealmAdapter
+{
+ public override Type[] ObjectTypes { get; } =
+ [
+ typeof(ScoreInfo),
+ typeof(BeatmapInfo),
+ typeof(BeatmapSetInfo),
+ typeof(BeatmapCollection),
+ typeof(BeatmapMetadata),
+ typeof(BeatmapDifficulty),
+ typeof(BeatmapUserSettings),
+ typeof(RulesetInfo),
+ typeof(RealmFile),
+ typeof(RealmNamedFileUsage),
+ typeof(RealmUser),
+ ];
+
+ public override int CountScores(Realm realm)
+ => realm.All().Count();
+
+ public override IEnumerable LoadScores(Realm realm)
+ => realm.All().AsEnumerable().Select(scoreInfo => scoreInfo.ToLazerReplay());
+
+ public override int CountBeatmapSets(Realm realm)
+ => realm.All().Count();
+
+ public override IEnumerable> LoadBeatmapSets(Realm realm, IScoreDataManager scoreDatabase)
+ => realm.All().AsEnumerable()
+ .Select(beatmapSetInfo => beatmapSetInfo.ToLazerBeatmaps(scoreDatabase));
+
+ public override IEnumerable ReadCollections(Realm realm, MapCacher mapCacher)
+ {
+ IRealmCollection allLazerCollections = realm.All().AsRealmCollection();
+
+ foreach (BeatmapCollection lazerCollection in allLazerCollections)
+ {
+ OsuCollection collection = new(mapCacher)
+ {
+ Name = lazerCollection.Name,
+ LazerId = lazerCollection.ID
+ };
+
+ foreach (string hash in lazerCollection.BeatmapMD5Hashes)
+ {
+ collection.AddBeatmapByHash(hash);
+ }
+
+ yield return collection;
+ }
+ }
+
+ public override void WriteCollections(Realm realm, OsuCollections collections)
+ {
+ realm.Write(() =>
+ {
+ Dictionary existingById = realm.All()
+ .ToDictionary(collection => collection.ID);
+
+ List<(Guid Id, string Name, List Hashes)> desired = collections
+ .Select(collection => (
+ Id: collection.LazerId != Guid.Empty ? collection.LazerId : Guid.NewGuid(),
+ collection.Name,
+ Hashes: collection.AllBeatmaps().Select(beatmap => beatmap.Md5).ToList()))
+ .ToList();
+
+ foreach ((Guid id, string name, List hashes) in desired)
+ {
+ if (existingById.TryGetValue(id, out BeatmapCollection realmCollection)
+ && realmCollection.Name == name
+ && realmCollection.BeatmapMD5Hashes.SequenceEqual(hashes))
+ {
+ continue; // unchanged
+ }
+
+ bool isNew = realmCollection is null;
+ realmCollection ??= new BeatmapCollection { ID = id };
+ realmCollection.Name = name;
+ realmCollection.LastModified = DateTimeOffset.Now;
+ realmCollection.BeatmapMD5Hashes.Clear();
+
+ foreach (string hash in hashes)
+ {
+ realmCollection.BeatmapMD5Hashes.Add(hash);
+ }
+
+ if (isNew)
+ {
+ _ = realm.Add(realmCollection);
+ }
+ }
+
+ HashSet desiredIds = [.. desired.Select(entry => entry.Id)];
+
+ foreach (BeatmapCollection staleCollection in existingById.Values
+ .Where(collection => !desiredIds.Contains(collection.ID)))
+ {
+ realm.Remove(staleCollection);
+ }
+ });
+ }
+}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmSchemaVersion.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmSchemaVersion.cs
new file mode 100644
index 0000000..455cdc0
--- /dev/null
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmSchemaVersion.cs
@@ -0,0 +1,22 @@
+namespace CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+
+///
+/// osu!lazer realm schema version used when writing collection files.
+/// Applies to newly created files only. Existing files always keep their current schema version.
+///
+public enum LazerRealmSchemaVersion
+{
+ ///
+ /// Use the schema version of the last opened realm file.
+ /// Falls back to when no realm file was opened beforehand.
+ ///
+ LastLoaded = -2,
+
+ ///
+ /// The newest supported osu!lazer realm schema version.
+ ///
+ Latest = -1,
+
+ V51 = 51,
+ V52 = 52,
+}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/OsuLazerDatabase.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/OsuLazerDatabase.cs
index 6c1864c..b480671 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/OsuLazerDatabase.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/OsuLazerDatabase.cs
@@ -1,13 +1,10 @@
-namespace CollectionManager.Core.Modules.FileIo.OsuLazerDb;
+namespace CollectionManager.Core.Modules.FileIo.OsuLazerDb;
-using CollectionManager.Core.Extensions;
using CollectionManager.Core.Interfaces;
using CollectionManager.Core.Types;
-using CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
using Realms;
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Threading;
public sealed class OsuLazerDatabase
@@ -24,56 +21,62 @@ public OsuLazerDatabase(IMapDataManager mapDataManager, IScoreDataManager scores
public void Load(string realmFilePath, IProgress progress, CancellationToken cancellationToken)
{
- using Realm localRealm = GetRealm(realmFilePath);
- LoadScores(localRealm, progress);
- LoadBeatmaps(localRealm, progress, cancellationToken);
+ using LazerRealm lazerRealm = OpenRealm(realmFilePath);
+ LoadScores(lazerRealm.Realm, lazerRealm.Adapter, progress);
+ LoadBeatmaps(lazerRealm.Realm, lazerRealm.Adapter, progress, cancellationToken);
_scoresDatabase.UpdateBeatmapsScoreMetadata(_mapDataManager);
}
- private void LoadScores(Realm realm, IProgress progress)
+ private void LoadScores(Realm realm, LazerRealmAdapter adapter, IProgress progress)
{
- IQueryable allLazerScores = realm.All();
- int scoresCount = allLazerScores.Count();
+ int scoresCount = adapter.CountScores(realm);
progress?.Report($"Loading {scoresCount} scores");
_scoresDatabase.StartMassStoring();
- foreach (ScoreInfo lazerScore in allLazerScores)
+ foreach (LazerReplay lazerScore in adapter.LoadScores(realm))
{
- _scoresDatabase.Store(lazerScore.ToLazerReplay());
+ _scoresDatabase.Store(lazerScore);
}
_scoresDatabase.EndMassStoring();
progress?.Report($"Loaded {scoresCount} scores");
}
- private void LoadBeatmaps(Realm realm, IProgress progress, CancellationToken cancellationToken)
+ private void LoadBeatmaps(Realm realm, LazerRealmAdapter realmAdapter, IProgress progress, CancellationToken cancellationToken)
{
- List allLazerBeatmapSets = realm.All().ToList();
- int beatmapSetCount = allLazerBeatmapSets.Count;
+ int beatmapSetCount = realmAdapter.CountBeatmapSets(realm);
progress?.Report($"Loading {beatmapSetCount} beatmap sets");
- _mapDataManager.StartMassStoring();
int totalBeatmapCount = 0;
+ int loadedBeatmapSetCount = 0;
- for (int i = 0; i < allLazerBeatmapSets.Count; i++)
+ try
{
- BeatmapSetInfo beatmapSetInfo = allLazerBeatmapSets[i];
- IEnumerable lazerBeatmaps = beatmapSetInfo.ToLazerBeatmaps(_scoresDatabase);
+ _mapDataManager.StartMassStoring();
+ cancellationToken.ThrowIfCancellationRequested();
- foreach (LazerBeatmap lazerBeatmap in lazerBeatmaps)
+ foreach (IEnumerable lazerBeatmaps in realmAdapter.LoadBeatmapSets(realm, _scoresDatabase))
{
- totalBeatmapCount++;
- _mapDataManager.StoreBeatmap(lazerBeatmap);
- }
+ foreach (LazerBeatmap lazerBeatmap in lazerBeatmaps)
+ {
+ totalBeatmapCount++;
+ _mapDataManager.StoreBeatmap(lazerBeatmap);
+ }
- if (i % 100 == 0)
- {
- cancellationToken.ThrowIfCancellationRequested();
- progress?.Report($"Loaded {i} of {beatmapSetCount} beatmap sets ({totalBeatmapCount} beatmaps)");
+ loadedBeatmapSetCount++;
+
+ if (loadedBeatmapSetCount % 100 == 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ progress?.Report($"Loaded {loadedBeatmapSetCount} of {beatmapSetCount} beatmap sets ({totalBeatmapCount} beatmaps)");
+ }
}
}
+ finally
+ {
+ _mapDataManager.EndMassStoring();
+ }
- _mapDataManager.EndMassStoring();
progress?.Report($"Loaded {beatmapSetCount} beatmap sets ({totalBeatmapCount} beatmaps)");
}
}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapCollection.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapCollection.cs
index c458a93..af7954f 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapCollection.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapCollection.cs
@@ -4,6 +4,7 @@
using Realms;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
internal partial class BeatmapCollection
: IRealmObject
{
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapDifficulty.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapDifficulty.cs
index 1353003..3832ca2 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapDifficulty.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapDifficulty.cs
@@ -2,6 +2,7 @@
using Realms;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
internal partial class BeatmapDifficulty
: IEmbeddedObject
{
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapInfo.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapInfo.cs
index d2a4d8e..1463031 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapInfo.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapInfo.cs
@@ -6,6 +6,7 @@
using System.Linq;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
[MapTo("Beatmap")]
internal partial class BeatmapInfo
: IRealmObject
@@ -24,7 +25,7 @@ internal partial class BeatmapInfo
public BeatmapUserSettings UserSettings { get; set; } = null!;
[UsedImplicitly]
- internal BeatmapInfo()
+ public BeatmapInfo()
{
}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapMetadata.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapMetadata.cs
index 440309c..f246cf4 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapMetadata.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapMetadata.cs
@@ -2,6 +2,7 @@
using Realms;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
internal partial class BeatmapMetadata
: IRealmObject
{
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapSetInfo.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapSetInfo.cs
index 2cf948c..271f1a2 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapSetInfo.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapSetInfo.cs
@@ -7,6 +7,7 @@
using System.Linq;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
[MapTo("BeatmapSet")]
internal partial class BeatmapSetInfo
: IRealmObject
@@ -71,7 +72,7 @@ public BeatmapOnlineStatus Status
//}
[UsedImplicitly] // Realm
- private BeatmapSetInfo()
+ public BeatmapSetInfo()
{
}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapUserSettings.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapUserSettings.cs
index be6c3c9..1de21cb 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapUserSettings.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/BeatmapUserSettings.cs
@@ -2,6 +2,7 @@
using Realms;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
internal partial class BeatmapUserSettings
: IEmbeddedObject
{
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmFile.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmFile.cs
index a68fe10..cf57320 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmFile.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmFile.cs
@@ -3,6 +3,7 @@
using System.Linq;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
[MapTo("File")]
internal partial class RealmFile
: IRealmObject
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmNamedFileUsage.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmNamedFileUsage.cs
index 4846f9c..bfd17c2 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmNamedFileUsage.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmNamedFileUsage.cs
@@ -2,6 +2,7 @@
using Realms;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
internal partial class RealmNamedFileUsage
: IEmbeddedObject
{
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmUser.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmUser.cs
index 97fd1ed..3cc4962 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmUser.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RealmUser.cs
@@ -3,6 +3,7 @@
using System;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
internal partial class RealmUser
: IEmbeddedObject
{
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RulesetInfo.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RulesetInfo.cs
index 860c466..41ce23f 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RulesetInfo.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/RulesetInfo.cs
@@ -4,6 +4,7 @@
using System;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
[MapTo("Ruleset")]
internal partial class RulesetInfo
: IRealmObject
diff --git a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/ScoreInfo.cs b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/ScoreInfo.cs
index 17a83e4..d5f3b84 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/ScoreInfo.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuLazerDb/RealmModels/ScoreInfo.cs
@@ -8,6 +8,7 @@
using CollectionManager.Core.Properties;
namespace CollectionManager.Modules.FileIO.OsuLazerDb.RealmModels;
+[Explicit]
[MapTo("Score")]
internal partial class ScoreInfo
: IRealmObject
@@ -151,7 +152,7 @@ internal partial class ScoreInfo
//}
[UsedImplicitly] // Realm
- private ScoreInfo()
+ public ScoreInfo()
{
}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuPathResolver.cs b/CollectionManager.Core/Modules/FileIO/OsuPathResolver.cs
index cc0d11d..840c26c 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuPathResolver.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuPathResolver.cs
@@ -3,6 +3,7 @@ namespace CollectionManager.Core.Modules.FileIo;
using CollectionManager.Core.Types;
using Microsoft.Win32;
using System;
+using System.Collections.Generic;
using System.IO;
public sealed class OsuPathResolver
@@ -30,22 +31,44 @@ public static async Task GetOsuPathAsync(Func> thisPa
public static OsuPathResult GetOsuOrLazerPath()
{
- if (TryGetRunningOsuPath(out string path))
+ string stablePath = null;
+ string lazerPath = null;
+
+ if (TryGetRunningOsuPath(out string runningPath))
+ {
+ stablePath = runningPath;
+ }
+
+ if (TryGetLazerDataPath(out string dataPath))
+ {
+ lazerPath = dataPath;
+ }
+
+ OsuPathEntry[] registryPaths = GetOsuPathsFromRegistry();
+ foreach (OsuPathEntry entry in registryPaths)
{
- return new OsuPathResult(path, OsuType.Stable);
+ if (entry.Type == OsuType.Stable && stablePath == null)
+ {
+ stablePath = entry.Path;
+ }
+ else if (entry.Type == OsuType.Lazer && lazerPath == null)
+ {
+ lazerPath = entry.Path;
+ }
}
- if (TryGetLazerDataPath(out path))
+ // prioritize stable in auto detection.
+ if (stablePath != null)
{
- return new OsuPathResult(path, OsuType.Lazer);
+ return new OsuPathResult(stablePath, OsuType.Stable, StablePath: stablePath, LazerPath: lazerPath);
}
- if (TryGetOsuPathFromRegistry(out path, out OsuType foundType, OsuType.Any))
+ if (lazerPath != null)
{
- return new OsuPathResult(path, foundType);
+ return new OsuPathResult(lazerPath, OsuType.Lazer, StablePath: stablePath, LazerPath: lazerPath);
}
- return new OsuPathResult(string.Empty, OsuType.None);
+ return new OsuPathResult(string.Empty, OsuType.None, StablePath: null, LazerPath: null);
}
public static async Task GetManualOsuPathAsync(Func> selectDirectoryDialog)
@@ -65,11 +88,17 @@ public static bool TryGetStablePath(out string path)
return true;
}
- if (TryGetOsuPathFromRegistry(out path, out _, OsuType.Stable) && IsOsuStableDirectory(path))
+ OsuPathEntry[] registryPaths = GetOsuPathsFromRegistry();
+ foreach (OsuPathEntry entry in registryPaths)
{
- return true;
+ if (entry.Type == OsuType.Stable && IsOsuStableDirectory(entry.Path))
+ {
+ path = entry.Path;
+ return true;
+ }
}
+ path = null;
return false;
}
@@ -133,34 +162,31 @@ public static bool TryGetRunningOsuPath(out string path)
public static bool IsOsuLazerDataDirectory(string directory) => File.Exists(Path.Combine(directory, "client.realm"));
///
- /// Attempts to retrieve osu! stable or lazer path from windows registry.
+ /// Attempts to retrieve osu! stable and lazer paths from windows registry.
///
///
- private static bool TryGetOsuPathFromRegistry(out string path, out OsuType foundType, OsuType osuType = OsuType.Any)
+ private static OsuPathEntry[] GetOsuPathsFromRegistry()
{
- foundType = OsuType.None;
if (!OperatingSystem.IsWindows())
{
- path = null;
- return false;
+ return [];
}
+ List results = [];
+
try
{
const string lazerKey = "osu.File.osz\\Shell\\Open\\Command";
const string stableKey = "osustable.File.osz\\Shell\\Open\\Command";
- (string key, OsuType type)[] keys = osuType switch
- {
- OsuType.Any => [(lazerKey, OsuType.Lazer), (stableKey, OsuType.Stable)],
- OsuType.Stable => [(stableKey, OsuType.Stable)],
- OsuType.Lazer => [(lazerKey, OsuType.Lazer)],
- OsuType unknown => throw new InvalidOperationException($"OsuType {unknown} is not valid.")
- };
+ OsuPathEntry[] keys = [
+ new(lazerKey, OsuType.Lazer),
+ new(stableKey, OsuType.Stable)
+ ];
- foreach ((string key, OsuType type) in keys)
+ foreach (OsuPathEntry entry in keys)
{
- using RegistryKey osuRegistryKey = Registry.ClassesRoot.OpenSubKey(key);
+ using RegistryKey osuRegistryKey = Registry.ClassesRoot.OpenSubKey(entry.Path);
if (osuRegistryKey is null)
{
@@ -170,11 +196,10 @@ private static bool TryGetOsuPathFromRegistry(out string path, out OsuType found
string keyValue = osuRegistryKey.GetValue(null).ToString();
// format: "C:\some\path\to\osu!\or\lazer\osu!.exe" "%1"
string exePath = keyValue.Remove(0, 1).Replace("\" \"%1\"", string.Empty);
- path = Path.GetDirectoryName(exePath);
+ string path = Path.GetDirectoryName(exePath);
if (IsOsuUserDataDirectory(path))
{
- foundType = type;
- return true;
+ results.Add(new OsuPathEntry(path, entry.Type));
}
}
}
@@ -183,7 +208,8 @@ private static bool TryGetOsuPathFromRegistry(out string path, out OsuType found
// Ignored.
}
- path = null;
- return false;
+ return [.. results];
}
+
+ private record OsuPathEntry(string Path, OsuType Type);
}
diff --git a/CollectionManager.Core/Modules/FileIO/OsuPathResult.cs b/CollectionManager.Core/Modules/FileIO/OsuPathResult.cs
index 382eaed..2fa1003 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuPathResult.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuPathResult.cs
@@ -2,4 +2,4 @@
using CollectionManager.Core.Types;
-public sealed record OsuPathResult(string Path, OsuType Type);
+public sealed record OsuPathResult(string Path, OsuType Type, string? StablePath = default, string? LazerPath = default);
diff --git a/CollectionManager.Core/Modules/FileIO/OsuRealmReader.cs b/CollectionManager.Core/Modules/FileIO/OsuRealmReader.cs
index 8e45806..f91b5df 100644
--- a/CollectionManager.Core/Modules/FileIO/OsuRealmReader.cs
+++ b/CollectionManager.Core/Modules/FileIO/OsuRealmReader.cs
@@ -1,43 +1,126 @@
-namespace CollectionManager.Core.Modules.FileIo;
+namespace CollectionManager.Core.Modules.FileIo;
+
+using CollectionManager.Core.Modules.FileIo.OsuLazerDb;
using Realms;
using Realms.Exceptions;
+using System.IO;
+using System.Linq;
using System.Text.RegularExpressions;
public partial class OsuRealmReader
{
- private const ulong _lastValidatedRealmSchemaVersion = 51;
+ private const string RealmFileVersionMismatchMessage = "because it has a file format version";
+
+ private static readonly LazerRealmAdapter BaseAdapter = new LazerRealmAdapter51();
+
+ ///
+ /// Supported osu!lazer realm schema versions.
+ ///
+ ///
+ /// When adding new version, if realm:
+ /// Only added tables/properties - reuse ;
+ /// Removed/renamed any property - copy the changed model classes to
+ /// RealmModels/vYY/, and add LazerRealmAdapterVYY: LazerRealmAdapter51 with overriden ObjectTypes.
+ ///
+ private static readonly (LazerRealmSchemaVersion Version, LazerRealmAdapter Adapter)[] SupportedSchemaVersions =
+ [
+ (LazerRealmSchemaVersion.V51, BaseAdapter),
+ (LazerRealmSchemaVersion.V52, BaseAdapter),
+ ];
+
+ private static LazerRealmSchemaVersion _lastLoadedSchemaVersion = LazerRealmSchemaVersion.Latest;
+
+ internal static LazerRealmSchemaVersion LastLoadedSchemaVersion => _lastLoadedSchemaVersion;
+
[GeneratedRegex("(\\d+)(?!.*\\d)")]
private static partial Regex LastNumberRegex();
- protected static Realm GetRealm(string realmFilePath, bool readOnly = true)
+ internal static LazerRealm OpenRealm(
+ string realmFilePath,
+ bool readOnly = true,
+ LazerRealmSchemaVersion targetSchemaVersion = LazerRealmSchemaVersion.LastLoaded)
{
- RealmConfiguration config = new(realmFilePath)
- {
- IsReadOnly = readOnly,
- SchemaVersion = _lastValidatedRealmSchemaVersion
- };
-
- try
+ if (!readOnly && !File.Exists(realmFilePath))
{
- return Realm.GetInstance(config);
+ return OpenRealmForNewFile(realmFilePath, targetSchemaVersion);
}
- catch (RealmException exception)
+
+ RealmException lastSchemaException = null;
+
+ foreach ((LazerRealmSchemaVersion realmSchemaVersion, LazerRealmAdapter realmAdapter) in SupportedSchemaVersions)
{
- const string RealmFileVersionMismatchMessage = "because it has a file format version";
+ RealmConfiguration config = new(realmFilePath)
+ {
+ IsReadOnly = readOnly,
+ SchemaVersion = (ulong)realmSchemaVersion,
+ Schema = realmAdapter.ObjectTypes,
+ };
- if (exception.Message.Contains(RealmFileVersionMismatchMessage))
+ try
{
- throw new RealmNotValidatedException($"Opening osu!lazer database failed. Consider reporting this on github. {exception.Message}");
+ return CreateLazerRealm(config, realmAdapter, realmSchemaVersion);
}
+ catch (RealmMismatchedConfigException)
+ {
+ throw new RealmNotValidatedException(
+ $"Opening osu!lazer database failed. '{realmFilePath}' is already open in this process " +
+ $"with a different configuration - finish or dispose that operation first (e.g. a load still in progress).");
+ }
+ catch (RealmException exception)
+ {
+ if (exception.Message.Contains(RealmFileVersionMismatchMessage))
+ {
+ throw new RealmNotValidatedException($"Opening osu!lazer database failed. Consider reporting this on github. {exception.Message}");
+ }
- Match numberMatch = LastNumberRegex().Match(exception.Message);
- string schemaVersionOrMessage = numberMatch.Success
- ? numberMatch.Value
- : exception.Message;
-
- throw new RealmNotValidatedException($"Opening osu!lazer database failed. " +
- $"Expected schema version: '{_lastValidatedRealmSchemaVersion}', " +
- $"got: '{schemaVersionOrMessage}'. Consider reporting this on github.");
+ lastSchemaException = exception;
+ }
}
+
+ Match numberMatch = LastNumberRegex().Match(lastSchemaException.Message);
+ string schemaVersionOrMessage = numberMatch.Success
+ ? numberMatch.Value
+ : lastSchemaException.Message;
+ string supportedVersions = string.Join(", ", SupportedSchemaVersions.Select(entry => (ulong)entry.Version));
+
+ throw new RealmNotValidatedException($"Opening osu!lazer database failed. " +
+ $"Supported schema versions: '{supportedVersions}', " +
+ $"got: '{schemaVersionOrMessage}'. Consider reporting this on github.");
+ }
+
+ private static LazerRealm OpenRealmForNewFile(string realmFilePath, LazerRealmSchemaVersion targetSchemaVersion)
+ {
+ LazerRealmSchemaVersion resolvedSchemaVersion = ResolveNewFileSchemaVersion(targetSchemaVersion, _lastLoadedSchemaVersion);
+
+ (LazerRealmSchemaVersion schemaVersion, LazerRealmAdapter adapter) = SupportedSchemaVersions
+ .Single(entry => entry.Version == resolvedSchemaVersion);
+
+ RealmConfiguration config = new(realmFilePath)
+ {
+ IsReadOnly = false,
+ SchemaVersion = (ulong)schemaVersion,
+ Schema = adapter.ObjectTypes,
+ };
+
+ return CreateLazerRealm(config, adapter, schemaVersion);
+ }
+
+ private static LazerRealmSchemaVersion ResolveNewFileSchemaVersion(LazerRealmSchemaVersion targetSchemaVersion, LazerRealmSchemaVersion lastLoadedSchemaVersion)
+ => targetSchemaVersion switch
+ {
+ LazerRealmSchemaVersion.LastLoaded when lastLoadedSchemaVersion is >= 0 => lastLoadedSchemaVersion,
+ LazerRealmSchemaVersion.LastLoaded or LazerRealmSchemaVersion.Latest => SupportedSchemaVersions[^1].Version,
+ _ => targetSchemaVersion,
+ };
+
+ private static LazerRealm CreateLazerRealm(
+ RealmConfiguration config,
+ LazerRealmAdapter adapter,
+ LazerRealmSchemaVersion realmSchemaVersion)
+ {
+ LazerRealm lazerRealm = new(Realm.GetInstance(config), adapter, realmSchemaVersion);
+ _lastLoadedSchemaVersion = realmSchemaVersion;
+
+ return lazerRealm;
}
}
diff --git a/CollectionManager.Core/Modules/FileIO/WriteBackOsuBinaryReader.cs b/CollectionManager.Core/Modules/FileIO/WriteBackOsuBinaryReader.cs
new file mode 100644
index 0000000..9af1d3c
--- /dev/null
+++ b/CollectionManager.Core/Modules/FileIO/WriteBackOsuBinaryReader.cs
@@ -0,0 +1,22 @@
+namespace CollectionManager.Core.Modules.FileIo;
+
+using CollectionManager.Core.Properties;
+using System.IO;
+using System.Text;
+
+///
+/// osu! binary reader that preserves null-marker strings as null (instead of empty string),
+/// so an osu!.db can be byte-perfect round tripped via .
+///
+public sealed class WriteBackOsuBinaryReader : OsuBinaryReader
+{
+ public WriteBackOsuBinaryReader([NotNull] Stream input) : base(input)
+ {
+ }
+
+ public WriteBackOsuBinaryReader([NotNull] Stream input, [NotNull] Encoding encoding) : base(input, encoding)
+ {
+ }
+
+ public override string ReadString() => ReadRawOsuString();
+}
diff --git a/CollectionManager.Core/Types/OsuCollection.cs b/CollectionManager.Core/Types/OsuCollection.cs
index ef9caf9..a7a3eec 100644
--- a/CollectionManager.Core/Types/OsuCollection.cs
+++ b/CollectionManager.Core/Types/OsuCollection.cs
@@ -152,10 +152,7 @@ public void SetLoadedMaps(MapCacher instance)
throw new BeatmapCacherNotInitalizedException();
}
- if (LoadedMaps is not null)
- {
- LoadedMaps.BeatmapsModified -= LoadedMaps_BeatmapsModified;
- }
+ LoadedMaps?.BeatmapsModified -= LoadedMaps_BeatmapsModified;
LoadedMaps = instance;
LoadedMaps.BeatmapsModified += LoadedMaps_BeatmapsModified;
@@ -201,7 +198,7 @@ public void AddBeatmap(BeatmapExtension map)
{
if (string.IsNullOrEmpty(map.Hash))
{
- map.Hash = "semiRandomHash:" + map.MapId + "|" + map.MapSetId;
+ map.Hash = $"manually-added|{map.MapId}|{map.MapSetId}";
}
if (_beatmaps.ContainsKey(map.Hash))
diff --git a/CollectionManager.Extensions.Tests/Modules/API/osu/OsuSiteTests.cs b/CollectionManager.Extensions.Tests/Modules/API/osu/OsuSiteTests.cs
index 1956f47..a65341c 100644
--- a/CollectionManager.Extensions.Tests/Modules/API/osu/OsuSiteTests.cs
+++ b/CollectionManager.Extensions.Tests/Modules/API/osu/OsuSiteTests.cs
@@ -1,7 +1,7 @@
namespace CollectionManager.Extensions.Tests.Modules.API.osu;
using CollectionManager.Extensions.Modules.API.osu;
-using FluentAssertions;
+using AwesomeAssertions;
using NSubstitute;
using System;
using System.Net;
diff --git a/CollectionManager.Extensions/Modules/API/osustats/OsuStatsApi.cs b/CollectionManager.Extensions/Modules/API/osustats/OsuStatsApi.cs
index 9c46efb..6cc2101 100644
--- a/CollectionManager.Extensions/Modules/API/osustats/OsuStatsApi.cs
+++ b/CollectionManager.Extensions/Modules/API/osustats/OsuStatsApi.cs
@@ -103,7 +103,7 @@ protected async Task> GetCollections(string path)
stream.CopyTo(fileStream);
}
- return OsdbCollectionHandler.ReadOsdb(tempFile, _mapCacher);
+ return OsdbCollectionHandler.ReadOsdb(tempFile, _mapCacher).Collections;
}
public async Task RemoveCollection(int collectionId)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e39915f..3ac51ba 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,7 @@
false
-
+
@@ -16,6 +16,10 @@
+
+
+
+
diff --git a/README.md b/README.md
index 0f6ec9e..c475574 100644
--- a/README.md
+++ b/README.md
@@ -148,80 +148,47 @@ CLI is provided with main Installer or as standalone exe in `CollectionManager-C
CLI uses sub-commands for different operations:
-* `convert` - Convert collection files between formats (.db/.osdb).
-
- * `-i` / `--Input`: Required. Input .db/.osdb collection file.
-
-* `create` - Create collection from beatmap IDs or hashes.
-
- * `-b` / `--BeatmapIds`: Comma or whitespace separated list of beatmap ids. This can also be a path to a file containing this list.
-
- * `-h` / `--Hashes`: Comma or whitespace separated list of beatmap hashes (MD5). This can also be a path to a file containing this list.
-
-* `generate` - Generate collections from user top scores using the osu! API.
-
- * `-u` / `--Usernames`: Required. Comma or whitespace separated list of usernames. This can also be a path to a file containing this list.
-
- * `-k` / `--ApiKey`: Required. osu! API key for accessing user data. Create one in your osu! settings, under `Legacy API` section.
-
- * `-p` / `--CollectionNamePattern`: Optional. Collection name format pattern. Default: `"{0} - {1}"` where `{0}` is username and `{1}` is mods.
-
- * `-g` / `--Gamemode`: Optional. Game mode: `0`=Osu, `1`=Taiko, `2`=Catch, `3`=Mania. Default: `0`.
-
- * `--MinPp`: Optional. Minimum PP required for a score. Default: `0`.
-
- * `--MaxPp`: Optional. Maximum PP allowed for a score. Default: `5000`.
-
- * `--MinAcc`: Optional. Minimum accuracy required for a score (0-100). Default: `0`.
-
- * `--MaxAcc`: Optional. Maximum accuracy allowed for a score (0-100). Default: `100`.
-
- * `-r` / `--Ranks`: Optional. Rank filter: `0`=S and better, `1`=A and worse, `2`=All. Default: `2`.
-
- * `-m` / `--Mods`: Optional. Comma separated list of required mods (e.g., `Hd,Hr`). If empty, all mods are included.
-
-Common options:
-
-* `-o` / `--Output`: Required. Output filename with or without a path. The filename extension will specify which format to save in: `.db` or `.osdb`.
-
-* `-l` / `--OsuLocation`: The location of your osu! directory or a directory containing a valid osu!.db or client.realm. If not provided, Collection Manager will attempt to find it automatically.
-
-* `-s` / `--SkipOsuLocation`: Skip loading of osu! database.
-
-* `--version`: Display version information.
-
-* `--help`: Display help for specific command.
+**Collection Operations:**
+* `list` / `ls` - List loaded collections
+* `load` / `open` - Load collections from file
+* `save` - Save collections to file
+* `rename` / `mv` - Rename a collection
+* `duplicate` - Duplicate a collection
+* `merge` - Merge multiple collections into one
+* `intersect` - Intersect collections (beatmaps present in all) into a new collection
+* `difference` - Difference collections (beatmaps present in only one) into a new collection
+* `inverse` - Inverse collections (loaded beatmaps not in any) into a new collection
+* `remove` / `rm` - Remove collection(s)
+
+**Creation:**
+* `create` - Create collection from beatmap IDs or hashes
+* `convert` - Convert collection files between formats. Same as doing `load` then `save` with different extension.
+* `generate` - Generate collections from user top scores using osu! API
+
+**Pipeline Mode:**
+Chain multiple commands together with `--then` to share collections between operations.
+
+**Getting Help:**
+Run `CollectionManager.App.Cli.exe --help` for all options, or `CollectionManager.App.Cli.exe --help` for command-specific usage.
### Examples
-**Convert collection format:**
```bash
+# Convert between collection formats. This will load your osu maps beforehand by default.
CollectionManager.App.Cli.exe convert -i input.db -o output.osdb
-#or
-CollectionManager.App.Cli.exe convert -i input.osdb -o output.db
-```
-**Create collection from beatmap IDs or hashes:**
-```bash
+# Create collection from beatmap IDs
CollectionManager.App.Cli.exe create -b "1 2 3 4 5" -o mycollection.osdb
-#or
-CollectionManager.App.Cli.exe create -h "hash1 hash2 hash3" -o mycollection.osdb
-#or using file contents
-CollectionManager.App.Cli.exe create -b C:\path\to\ids-or-hashes.txt -o mycollection.osdb
-```
-**Specify osu! location or path to database file manually, instead of using auto detection:**
-```bash
-CollectionManager.App.Cli.exe create -b "1 2 3" -o output.osdb -l "C:\osu!\osu!.db"
-```
+# Generate collections from user top scores, and save
+CollectionManager.App.Cli.exe generate -u "playerName" -k "YOUR_API_KEY" -o "top_plays.osdb"
-**Generate collections from user top scores:**
-```bash
-CollectionManager.App.Cli.exe generate -u "Piotrekol" -k "YOUR_API_KEY" -o "top_plays.osdb"
-#or for multiple users
-CollectionManager.App.Cli.exe generate -u "player1,player2,player3" -k "YOUR_API_KEY" -o "top_plays.osdb"
-#or using file contents
-CollectionManager.App.Cli.exe generate -u C:\path\to\usernames.txt -k "YOUR_API_KEY" -o "top_plays.osdb"
-#or with mods filter and minimum PP
-CollectionManager.App.Cli.exe generate -u "player1" -k "YOUR_API_KEY" -o "hdhr_plays.osdb" -m "HR,HD" --MinPp 500
+# Pipeline: Load, list, and save
+CollectionManager.App.Cli.exe load collection.osdb --then ls --then save -o backup.db
+
+# Pipeline: Load collections, and beatmaps from osu! stable, and export as .osdb
+CollectionManager.App.Cli.exe load --stable --then load-maps --stable --then save -o C:\some\cloud\folder\my_collections.osdb
+
+# Pipeline: Load collections, intersect two of them (use `ls` to find their Ids), and save the result
+CollectionManager.App.Cli.exe load collections.osdb --then ls --then intersect -i "1 2" -n "Both" --then save -o intersection.osdb
```