From e5b6d79693179c14d327d9e207f123287d2aa6a7 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:15:41 +0100 Subject: [PATCH 1/9] Misc: cache nuget packages in workflows --- .github/workflows/ci.yml | 10 ++++++++++ .github/workflows/release.yml | 10 ++++++++++ 2 files changed, 20 insertions(+) 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 From 33e397126bf1ea467e482c3f1c0048bfba0a39cc Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:36:40 +0100 Subject: [PATCH 2/9] Add: Bunch more CLI operations --- CollectionManager.App.Cli/CliConstants.cs | 15 ++ .../CollectionManager.App.Cli.csproj | 5 + .../Commands/ConvertCommand.cs | 33 +++ .../Commands/CreateCommand.cs | 116 ++++++++++ .../Commands/GenerateCommand.cs | 207 ++++++++++++++++++ .../Commands/InteractiveCommand.cs | 79 +++++++ .../Commands/ListCommand.cs | 45 ++++ .../Commands/LoadCommand.cs | 127 +++++++++++ .../Commands/LoadOsuDbCommand.cs | 12 + .../Commands/MergeCommand.cs | 66 ++++++ .../Commands/PipelineHelpCommand.cs | 38 ++++ .../Commands/RemoveCommand.cs | 125 +++++++++++ .../Commands/RenameCommand.cs | 93 ++++++++ .../Commands/SaveCommand.cs | 20 ++ .../Common/CommonOptions.cs | 15 -- .../Common/CommonOptionsExtensions.cs | 65 ------ .../Convert/ConvertCommand.cs | 25 --- .../Create/CreateCommand.cs | 118 ---------- .../Generate/GenerateCommand.cs | 191 ---------------- .../Logging/CollectionLogger.cs | 30 +++ .../Logging/IndentationEnricher.cs | 37 ++++ .../Pipeline/CollectionContext.cs | 167 ++++++++++++++ .../Pipeline/IPipelineCommand.cs | 8 + .../Pipeline/PipelineExecutor.cs | 128 +++++++++++ .../Pipeline/PipelineOptions.cs | 26 +++ .../Pipeline/PipelineParser.cs | 89 ++++++++ CollectionManager.App.Cli/Program.cs | 48 +++- .../Properties/launchSettings.json | 8 + .../CollectionManager.App.WinForms.csproj | 1 - .../Modules/Collection/CollectionsManager.cs | 6 + .../Modules/FileIO/OsuPathResolver.cs | 82 ++++--- .../Modules/FileIO/OsuPathResult.cs | 2 +- CollectionManager.Core/Types/OsuCollection.cs | 7 +- Directory.Packages.props | 4 + README.md | 88 ++------ 35 files changed, 1602 insertions(+), 524 deletions(-) create mode 100644 CollectionManager.App.Cli/CliConstants.cs create mode 100644 CollectionManager.App.Cli/Commands/ConvertCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/CreateCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/GenerateCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/InteractiveCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/ListCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/LoadCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/LoadOsuDbCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/MergeCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/PipelineHelpCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/RemoveCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/RenameCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/SaveCommand.cs delete mode 100644 CollectionManager.App.Cli/Common/CommonOptions.cs delete mode 100644 CollectionManager.App.Cli/Common/CommonOptionsExtensions.cs delete mode 100644 CollectionManager.App.Cli/Convert/ConvertCommand.cs delete mode 100644 CollectionManager.App.Cli/Create/CreateCommand.cs delete mode 100644 CollectionManager.App.Cli/Generate/GenerateCommand.cs create mode 100644 CollectionManager.App.Cli/Logging/CollectionLogger.cs create mode 100644 CollectionManager.App.Cli/Logging/IndentationEnricher.cs create mode 100644 CollectionManager.App.Cli/Pipeline/CollectionContext.cs create mode 100644 CollectionManager.App.Cli/Pipeline/IPipelineCommand.cs create mode 100644 CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs create mode 100644 CollectionManager.App.Cli/Pipeline/PipelineOptions.cs create mode 100644 CollectionManager.App.Cli/Pipeline/PipelineParser.cs create mode 100644 CollectionManager.App.Cli/Properties/launchSettings.json 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..d44d1cb --- /dev/null +++ b/CollectionManager.App.Cli/Commands/ConvertCommand.cs @@ -0,0 +1,33 @@ +namespace CollectionManager.App.Cli.Commands; + +using CollectionManager.App.Cli.Pipeline; +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(); + int loadedCount = context.LoadCollectionsFromFile(InputFile); + LogLoadedCollections(loadedCount, 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/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/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..533d297 --- /dev/null +++ b/CollectionManager.App.Cli/Commands/LoadCommand.cs @@ -0,0 +1,127 @@ +namespace CollectionManager.App.Cli.Commands; + +using CollectionManager.App.Cli.Pipeline; +using CollectionManager.Core.Modules.FileIo; +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 + { + int loadedCount = context.LoadCollectionsFromFile(effectiveInputFile); + LogLoadedCollections(loadedCount, effectiveInputFile); + 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 {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..c89d331 --- /dev/null +++ b/CollectionManager.App.Cli/Pipeline/CollectionContext.cs @@ -0,0 +1,167 @@ +namespace CollectionManager.App.Cli.Pipeline; + +using CollectionManager.Core.Extensions; +using CollectionManager.Core.Modules.Collection; +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 int LoadCollectionsFromFile(string path) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Collection file not found: {path}"); + } + + OsuCollections loaded = _fileIo.CollectionLoader.LoadCollection(path); + + if (loaded != null && loaded.Count > 0) + { + CollectionEditArgs args = CollectionEditArgs.AddCollections(loaded); + Manager.EditCollection(args); + } + + return loaded.Count; + } + + public void SaveCollectionsToFile(string path) => _fileIo.CollectionLoader.SaveCollection(Collections, path); + + 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..9ca8978 --- /dev/null +++ b/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs @@ -0,0 +1,128 @@ +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, + GenerateCommand, + InteractiveCommand, + LoadCommand, + LoadOsuDbCommand, + SaveCommand, + ListCommand, + RenameCommand, + MergeCommand, + RemoveCommand, + PipelineHelpCommand + >(args) + .MapResult( + (ConvertCommand cmd) => ExecuteCommandAsync(cmd, context), + (CreateCommand cmd) => ExecuteCommandAsync(cmd, context), + (GenerateCommand cmd) => ExecuteCommandAsync(cmd, context), + (InteractiveCommand 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); + 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..38903e9 --- /dev/null +++ b/CollectionManager.App.Cli/Pipeline/PipelineOptions.cs @@ -0,0 +1,26 @@ +namespace CollectionManager.App.Cli.Pipeline; + +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('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.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/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/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/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/Directory.Packages.props b/Directory.Packages.props index e39915f..b5e766a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,10 @@ + + + + diff --git a/README.md b/README.md index 0f6ec9e..ce2bd2f 100644 --- a/README.md +++ b/README.md @@ -148,80 +148,40 @@ 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). +**Collection Operations:** +* `list` / `ls` - List loaded collections +* `load` / `open` - Load collections from file +* `save` - Save collections to file +* `rename` / `mv` - Rename a collection +* `merge` - Merge multiple collections into one +* `remove` / `rm` - Remove collection(s) - * `-i` / `--Input`: Required. Input .db/.osdb collection file. +**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 -* `create` - Create collection from beatmap IDs or hashes. +**Pipeline Mode:** +Chain multiple commands together with `--then` to share collections between operations. - * `-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. +**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 ``` From 6e378c479869711ead4bbcb4e7e2e0ac70ea9040 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:40:01 +0100 Subject: [PATCH 3/9] Add: Rest of commands CLI should now have feature-parity for collection operations available in UI. --- .../Commands/DifferenceCommand.cs | 62 ++++++++++++++++ .../Commands/DuplicateCommand.cs | 50 +++++++++++++ .../Commands/IntersectCommand.cs | 62 ++++++++++++++++ .../Commands/InverseCommand.cs | 72 +++++++++++++++++++ .../Pipeline/PipelineExecutor.cs | 8 +++ 5 files changed, 254 insertions(+) create mode 100644 CollectionManager.App.Cli/Commands/DifferenceCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/DuplicateCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/IntersectCommand.cs create mode 100644 CollectionManager.App.Cli/Commands/InverseCommand.cs 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/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/Pipeline/PipelineExecutor.cs b/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs index 9ca8978..99ca735 100644 --- a/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs +++ b/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs @@ -58,8 +58,12 @@ private static async Task ExecuteCommandAsync(string[] args, CollectionCont .ParseArguments< ConvertCommand, CreateCommand, + DifferenceCommand, + DuplicateCommand, GenerateCommand, InteractiveCommand, + IntersectCommand, + InverseCommand, LoadCommand, LoadOsuDbCommand, SaveCommand, @@ -72,8 +76,12 @@ private static async Task ExecuteCommandAsync(string[] args, CollectionCont .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), From 21221fac3cfc24e0f74621a32a306e815cc0688f Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:51:10 +0200 Subject: [PATCH 4/9] Fix: osu!.db failing to load in some cases --- .../Modules/FileIO/OsuBinaryReader.cs | 7 +++++- .../Modules/FileIO/OsuDb/MapCacher.cs | 13 +++++++++-- .../FileIO/OsuDb/StableOsuDatabaseReader.cs | 10 +++++---- .../FileIO/WriteBackOsuBinaryReader.cs | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 CollectionManager.Core/Modules/FileIO/WriteBackOsuBinaryReader.cs 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/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(); +} From 54151031e805fa1821bfc6857b54b21af353af42 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:07:39 +0200 Subject: [PATCH 5/9] Misc: Update CLI docs --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index ce2bd2f..c475574 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,11 @@ CLI uses sub-commands for different operations: * `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:** @@ -184,4 +188,7 @@ CollectionManager.App.Cli.exe load collection.osdb --then ls --then save -o back # 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 ``` From 989c9d8a1fba9aa251ed1924b844718b59d445f7 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:10:09 +0200 Subject: [PATCH 6/9] Fix: Renaming collection to same name should not change its name (NoOp) --- .../Modules/Collection/Strategies/RenameStrategy.cs | 6 ++++++ 1 file changed, 6 insertions(+) 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 From fa1b13dcca6bc955b46d37dd4ed614d669c4e426 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:12:17 +0200 Subject: [PATCH 7/9] Fix: Inconsistencies around hash and mapId comparisons --- .../Collection/BeatmapIdentityComparer.cs | 21 ++++++++++++ .../Collection/CollectionBeatmapComparer.cs | 19 ----------- .../Strategies/DifferenceStrategy.cs | 32 ++++++++++++------- .../Strategies/IntersectStrategy.cs | 2 +- .../Collection/Strategies/InverseStrategy.cs | 2 +- .../Collection/Strategies/MergeStrategy.cs | 6 +++- 6 files changed, 49 insertions(+), 33 deletions(-) create mode 100644 CollectionManager.Core/Modules/Collection/BeatmapIdentityComparer.cs delete mode 100644 CollectionManager.Core/Modules/Collection/CollectionBeatmapComparer.cs 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/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); From 4907169949c4cafd6aeddd2b11405becdbf25c5c Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:13:38 +0200 Subject: [PATCH 8/9] Misc: Base CollectionManager tests long overdue --- .../Extensions/StringExtensionsTests.cs | 2 +- .../CollectionNameExistsTests.cs | 64 +++++++ .../GetCollectionByIdTests.cs | 60 ++++++ .../GetCollectionByNameTests.cs | 70 +++++++ .../GetCollectionsByIdTests.cs | 69 +++++++ .../GetValidCollectionNameTests.cs | 80 ++++++++ .../IsCollectionNameValidTests.cs | 64 +++++++ .../AddBeatmapsStrategy/ExecuteTests.cs | 120 ++++++++++++ .../ExecuteTests.cs | 166 +++++++++++++++++ .../Strategies/AddStrategy/ExecuteTests.cs | 87 +++++++++ .../Strategies/ClearStrategy/ExecuteTests.cs | 89 +++++++++ .../DifferenceStrategy/ExecuteTests.cs | 170 +++++++++++++++++ .../DuplicateStrategy/ExecuteTests.cs | 127 +++++++++++++ .../IntersectStrategy/ExecuteTests.cs | 135 ++++++++++++++ .../InverseStrategy/ExecuteTests.cs | 131 ++++++++++++++ .../Strategies/MergeStrategy/ExecuteTests.cs | 171 ++++++++++++++++++ .../RemoveBeatmapsStrategy/ExecuteTests.cs | 115 ++++++++++++ .../Strategies/RemoveStrategy/ExecuteTests.cs | 85 +++++++++ .../Strategies/RenameStrategy/ExecuteTests.cs | 104 +++++++++++ .../ReorderStrategy/ExecuteTests.cs | 125 +++++++++++++ .../Modules/ModParserTests.cs | 2 +- .../Modules/API/osu/OsuSiteTests.cs | 2 +- Directory.Packages.props | 2 +- 23 files changed, 2036 insertions(+), 4 deletions(-) create mode 100644 CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/CollectionNameExistsTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionByIdTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionByNameTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetCollectionsByIdTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/GetValidCollectionNameTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/CollectionsManager/IsCollectionNameValidTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/AddBeatmapsStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/AddOrMergeIfExistsStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/AddStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/ClearStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/DifferenceStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/DuplicateStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/IntersectStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/InverseStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/MergeStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/RemoveBeatmapsStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/RemoveStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/RenameStrategy/ExecuteTests.cs create mode 100644 CollectionManager.Core.Tests/Modules/Collection/Strategies/ReorderStrategy/ExecuteTests.cs 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/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/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.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/Directory.Packages.props b/Directory.Packages.props index b5e766a..3ac51ba 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ false - + From 0e469f0f9f1ede0aa473edea9473b34d2ca5a4f3 Mon Sep 17 00:00:00 2001 From: Piotrekol <4990365+Piotrekol@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:22:03 +0200 Subject: [PATCH 9/9] Add: Support multiple realm versions --- .../Commands/ConvertCommand.cs | 5 +- .../Commands/LoadCommand.cs | 21 +- .../Pipeline/CollectionContext.cs | 15 +- .../Pipeline/PipelineExecutor.cs | 2 +- .../Pipeline/PipelineOptions.cs | 4 + CollectionManager.App.Shared/Initalizer.cs | 2 +- .../SidePanelActions/LoadCollectionHandler.cs | 2 +- .../SidePanelActionHelpers.cs | 2 +- .../CollectionManager.Core.Tests.csproj | 1 + CollectionManager.Core.Tests/FodyWeavers.xml | 3 + CollectionManager.Core.Tests/FodyWeavers.xsd | 28 ++ .../Modules/FileIo/OsuRealmReaderTests.cs | 275 ++++++++++++++++++ .../CollectionManager.Core.csproj | 3 + .../FileCollections/CollectionLoadResult.cs | 5 + .../FileCollections/CollectionLoader.cs | 30 +- .../FileCollections/DbCollectionLoadResult.cs | 6 + .../FileCollections/LazerCollectionHandler.cs | 48 +-- .../FileCollections/OsdbCollectionHandler.cs | 8 +- .../RealmCollectionLoadResult.cs | 7 + .../Modules/FileIO/LazerRealm.cs | 11 + .../FileIO/OsuLazerDb/LazerRealmAdapter.cs | 25 ++ .../FileIO/OsuLazerDb/LazerRealmAdapter51.cs | 114 ++++++++ .../OsuLazerDb/LazerRealmSchemaVersion.cs | 22 ++ .../FileIO/OsuLazerDb/OsuLazerDatabase.cs | 59 ++-- .../RealmModels/BeatmapCollection.cs | 1 + .../RealmModels/BeatmapDifficulty.cs | 1 + .../OsuLazerDb/RealmModels/BeatmapInfo.cs | 3 +- .../OsuLazerDb/RealmModels/BeatmapMetadata.cs | 1 + .../OsuLazerDb/RealmModels/BeatmapSetInfo.cs | 3 +- .../RealmModels/BeatmapUserSettings.cs | 1 + .../OsuLazerDb/RealmModels/RealmFile.cs | 1 + .../RealmModels/RealmNamedFileUsage.cs | 1 + .../OsuLazerDb/RealmModels/RealmUser.cs | 1 + .../OsuLazerDb/RealmModels/RulesetInfo.cs | 1 + .../OsuLazerDb/RealmModels/ScoreInfo.cs | 3 +- .../Modules/FileIO/OsuRealmReader.cs | 129 ++++++-- .../Modules/API/osustats/OsuStatsApi.cs | 2 +- 37 files changed, 719 insertions(+), 127 deletions(-) create mode 100644 CollectionManager.Core.Tests/FodyWeavers.xml create mode 100644 CollectionManager.Core.Tests/FodyWeavers.xsd create mode 100644 CollectionManager.Core.Tests/Modules/FileIo/OsuRealmReaderTests.cs create mode 100644 CollectionManager.Core/Modules/FileIO/FileCollections/CollectionLoadResult.cs create mode 100644 CollectionManager.Core/Modules/FileIO/FileCollections/DbCollectionLoadResult.cs create mode 100644 CollectionManager.Core/Modules/FileIO/FileCollections/RealmCollectionLoadResult.cs create mode 100644 CollectionManager.Core/Modules/FileIO/LazerRealm.cs create mode 100644 CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmAdapter.cs create mode 100644 CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmAdapter51.cs create mode 100644 CollectionManager.Core/Modules/FileIO/OsuLazerDb/LazerRealmSchemaVersion.cs diff --git a/CollectionManager.App.Cli/Commands/ConvertCommand.cs b/CollectionManager.App.Cli/Commands/ConvertCommand.cs index d44d1cb..66bb4e9 100644 --- a/CollectionManager.App.Cli/Commands/ConvertCommand.cs +++ b/CollectionManager.App.Cli/Commands/ConvertCommand.cs @@ -1,6 +1,7 @@ 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; @@ -19,8 +20,8 @@ public override Task RunAsync(CollectionContext context) { _ = context.EnsureOsuDatabaseLoaded(this); LogConvertingCollections(); - int loadedCount = context.LoadCollectionsFromFile(InputFile); - LogLoadedCollections(loadedCount, InputFile); + CollectionLoadResult loaded = context.LoadCollectionsFromFile(InputFile); + LogLoadedCollections(loaded.Collections.Count, InputFile); return Task.FromResult(0); } diff --git a/CollectionManager.App.Cli/Commands/LoadCommand.cs b/CollectionManager.App.Cli/Commands/LoadCommand.cs index 533d297..0b2309f 100644 --- a/CollectionManager.App.Cli/Commands/LoadCommand.cs +++ b/CollectionManager.App.Cli/Commands/LoadCommand.cs @@ -2,6 +2,7 @@ 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; @@ -51,8 +52,19 @@ public override Task RunAsync(CollectionContext context) try { - int loadedCount = context.LoadCollectionsFromFile(effectiveInputFile); - LogLoadedCollections(loadedCount, effectiveInputFile); + 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) @@ -119,6 +131,11 @@ public override Task RunAsync(CollectionContext context) [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); diff --git a/CollectionManager.App.Cli/Pipeline/CollectionContext.cs b/CollectionManager.App.Cli/Pipeline/CollectionContext.cs index c89d331..bb63e1a 100644 --- a/CollectionManager.App.Cli/Pipeline/CollectionContext.cs +++ b/CollectionManager.App.Cli/Pipeline/CollectionContext.cs @@ -1,7 +1,9 @@ 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; @@ -111,25 +113,26 @@ private bool EnsureOsuDatabaseLoaded(string? explicitPath = default, bool skip = return path; } - public int LoadCollectionsFromFile(string path) + public CollectionLoadResult LoadCollectionsFromFile(string path) { if (!File.Exists(path)) { throw new FileNotFoundException($"Collection file not found: {path}"); } - OsuCollections loaded = _fileIo.CollectionLoader.LoadCollection(path); + CollectionLoadResult loaded = _fileIo.CollectionLoader.LoadCollection(path); - if (loaded != null && loaded.Count > 0) + if (loaded.Collections.Count > 0) { - CollectionEditArgs args = CollectionEditArgs.AddCollections(loaded); + CollectionEditArgs args = CollectionEditArgs.AddCollections(loaded.Collections); Manager.EditCollection(args); } - return loaded.Count; + return loaded; } - public void SaveCollectionsToFile(string path) => _fileIo.CollectionLoader.SaveCollection(Collections, path); + public void SaveCollectionsToFile(string path, LazerRealmSchemaVersion targetSchemaVersion) + => _fileIo.CollectionLoader.SaveCollection(Collections, path, targetSchemaVersion); public StableOsuDatabaseData? GetStableOsuDatabaseData() => _fileIo.OsuDatabase.StableOsuDatabaseData; diff --git a/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs b/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs index 99ca735..45a526f 100644 --- a/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs +++ b/CollectionManager.App.Cli/Pipeline/PipelineExecutor.cs @@ -109,7 +109,7 @@ private static async Task ExecuteCommandAsync(PipelineOptions cmd, Collecti } string path = GetOutputPath(cmd.OutputFile); - context.SaveCollectionsToFile(path); + context.SaveCollectionsToFile(path, cmd.RealmVersion); Logger.LogSavedToFile(path); } diff --git a/CollectionManager.App.Cli/Pipeline/PipelineOptions.cs b/CollectionManager.App.Cli/Pipeline/PipelineOptions.cs index 38903e9..266c73e 100644 --- a/CollectionManager.App.Cli/Pipeline/PipelineOptions.cs +++ b/CollectionManager.App.Cli/Pipeline/PipelineOptions.cs @@ -1,5 +1,6 @@ namespace CollectionManager.App.Cli.Pipeline; +using CollectionManager.Core.Modules.FileIo.OsuLazerDb; using CommandLine; /// @@ -10,6 +11,9 @@ 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; } 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.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/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/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/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/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/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/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.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)