diff --git a/Knossos.NET/Classes/SemanticVersion.cs b/Knossos.NET/Classes/SemanticVersion.cs index e90a498b..4b99d47e 100644 --- a/Knossos.NET/Classes/SemanticVersion.cs +++ b/Knossos.NET/Classes/SemanticVersion.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; namespace Knossos.NET.Classes { @@ -258,7 +260,7 @@ private static string PreReleaseSeparateNumbers(string preReleaseString) /// /// Compares a semantic version string to the version string in the mod dependency to see if it sastifies the requirement. - /// Version : null -> Any, Version: "4.6.1" -> Only that version, Version: "~4.6.1" -> >=4.6.1 < 4.7.0, Version: ">=4.6.1" -> equal or newer, Version: "<=4.6.1" -> equal or older, Version: ">4.6.1" -> newer, Version: "<4.6.1" -> older + /// Version : null -> Any, Version: "4.6.1" -> Only that version, Version: "~4.6.1" -> >=4.6.1 < 4.7.0, Version: ">=4.6.1" -> equal or newer, Version: "<=4.6.1" -> equal or older, Version: ">4.6.1" -> newer, Version: "<4.6.1" -> older, Version: "[4.6.1,5.0.0)" -> NuGet interval (any combination of [/]/(/) brackets), Version: ">=4.6.1 <5.0.0" -> space-separated AND /// /// /// @@ -276,7 +278,7 @@ public static bool SastifiesDependency(string? dependencyVersion, string? versio */ /// /// Compares a semantic version to the version string in the mod dependency to see if it sastifies the requirement. - /// Version : null -> Any, Version: "4.6.1" -> Only that version, Version: "~4.6.1" -> >=4.6.1 < 4.7.0, Version: ">=4.6.1" -> equal or newer, Version: "<=4.6.1" -> equal or older, Version: ">4.6.1" -> newer, Version: "<4.6.1" -> older + /// Version : null -> Any, Version: "4.6.1" -> Only that version, Version: "~4.6.1" -> >=4.6.1 < 4.7.0, Version: ">=4.6.1" -> equal or newer, Version: "<=4.6.1" -> equal or older, Version: ">4.6.1" -> newer, Version: "<4.6.1" -> older, Version: "[4.6.1,5.0.0)" -> NuGet interval (any combination of [/]/(/) brackets), Version: ">=4.6.1 <5.0.0" -> space-separated AND /// /// /// @@ -291,73 +293,191 @@ public static bool SastifiesDependency(string? dependencyVersion, SemanticVersio return true; } - if (dependencyVersion.Contains("~")) + var parts = NormalizeToSingleOps(dependencyVersion.Trim()); + return parts.Count > 0 && parts.All(p => SatisfiesSingleOp(p, version)); + } + catch (Exception ex) + { + Log.Add(Log.LogSeverity.Error, "SemanticVersion.SastifiesDependency()", ex); + return false; + } + } + + /// + /// True if the constraint cannot be represented as a single-operator + version (e.g. NuGet interval + /// "[1.0,2.0)" or space-separated AND ">=1.0 <2.0"). Used by callers that strip operators or by UI + /// pickers that can only display a single operator. + /// + public static bool IsComplexConstraint(string? constraint) + { + if (string.IsNullOrWhiteSpace(constraint)) + return false; + var s = constraint.Trim(); + if (s.Length == 0) + return false; + if (s[0] == '[' || s[0] == '(') + return true; + if (s.Any(char.IsWhiteSpace)) + return true; + return false; + } + + /// + /// Returns the bare lower-bound version string from any supported constraint form, or null when the + /// constraint has no lower bound (e.g. "<2.0.0" or "(,2.0]"). Null-safe and exception-safe. + /// + public static string? GetLowerBound(string? constraint) + { + if (string.IsNullOrWhiteSpace(constraint)) + return null; + try + { + var parts = NormalizeToSingleOps(constraint.Trim()); + foreach (var p in parts) { - var versionDep = new SemanticVersion(dependencyVersion.Replace("~", "")); - /* major and minor has to math, revision needs to be equal or superior*/ - if (version.major == versionDep.major && version.minor == versionDep.minor && version.revision >= versionDep.revision) - { - if (Compare(version, versionDep) >= 0) - { - return true; - } - } - return false; + if (p.StartsWith(">=")) + return p.Substring(2).Trim(); + if (p.StartsWith("~")) + return p.Substring(1).Trim(); + if (p.StartsWith(">")) + return p.Substring(1).Trim(); + if (p.StartsWith("<")) + continue; + return p.Trim(); } + return null; + } + catch + { + return null; + } + } - if (dependencyVersion.Contains(">=")) - { - var versionDep = new SemanticVersion(dependencyVersion.Replace(">=", "")); - /* major minor and revision needs to be equal or superior*/ - if (version.major >= versionDep.major || version.major == versionDep.major && version.minor >= versionDep.minor || version.major == versionDep.major && version.minor == versionDep.minor && version.revision >= versionDep.revision) - { - if (Compare(version, versionDep) >= 0) - { - return true; - } - } + /// + /// Splits a dependency version string into a list of single-operator constraints to be ANDed together. + /// Recognizes NuGet interval notation, npm hyphen ranges, and npm space-separated ranges; otherwise + /// returns the input unchanged as a one-element list (the existing single-operator path). + /// + private static List NormalizeToSingleOps(string input) + { + var result = new List(); + + if (string.IsNullOrWhiteSpace(input)) + return result; + + //NuGet interval notation: [X,Y], (X,Y), [X,Y), (X,Y], [X,), (X,], (,Y], (,Y), [X] + if (input[0] == '[' || input[0] == '(') + { + var openBracket = input[0]; + var closeBracket = input[input.Length - 1]; + if (closeBracket != ']' && closeBracket != ')') + throw new Exception("Invalid NuGet interval, missing closing bracket: " + input); + + var inner = input.Substring(1, input.Length - 2); + var commaIdx = inner.IndexOf(','); - return false; + if (commaIdx < 0) + { + //No comma -> [X] form (exact match). Both brackets must be square. + if (openBracket != '[' || closeBracket != ']') + throw new Exception("NuGet exact form requires square brackets: " + input); + var v = inner.Trim(); + if (v.Length == 0) + throw new Exception("NuGet exact form requires a version: " + input); + result.Add(v); + return result; } - if (dependencyVersion.Contains("<=")) + var lowStr = inner.Substring(0, commaIdx).Trim(); + var highStr = inner.Substring(commaIdx + 1).Trim(); + + if (lowStr.Length > 0) + result.Add((openBracket == '[' ? ">=" : ">") + lowStr); + if (highStr.Length > 0) + result.Add((closeBracket == ']' ? "<=" : "<") + highStr); + + return result; + } + + //npm space-separated AND. Also catches single-op-with-internal-space like ">= 4.6.1". + if (input.Any(char.IsWhiteSpace)) + { + var matches = Regex.Matches(input, @"(>=|<=|>|<|~)?\s*\d+(?:\.\d+){0,2}(?:-\S+)?"); + if (matches.Count == 0) + throw new Exception("Could not parse range: " + input); + foreach (Match m in matches) + result.Add(Regex.Replace(m.Value, @"\s+", "")); + return result; + } + + //Single-operator form, no parsing needed. + result.Add(input); + return result; + } + + /// + /// Evaluates a single-operator constraint string (e.g. ">=4.6.1", "~4.6.1", "4.6.1") against a candidate + /// version. This is the original per-operator logic, extracted unchanged from SastifiesDependency. + /// + private static bool SatisfiesSingleOp(string singleOp, SemanticVersion version) + { + if (singleOp.Contains("~")) + { + var versionDep = new SemanticVersion(singleOp.Replace("~", "")); + /* major and minor has to math, revision needs to be equal or superior*/ + if (version.major == versionDep.major && version.minor == versionDep.minor && version.revision >= versionDep.revision) { - var versionDep = new SemanticVersion(dependencyVersion.Replace("<=", "")); - /* major minor and revision needs to be equal or inferior*/ - if (version.major <= versionDep.major || version.major == versionDep.major && version.minor <= versionDep.minor || version.major == versionDep.major && version.minor == versionDep.minor && version.revision <= versionDep.revision) + if (Compare(version, versionDep) >= 0) { - if (Compare(version, versionDep) <= 0) - { - return true; - } + return true; } - - return false; } + return false; + } - if (dependencyVersion.Contains(">")) + if (singleOp.Contains(">=")) + { + var versionDep = new SemanticVersion(singleOp.Replace(">=", "")); + /* major minor and revision needs to be equal or superior*/ + if (version.major >= versionDep.major || version.major == versionDep.major && version.minor >= versionDep.minor || version.major == versionDep.major && version.minor == versionDep.minor && version.revision >= versionDep.revision) { - var versionDep = new SemanticVersion(dependencyVersion.Replace(">", "")); - return Compare(version, versionDep) > 0; + if (Compare(version, versionDep) >= 0) + { + return true; + } } - if (dependencyVersion.Contains("<")) - { - var versionDep = new SemanticVersion(dependencyVersion.Replace("<", "")); - return Compare(version, versionDep) < 0; - } + return false; + } - if (Compare(version, new SemanticVersion(dependencyVersion)) == 0) + if (singleOp.Contains("<=")) + { + var versionDep = new SemanticVersion(singleOp.Replace("<=", "")); + /* major minor and revision needs to be equal or inferior*/ + if (version.major <= versionDep.major || version.major == versionDep.major && version.minor <= versionDep.minor || version.major == versionDep.major && version.minor == versionDep.minor && version.revision <= versionDep.revision) { - return true; + if (Compare(version, versionDep) <= 0) + { + return true; + } } return false; - }catch (Exception ex) + } + + if (singleOp.Contains(">")) { - Log.Add(Log.LogSeverity.Error, "SemanticVersion.SastifiesDependency()", ex); - return false; + var versionDep = new SemanticVersion(singleOp.Replace(">", "")); + return Compare(version, versionDep) > 0; } + + if (singleOp.Contains("<")) + { + var versionDep = new SemanticVersion(singleOp.Replace("<", "")); + return Compare(version, versionDep) < 0; + } + + return Compare(version, new SemanticVersion(singleOp)) == 0; } public static bool operator >(SemanticVersion a, SemanticVersion b) diff --git a/Knossos.NET/Models/Mod.cs b/Knossos.NET/Models/Mod.cs index abb6d9c2..7e77449d 100644 --- a/Knossos.NET/Models/Mod.cs +++ b/Knossos.NET/Models/Mod.cs @@ -415,6 +415,11 @@ private List FilterDependencies(List unFilteredDep { temp.Remove(d); } + else if (SemanticVersion.IsComplexConstraint(d.version)) + { + //Range syntax (e.g. "[1.0,2.0)" or ">=1.0 <2.0") cannot be safely stripped + //to a bare version for the dedup comparisons below; leave in temp as-is. + } else { if (d.version.Contains(">=")) diff --git a/Knossos.NET/ViewModels/Templates/DevModPkgMgrViewModel.cs b/Knossos.NET/ViewModels/Templates/DevModPkgMgrViewModel.cs index 9dbbb410..23b820a2 100644 --- a/Knossos.NET/ViewModels/Templates/DevModPkgMgrViewModel.cs +++ b/Knossos.NET/ViewModels/Templates/DevModPkgMgrViewModel.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using CommunityToolkit.Mvvm.ComponentModel; +using Knossos.NET.Classes; using Knossos.NET.Models; using Knossos.NET.Views; using System; @@ -26,6 +27,11 @@ public partial class EditorDependencyItem : ObservableObject [ObservableProperty] internal bool displayPackages = false; + //True when the dep was loaded with a range/complex constraint that the dropdowns can't represent. + //Cleared on any user interaction with the mod, version, or operator dropdowns. While set, + //GetDependency() returns the original Dependency unchanged so the constraint round-trips intact. + private bool preserveOriginalConstraint = false; + internal int versionSelectedIndex = 0; internal int VersionSelectedIndex { @@ -38,6 +44,7 @@ internal int VersionSelectedIndex if(versionSelectedIndex != value) { SetProperty(ref versionSelectedIndex, value); + preserveOriginalConstraint = false; FillPackages(); } } @@ -46,6 +53,19 @@ internal int VersionSelectedIndex [ObservableProperty] internal int versionTypeIndex = 0; + partial void OnVersionTypeIndexChanged(int value) + { + if (preserveOriginalConstraint) + { + //User edited the operator while a complex constraint was preserved — exit preserve mode + //and rebuild the version dropdown so the placeholder is gone and a real version is selected. + preserveOriginalConstraint = false; + VersionItems.Clear(); + FillAllVersions(); + VersionSelectedIndex = 1; + } + } + internal int modSelectedIndex = 0; internal int ModSelectedIndex { @@ -58,6 +78,7 @@ internal int ModSelectedIndex if (modSelectedIndex != value) { SetProperty(ref modSelectedIndex, value); + preserveOriginalConstraint = false; VersionItems.Clear(); FillAllVersions(); VersionSelectedIndex = 1; @@ -90,39 +111,40 @@ public EditorDependencyItem(ModDependency dep, EditorModPackageItem pkgItem, str FillAllVersions(); - var currentVersion = VersionItems.FirstOrDefault(x => x.Content != null && dep.version != null && x.Content.ToString() == dep.version.Trim().Replace(">=", "").Replace("<=", "").Replace(">", "").Replace("<", "").Replace("~", "")); - if (currentVersion != null) + if (SemanticVersion.IsComplexConstraint(dep.version)) { - versionSelectedIndex = VersionItems.IndexOf(currentVersion); - if (dep.version!.Contains("~")) - { - versionTypeIndex = 2; - } - else if (dep.version!.Contains(">=")) - { - versionTypeIndex = 1; - } - else if (dep.version!.Contains("<=")) - { - versionTypeIndex = 3; - } - else if (dep.version!.Contains(">")) + //Range syntax cannot be expressed via the operator+version dropdowns; show the original + //string in a placeholder item and enter preserve mode so GetDependency() round-trips + //the dep unchanged unless the user edits one of the dropdowns. + preserveOriginalConstraint = true; + var complexItem = new ComboBoxItem { Content = dep.version }; + VersionItems.Add(complexItem); + versionSelectedIndex = VersionItems.Count - 1; + versionTypeIndex = 0; + } + else + { + versionTypeIndex = OperatorTypeIndexFromVersion(dep.version); + + var bareVersion = dep.version != null ? StripVersionOperators(dep.version) : null; + var currentVersion = VersionItems.FirstOrDefault(x => x.Content != null && bareVersion != null && x.Content.ToString() == bareVersion); + if (currentVersion != null) { - versionTypeIndex = 4; + versionSelectedIndex = VersionItems.IndexOf(currentVersion); } - else if (dep.version!.Contains("<")) + else if (!string.IsNullOrEmpty(bareVersion)) { - versionTypeIndex = 5; + //Requested version isn't installed — surface it as its own entry so the UI matches the JSON. + var itemVer = new ComboBoxItem(); + itemVer.Content = bareVersion; + VersionItems.Add(itemVer); + versionSelectedIndex = VersionItems.Count - 1; } else { - versionTypeIndex = 0; + VersionSelectedIndex = 0; } } - else - { - VersionSelectedIndex = 0; - } FillPackages(); } @@ -135,37 +157,9 @@ public EditorDependencyItem(ModDependency dep, EditorModPackageItem pkgItem, str itemMod.IsEnabled = false; //Important! This signals that on writing to return the original depedency data to avoid possible loss of dep data ModItems.Insert(0, itemMod); ModSelectedIndex = 0; - if (dep.version != null) // Make sure we hae a version to read. - { - if (dep.version!.Contains("~")) - { - VersionTypeIndex = 2; - } - else if (dep.version!.Contains(">=")) - { - VersionTypeIndex = 1; - } - else if (dep.version!.Contains("<=")) - { - VersionTypeIndex = 3; - } - else if (dep.version!.Contains(">")) - { - VersionTypeIndex = 4; - } - else if (dep.version!.Contains("<")) - { - VersionTypeIndex = 5; - } - else - { - VersionTypeIndex = 0; - } - } else { - VersionTypeIndex = 0; - } + VersionTypeIndex = OperatorTypeIndexFromVersion(dep.version); var itemVer = new ComboBoxItem(); - itemVer.Content = dep.version != null ? dep.version.Replace(">=", "").Replace("<=", "").Replace(">", "").Replace("<", "").Replace("~", "") : "Any"; + itemVer.Content = dep.version != null ? StripVersionOperators(dep.version) : "Any"; VersionItems.Add(itemVer); } } @@ -293,6 +287,25 @@ private void FillPackages() } } + //Maps a dependency version string to the matching index in the version-type combobox + //(0 == exact, 1 >=, 2 ~, 3 <=, 4 >, 5 <). + private static int OperatorTypeIndexFromVersion(string? version) + { + if (version == null) return 0; + if (version.Contains("~")) return 2; + if (version.Contains(">=")) return 1; + if (version.Contains("<=")) return 3; + if (version.Contains(">")) return 4; + if (version.Contains("<")) return 5; + return 0; + } + + private static string StripVersionOperators(string version) + { + return version.Trim().Replace(">=", "").Replace("<=", "") + .Replace(">", "").Replace("<", "").Replace("~", ""); + } + internal void DeleteDependency() { EditorPackageItem.DeleteDependency(this); @@ -313,6 +326,12 @@ internal void ReloadDependency() return Dependency; } + //If the version is a preserved complex constraint and the user hasn't touched anything, round-trip it unchanged + if (preserveOriginalConstraint) + { + return Dependency; + } + var depId = ModItems[ModSelectedIndex].Tag as string; var depVersion = VersionItems[VersionSelectedIndex].Content as string; diff --git a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs index 70988fb1..c3ab9e02 100644 --- a/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs +++ b/Knossos.NET/ViewModels/Templates/Tasks/InstallMod.cs @@ -70,7 +70,7 @@ public async Task InstallMod(Mod mod, CancellationTokenSource cancelSource try { var fso = mod.GetDependency("FSO"); - if (fso != null && (fso.version == null || SemanticVersion.Compare(fso.version.Replace(">=", "").Replace("<=", "").Replace(">", "").Replace("<", "").Replace("~", "").Trim(), VPCompression.MinimumFSOVersion) > 0)) + if (fso != null && (fso.version == null || SemanticVersion.Compare(SemanticVersion.GetLowerBound(fso.version) ?? "0.0.0", VPCompression.MinimumFSOVersion) > 0)) compressMod = true; } catch (Exception ex)