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)