Skip to content

Commit d01d64f

Browse files
committed
Release v1.1.0 compatibility fixes
1 parent 76c2429 commit d01d64f

36 files changed

Lines changed: 681 additions & 53 deletions

BetterModMenu.Tests/BetterModMenu.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<Compile Include="..\Data\ModNameStyleEditorRules.cs" Link="Data\ModNameStyleEditorRules.cs" />
2828
<Compile Include="..\Data\ModNameStyleRules.cs" Link="Data\ModNameStyleRules.cs" />
2929
<Compile Include="..\Data\ModInstallPathResolver.cs" Link="Data\ModInstallPathResolver.cs" />
30+
<Compile Include="..\Data\ModAnnotationRules.cs" Link="Data\ModAnnotationRules.cs" />
3031
<Compile Include="..\Data\ModListExportBuilder.cs" Link="Data\ModListExportBuilder.cs" />
3132
<Compile Include="..\Data\ModSearchRules.cs" Link="Data\ModSearchRules.cs" />
3233
<Compile Include="..\Data\ModConfigProviderRules.cs" Link="Data\ModConfigProviderRules.cs" />

BetterModMenu.Tests/LogicTests.cs

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,51 @@ public void Search_ProvidesLocalizationKeyForMatchReason()
264264
Assert.AreEqual("Matched author", results[0].MatchReason);
265265
}
266266

267+
[TestMethod]
268+
public void Search_MatchesPersonalAliasAndNotes()
269+
{
270+
var documents = new[]
271+
{
272+
new ModSearchDocument("Example.Mod", "Example Mod")
273+
{
274+
Alias = "Daily run helper",
275+
Notes = "Disable before multiplayer"
276+
}
277+
};
278+
279+
var aliasResults = ModSearchRules.Search(documents, "Daily run helper");
280+
var noteResults = ModSearchRules.Search(documents, "multiplayer");
281+
282+
Assert.AreEqual(BmmText.SearchMatchAlias, aliasResults[0].MatchReasonKey);
283+
Assert.AreEqual(BmmText.SearchMatchNotes, noteResults[0].MatchReasonKey);
284+
}
285+
286+
[TestMethod]
287+
public void ModAnnotations_NormalizeWhitespaceAndDiscardEmptyEntries()
288+
{
289+
var annotations = ModAnnotationRules.NormalizeDictionary(new Dictionary<string, ModAnnotation>
290+
{
291+
[" Example.Mod "] = new() { Alias = " Helper ", Notes = "line one\r\nline two " },
292+
["Empty.Mod"] = new()
293+
});
294+
295+
Assert.AreEqual(1, annotations.Count);
296+
Assert.AreEqual("Helper", annotations["Example.Mod"].Alias);
297+
Assert.AreEqual("line one\nline two", annotations["Example.Mod"].Notes);
298+
}
299+
300+
[TestMethod]
301+
public void ModAnnotations_LoadNormalizationPreservesFutureOrManuallyEditedLengths()
302+
{
303+
string longNotes = new('x', ModAnnotationRules.MaxNotesLength + 1);
304+
var annotations = ModAnnotationRules.NormalizeDictionary(new Dictionary<string, ModAnnotation>
305+
{
306+
["Example.Mod"] = new() { Notes = longNotes }
307+
});
308+
309+
Assert.AreEqual(longNotes, annotations["Example.Mod"].Notes);
310+
}
311+
267312
[TestMethod]
268313
public void Search_ToleratesTyposAndLookalikesForLongQueries()
269314
{
@@ -509,6 +554,14 @@ public void GetTopBarPresentation_UsesIconOnlyActions()
509554
Assert.AreEqual(ModdingScreenConstants.TopBarButtonCompactWidth, wide.ButtonWidth);
510555
}
511556

557+
[TestMethod]
558+
public void ShouldStackTopBar_UsesAvailableLocalizedTitleSpace()
559+
{
560+
Assert.IsTrue(ModdingScreenLayoutRules.ShouldStackTopBar(0f, 275f));
561+
Assert.IsTrue(ModdingScreenLayoutRules.ShouldStackTopBar(274f, 275f));
562+
Assert.IsFalse(ModdingScreenLayoutRules.ShouldStackTopBar(275f, 275f));
563+
}
564+
512565
[TestMethod]
513566
public void ShouldShowRowMoveButtons_PreservesGroupPickerOnNarrowRows()
514567
{
@@ -775,7 +828,11 @@ public void ProfileSaveStorage_RoundTripsCurrentSaveData()
775828
CurrentProfileIndex = 0,
776829
CustomGroups = new List<string> { "Bosses" },
777830
ModGroups = new Dictionary<string, string> { ["mod-a"] = "Bosses" },
778-
CollapsedGroups = new HashSet<string> { "Bosses" }
831+
CollapsedGroups = new HashSet<string> { "Bosses" },
832+
ModAnnotations = new Dictionary<string, ModAnnotation>
833+
{
834+
["mod-a"] = new() { Alias = "My boss mod", Notes = "Load after BaseLib" }
835+
}
779836
};
780837

781838
bool wrote = ProfileSaveStorage.TryWrite(savePath, saveData, _ => { }, out string? error);
@@ -787,6 +844,8 @@ public void ProfileSaveStorage_RoundTripsCurrentSaveData()
787844
Assert.AreEqual("Bosses", loaded.CustomGroups[0]);
788845
Assert.AreEqual("Bosses", loaded.ModGroups["mod-a"]);
789846
Assert.IsTrue(loaded.CollapsedGroups.Contains("Bosses"));
847+
Assert.AreEqual("My boss mod", loaded.ModAnnotations["mod-a"].Alias);
848+
Assert.AreEqual("Load after BaseLib", loaded.ModAnnotations["mod-a"].Notes);
790849
}
791850
finally
792851
{
@@ -1020,8 +1079,8 @@ public void BuildCsv_EscapesExcelFriendlyModExportRows()
10201079
});
10211080

10221081
string expected = string.Join(Environment.NewLine,
1023-
"Mod Id,Name,Version,Enabled,Group,Workshop Link",
1024-
"Example.Mod,\"Example, Mod\",1.2.3,TRUE,\"QoL \"\"Core\"\"\",",
1082+
"Mod Id,Name,Version,Enabled,Group,Workshop Link,Alias,Notes",
1083+
"Example.Mod,\"Example, Mod\",1.2.3,TRUE,\"QoL \"\"Core\"\"\",,,",
10251084
string.Empty);
10261085

10271086
Assert.AreEqual(expected, csv);
@@ -1084,7 +1143,11 @@ public void BuildRows_UsesManifestInfoAndAssignedGroups()
10841143
}
10851144
},
10861145
new Dictionary<string, string> { ["Example.Mod"] = "Core" },
1087-
"Unassigned");
1146+
"Unassigned",
1147+
new Dictionary<string, ModAnnotation>
1148+
{
1149+
["Example.Mod"] = new() { Alias = "My helper", Notes = "Keep enabled" }
1150+
});
10881151

10891152
Assert.AreEqual(1, rows.Count);
10901153
Assert.AreEqual("Example.Mod", rows[0].ModId);
@@ -1093,6 +1156,8 @@ public void BuildRows_UsesManifestInfoAndAssignedGroups()
10931156
Assert.IsFalse(rows[0].Enabled);
10941157
Assert.AreEqual("Core", rows[0].Group);
10951158
Assert.AreEqual("https://steamcommunity.com/sharedfiles/filedetails/?id=3456789012", rows[0].WorkshopUrl);
1159+
Assert.AreEqual("My helper", rows[0].Alias);
1160+
Assert.AreEqual("Keep enabled", rows[0].Notes);
10961161
}
10971162
finally
10981163
{

BetterModMenu.Tests/ModListExportSecurityTests.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ public void BuildCsv_NeutralizesFormulaPrefixes_when_ManifestAndGroupValuesStart
3838
string csv = ModListExportBuilder.BuildCsv(rows);
3939

4040
string expected = string.Join(Environment.NewLine,
41-
"Mod Id,Name,Version,Enabled,Group,Workshop Link",
42-
"Formula.Mod,\"'=HYPERLINK(\"\"https://example.invalid\"\")\",'+1.2.3,TRUE,'-Injected Group,",
41+
"Mod Id,Name,Version,Enabled,Group,Workshop Link,Alias,Notes",
42+
"Formula.Mod,\"'=HYPERLINK(\"\"https://example.invalid\"\")\",'+1.2.3,TRUE,'-Injected Group,,,",
4343
string.Empty);
4444
Assert.AreEqual(expected, csv);
4545
}
@@ -61,13 +61,15 @@ public void BuildCsv_NeutralizesFormulaPrefixes_when_RawExportRowsStartWithContr
6161
Version = "1.0.0",
6262
Enabled = false,
6363
Group = "Safe Group",
64-
WorkshopUrl = "=HYPERLINK(\"https://example.invalid\")"
64+
WorkshopUrl = "=HYPERLINK(\"https://example.invalid\")",
65+
Alias = "+Alias",
66+
Notes = "=cmd|'/C calc'!A0\nsecond line"
6567
}
6668
});
6769

6870
string expected = string.Join(Environment.NewLine,
69-
"Mod Id,Name,Version,Enabled,Group,Workshop Link",
70-
"'\tTabbed.Mod,\"'\rCarriage Mod\",1.0.0,FALSE,Safe Group,\"'=HYPERLINK(\"\"https://example.invalid\"\")\"",
71+
"Mod Id,Name,Version,Enabled,Group,Workshop Link,Alias,Notes",
72+
"'\tTabbed.Mod,\"'\rCarriage Mod\",1.0.0,FALSE,Safe Group,\"'=HYPERLINK(\"\"https://example.invalid\"\")\",'+Alias,\"'=cmd|'/C calc'!A0\nsecond line\"",
7173
string.Empty);
7274
Assert.AreEqual(expected, csv);
7375
}

BetterModMenu.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"name": "Better Mod Menu",
44
"author": "Crunch",
55
"description": "Adds profile and grouping functionality to the Slay the Spire 2 mod manager.",
6-
"version": "1.0.1",
6+
"version": "1.1.0",
77
"min_game_version": "0.107.1",
88
"has_pck": false,
99
"has_dll": true,

Data/Localization.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ internal static class BmmText
5656
public const string DetailSearchMatchTooltip = "DETAIL.SEARCH_MATCH.TOOLTIP";
5757
public const string DetailGameplayBadge = "DETAIL.GAMEPLAY.BADGE";
5858
public const string DetailNoMatchingMods = "DETAIL.NO_MATCHING_MODS";
59+
public const string DetailAnnotation = "DETAIL.ANNOTATION";
60+
public const string DetailAnnotationTooltip = "DETAIL.ANNOTATION.TOOLTIP";
5961
public const string SearchMatchModId = "SEARCH.MATCH.MOD_ID";
6062
public const string SearchMatchModName = "SEARCH.MATCH.MOD_NAME";
6163
public const string SearchMatchAuthor = "SEARCH.MATCH.AUTHOR";
@@ -66,6 +68,8 @@ internal static class BmmText
6668
public const string SearchMatchWorkshopLink = "SEARCH.MATCH.WORKSHOP_LINK";
6769
public const string SearchMatchDescription = "SEARCH.MATCH.DESCRIPTION";
6870
public const string SearchMatchDependency = "SEARCH.MATCH.DEPENDENCY";
71+
public const string SearchMatchAlias = "SEARCH.MATCH.ALIAS";
72+
public const string SearchMatchNotes = "SEARCH.MATCH.NOTES";
6973
public const string DialogLoadBackupTitle = "DIALOG.LOAD_BACKUP.TITLE";
7074
public const string DialogLoadBackupBody = "DIALOG.LOAD_BACKUP.BODY";
7175
public const string DialogLoadBackupBodyManyFormat = "DIALOG.LOAD_BACKUP.BODY_MANY_FORMAT";
@@ -76,6 +80,14 @@ internal static class BmmText
7680
public const string DialogRenameGroupHelp = "DIALOG.RENAME_GROUP.HELP";
7781
public const string DialogRenameProfileTitle = "DIALOG.RENAME_PROFILE.TITLE";
7882
public const string DialogRenameProfileHelp = "DIALOG.RENAME_PROFILE.HELP";
83+
public const string DialogAnnotationTitleFormat = "DIALOG.ANNOTATION.TITLE_FORMAT";
84+
public const string DialogAnnotationAlias = "DIALOG.ANNOTATION.ALIAS";
85+
public const string DialogAnnotationAliasPlaceholder = "DIALOG.ANNOTATION.ALIAS_PLACEHOLDER";
86+
public const string DialogAnnotationNotes = "DIALOG.ANNOTATION.NOTES";
87+
public const string DialogAnnotationNotesPlaceholder = "DIALOG.ANNOTATION.NOTES_PLACEHOLDER";
88+
public const string DialogAnnotationHelp = "DIALOG.ANNOTATION.HELP";
89+
public const string AnnotationSaveFailedTitle = "ANNOTATION.SAVE_FAILED.TITLE";
90+
public const string AnnotationSaveFailedMessageFormat = "ANNOTATION.SAVE_FAILED.MESSAGE_FORMAT";
7991
public const string DialogCloudFolderTitle = "DIALOG.CLOUD_FOLDER.TITLE";
8092
public const string DialogCloudFolderHelp = "DIALOG.CLOUD_FOLDER.HELP";
8193
public const string LogCopyAll = "LOG.COPY_ALL";

Data/ModAnnotationRules.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
namespace BetterModMenu.Data;
2+
3+
internal static class ModAnnotationRules
4+
{
5+
public const int MaxAliasLength = 120;
6+
public const int MaxNotesLength = 4000;
7+
8+
public static bool TryNormalize(string? alias, string? notes, out ModAnnotation annotation, out string? error)
9+
{
10+
string normalizedAlias = (alias ?? string.Empty).Trim();
11+
string normalizedNotes = (notes ?? string.Empty)
12+
.Replace("\r\n", "\n", StringComparison.Ordinal)
13+
.Replace('\r', '\n')
14+
.Trim();
15+
16+
if (normalizedAlias.Length > MaxAliasLength)
17+
{
18+
annotation = new ModAnnotation();
19+
error = $"Alias must be {MaxAliasLength} characters or fewer.";
20+
return false;
21+
}
22+
23+
if (normalizedNotes.Length > MaxNotesLength)
24+
{
25+
annotation = new ModAnnotation();
26+
error = $"Notes must be {MaxNotesLength} characters or fewer.";
27+
return false;
28+
}
29+
30+
annotation = new ModAnnotation { Alias = normalizedAlias, Notes = normalizedNotes };
31+
error = null;
32+
return true;
33+
}
34+
35+
public static Dictionary<string, ModAnnotation> NormalizeDictionary(Dictionary<string, ModAnnotation>? annotations)
36+
{
37+
var normalized = new Dictionary<string, ModAnnotation>(StringComparer.Ordinal);
38+
if (annotations == null)
39+
return normalized;
40+
41+
foreach ((string modId, ModAnnotation? annotation) in annotations)
42+
{
43+
if (string.IsNullOrWhiteSpace(modId) || annotation == null)
44+
continue;
45+
46+
var value = new ModAnnotation
47+
{
48+
Alias = (annotation.Alias ?? string.Empty).Trim(),
49+
Notes = (annotation.Notes ?? string.Empty)
50+
.Replace("\r\n", "\n", StringComparison.Ordinal)
51+
.Replace('\r', '\n')
52+
.Trim()
53+
};
54+
55+
if (!string.IsNullOrEmpty(value.Alias) || !string.IsNullOrEmpty(value.Notes))
56+
normalized[modId.Trim()] = value;
57+
}
58+
59+
return normalized;
60+
}
61+
}

Data/ModListExportBuilder.cs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,24 @@ internal sealed class ModListExportRow
1818
public bool Enabled { get; init; }
1919
public string Group { get; init; } = string.Empty;
2020
public string WorkshopUrl { get; init; } = string.Empty;
21+
public string Alias { get; init; } = string.Empty;
22+
public string Notes { get; init; } = string.Empty;
2123
}
2224

2325
internal static class ModListExportBuilder
2426
{
2527
public const string ErrorNoExportDirectory = "No export directory was provided.";
2628

27-
private static readonly string[] Header = ["Mod Id", "Name", "Version", "Enabled", "Group", "Workshop Link"];
29+
private static readonly string[] Header = ["Mod Id", "Name", "Version", "Enabled", "Group", "Workshop Link", "Alias", "Notes"];
2830

2931
public static List<ModListExportRow> BuildRows(
3032
IEnumerable<InstalledModExportInput> mods,
3133
IReadOnlyDictionary<string, string> assignedGroups,
32-
string unassignedGroup)
34+
string unassignedGroup,
35+
IReadOnlyDictionary<string, ModAnnotation>? annotations = null)
3336
{
3437
return mods
35-
.Select(mod => BuildRow(mod, assignedGroups, unassignedGroup))
38+
.Select(mod => BuildRow(mod, assignedGroups, unassignedGroup, annotations))
3639
.OrderBy(row => row.Group, StringComparer.OrdinalIgnoreCase)
3740
.ThenBy(row => string.IsNullOrWhiteSpace(row.Name) ? row.ModId : row.Name, StringComparer.OrdinalIgnoreCase)
3841
.ToList();
@@ -51,7 +54,9 @@ public static string BuildCsv(IEnumerable<ModListExportRow> rows)
5154
row.Version,
5255
row.Enabled ? "TRUE" : "FALSE",
5356
row.Group,
54-
row.WorkshopUrl);
57+
row.WorkshopUrl,
58+
row.Alias,
59+
row.Notes);
5560
}
5661

5762
return builder.ToString();
@@ -88,13 +93,17 @@ public static string BuildExportFileName(DateTimeOffset timestamp)
8893
private static ModListExportRow BuildRow(
8994
InstalledModExportInput mod,
9095
IReadOnlyDictionary<string, string> assignedGroups,
91-
string unassignedGroup)
96+
string unassignedGroup,
97+
IReadOnlyDictionary<string, ModAnnotation>? annotations)
9298
{
9399
ModManifestInfo manifestInfo = new();
94100
if (!string.IsNullOrWhiteSpace(mod.ManifestPath) && File.Exists(mod.ManifestPath))
95101
ManifestScanner.TryReadManifestInfo(mod.ManifestPath, mod.ModId, out manifestInfo);
96102

97103
string modId = string.IsNullOrWhiteSpace(manifestInfo.Id) ? mod.ModId : manifestInfo.Id;
104+
ModAnnotation annotation = annotations != null && annotations.TryGetValue(modId, out ModAnnotation? value)
105+
? value
106+
: new ModAnnotation();
98107
return new ModListExportRow
99108
{
100109
ModId = modId,
@@ -104,7 +113,9 @@ private static ModListExportRow BuildRow(
104113
Group = assignedGroups.TryGetValue(modId, out string? group) && !string.IsNullOrWhiteSpace(group)
105114
? group
106115
: unassignedGroup,
107-
WorkshopUrl = mod.WorkshopUrl
116+
WorkshopUrl = mod.WorkshopUrl,
117+
Alias = annotation.Alias,
118+
Notes = annotation.Notes
108119
};
109120
}
110121

Data/ModSearchRules.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ internal sealed class ModSearchDocument(string modId, string name)
99
public string Name { get; } = name;
1010
public string Author { get; init; } = string.Empty;
1111
public string Description { get; init; } = string.Empty;
12+
public string Alias { get; init; } = string.Empty;
13+
public string Notes { get; init; } = string.Empty;
1214
public string Version { get; init; } = string.Empty;
1315
public IReadOnlyList<string> Dependencies { get; init; } = Array.Empty<string>();
1416
public string Group { get; init; } = string.Empty;
@@ -99,13 +101,15 @@ private static IReadOnlyList<SearchField> BuildFields(ModSearchDocument document
99101
{
100102
new("id", document.ModId, ExactIdOrNameScore, PrefixIdOrNameScore, FuzzyIdScore, IncludeContainsForShortQuery: true),
101103
new("name", document.Name, ExactIdOrNameScore, PrefixIdOrNameScore, FuzzyNameScore, IncludeContainsForShortQuery: true),
104+
new("alias", document.Alias, ExactIdOrNameScore, PrefixIdOrNameScore, FuzzyNameScore, IncludeContainsForShortQuery: true),
102105
new("author", document.Author, MetadataScore, MetadataScore - 40, MetadataScore - 90, IncludeContainsForShortQuery: false),
103106
new("version", document.Version, MetadataScore, MetadataScore - 40, 0, IncludeContainsForShortQuery: false),
104107
new("group", document.Group, MetadataScore, MetadataScore - 40, MetadataScore - 120, IncludeContainsForShortQuery: false),
105108
new("state", document.Enabled ? "enabled" : "disabled", MetadataScore, MetadataScore - 40, 0, IncludeContainsForShortQuery: false),
106109
new("Steam Workshop id", document.WorkshopId, WorkshopScore, WorkshopScore - 40, 0, IncludeContainsForShortQuery: true),
107110
new("Steam Workshop link", document.WorkshopUrl, WorkshopScore - 80, WorkshopScore - 100, 0, IncludeContainsForShortQuery: true),
108-
new("description", document.Description, DescriptionScore, DescriptionScore - 40, DescriptionScore - 80, IncludeContainsForShortQuery: false)
111+
new("description", document.Description, DescriptionScore, DescriptionScore - 40, DescriptionScore - 80, IncludeContainsForShortQuery: false),
112+
new("notes", document.Notes, DescriptionScore, DescriptionScore - 40, DescriptionScore - 80, IncludeContainsForShortQuery: false)
109113
};
110114

111115
foreach (string dependency in document.Dependencies)
@@ -225,13 +229,15 @@ private static SearchReason BuildReason(SearchField field)
225229
{
226230
"id" => new SearchReason(BmmText.SearchMatchModId, "Matched mod id"),
227231
"name" => new SearchReason(BmmText.SearchMatchModName, "Matched mod name"),
232+
"alias" => new SearchReason(BmmText.SearchMatchAlias, "Matched alias"),
228233
"author" => new SearchReason(BmmText.SearchMatchAuthor, "Matched author"),
229234
"version" => new SearchReason(BmmText.SearchMatchVersion, "Matched version"),
230235
"group" => new SearchReason(BmmText.SearchMatchGroup, "Matched group"),
231236
"state" => new SearchReason(BmmText.SearchMatchState, "Matched state"),
232237
"Steam Workshop id" => new SearchReason(BmmText.SearchMatchWorkshopId, "Matched Steam Workshop id"),
233238
"Steam Workshop link" => new SearchReason(BmmText.SearchMatchWorkshopLink, "Matched Steam Workshop link"),
234239
"description" => new SearchReason(BmmText.SearchMatchDescription, "Matched description"),
240+
"notes" => new SearchReason(BmmText.SearchMatchNotes, "Matched notes"),
235241
"dependency" => new SearchReason(BmmText.SearchMatchDependency, "Matched dependency"),
236242
_ => new SearchReason(string.Empty, "Matched " + field.Name)
237243
};

0 commit comments

Comments
 (0)