diff --git a/Hasheous.sln b/Hasheous.sln index 39d3bb1e..b1d27e3d 100644 --- a/Hasheous.sln +++ b/Hasheous.sln @@ -13,32 +13,90 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "hasheous-lib", "hasheous-li EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "service-host", "service-host\service-host.csproj", "{EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "hasheous-lib.Tests", "hasheous-lib.Tests\hasheous-lib.Tests.csproj", "{48D0391E-5FB8-4508-A850-C96A814864CF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {37509913-50FD-4865-888A-F5ABBE6C5D05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {37509913-50FD-4865-888A-F5ABBE6C5D05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37509913-50FD-4865-888A-F5ABBE6C5D05}.Debug|x64.ActiveCfg = Debug|Any CPU + {37509913-50FD-4865-888A-F5ABBE6C5D05}.Debug|x64.Build.0 = Debug|Any CPU + {37509913-50FD-4865-888A-F5ABBE6C5D05}.Debug|x86.ActiveCfg = Debug|Any CPU + {37509913-50FD-4865-888A-F5ABBE6C5D05}.Debug|x86.Build.0 = Debug|Any CPU {37509913-50FD-4865-888A-F5ABBE6C5D05}.Release|Any CPU.ActiveCfg = Release|Any CPU {37509913-50FD-4865-888A-F5ABBE6C5D05}.Release|Any CPU.Build.0 = Release|Any CPU + {37509913-50FD-4865-888A-F5ABBE6C5D05}.Release|x64.ActiveCfg = Release|Any CPU + {37509913-50FD-4865-888A-F5ABBE6C5D05}.Release|x64.Build.0 = Release|Any CPU + {37509913-50FD-4865-888A-F5ABBE6C5D05}.Release|x86.ActiveCfg = Release|Any CPU + {37509913-50FD-4865-888A-F5ABBE6C5D05}.Release|x86.Build.0 = Release|Any CPU {D708CF76-505B-4954-911F-A535F5E18047}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D708CF76-505B-4954-911F-A535F5E18047}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D708CF76-505B-4954-911F-A535F5E18047}.Debug|x64.ActiveCfg = Debug|Any CPU + {D708CF76-505B-4954-911F-A535F5E18047}.Debug|x64.Build.0 = Debug|Any CPU + {D708CF76-505B-4954-911F-A535F5E18047}.Debug|x86.ActiveCfg = Debug|Any CPU + {D708CF76-505B-4954-911F-A535F5E18047}.Debug|x86.Build.0 = Debug|Any CPU {D708CF76-505B-4954-911F-A535F5E18047}.Release|Any CPU.ActiveCfg = Release|Any CPU {D708CF76-505B-4954-911F-A535F5E18047}.Release|Any CPU.Build.0 = Release|Any CPU + {D708CF76-505B-4954-911F-A535F5E18047}.Release|x64.ActiveCfg = Release|Any CPU + {D708CF76-505B-4954-911F-A535F5E18047}.Release|x64.Build.0 = Release|Any CPU + {D708CF76-505B-4954-911F-A535F5E18047}.Release|x86.ActiveCfg = Release|Any CPU + {D708CF76-505B-4954-911F-A535F5E18047}.Release|x86.Build.0 = Release|Any CPU {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Debug|x64.ActiveCfg = Debug|Any CPU + {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Debug|x64.Build.0 = Debug|Any CPU + {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Debug|x86.ActiveCfg = Debug|Any CPU + {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Debug|x86.Build.0 = Debug|Any CPU {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Release|Any CPU.ActiveCfg = Release|Any CPU {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Release|Any CPU.Build.0 = Release|Any CPU + {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Release|x64.ActiveCfg = Release|Any CPU + {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Release|x64.Build.0 = Release|Any CPU + {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Release|x86.ActiveCfg = Release|Any CPU + {E925C33C-E514-4351-B709-13FFBF3F6CC9}.Release|x86.Build.0 = Release|Any CPU {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Debug|x64.ActiveCfg = Debug|Any CPU + {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Debug|x64.Build.0 = Debug|Any CPU + {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Debug|x86.ActiveCfg = Debug|Any CPU + {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Debug|x86.Build.0 = Debug|Any CPU {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Release|Any CPU.ActiveCfg = Release|Any CPU {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Release|Any CPU.Build.0 = Release|Any CPU + {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Release|x64.ActiveCfg = Release|Any CPU + {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Release|x64.Build.0 = Release|Any CPU + {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Release|x86.ActiveCfg = Release|Any CPU + {DCDE72D0-5B03-4B5C-B85C-DCFEA3A93119}.Release|x86.Build.0 = Release|Any CPU {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Debug|x64.ActiveCfg = Debug|Any CPU + {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Debug|x64.Build.0 = Debug|Any CPU + {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Debug|x86.ActiveCfg = Debug|Any CPU + {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Debug|x86.Build.0 = Debug|Any CPU {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Release|Any CPU.ActiveCfg = Release|Any CPU {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Release|Any CPU.Build.0 = Release|Any CPU + {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Release|x64.ActiveCfg = Release|Any CPU + {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Release|x64.Build.0 = Release|Any CPU + {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Release|x86.ActiveCfg = Release|Any CPU + {EE31901D-72C9-48DF-B6A6-FDDBABE1F01C}.Release|x86.Build.0 = Release|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Debug|x64.Build.0 = Debug|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Debug|x86.Build.0 = Debug|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Release|Any CPU.Build.0 = Release|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Release|x64.ActiveCfg = Release|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Release|x64.Build.0 = Release|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Release|x86.ActiveCfg = Release|Any CPU + {48D0391E-5FB8-4508-A850-C96A814864CF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/hasheous-lib.Tests/UnitTest1.cs b/hasheous-lib.Tests/UnitTest1.cs new file mode 100644 index 00000000..2ba89bd6 --- /dev/null +++ b/hasheous-lib.Tests/UnitTest1.cs @@ -0,0 +1,116 @@ +using hasheous_server.Classes; + +namespace hasheous_lib.Tests; + +public class GetSearchCandidatesTests +{ + private static List GetCandidates(string name) + { + return DataObjects.GetSearchCandidates(name); + } + + [Theory] + [InlineData("The Legend of Zelda", "The Legend of Zelda", "Legend of Zelda", "Legend of Zelda, The")] + [InlineData("Legend of Zelda, The", "Legend of Zelda, The", "Legend of Zelda", "The Legend of Zelda")] + [InlineData("Final Fantasy IV", "Final Fantasy IV", "Final Fantasy 4", "Final Fantasy IV")] + [InlineData("Resident Evil - Code: Veronica", "Resident Evil - Code: Veronica", "Resident Evil: Code: Veronica", "Resident Evil - Code: Veronica")] + [InlineData("Sonic (USA)", "Sonic (USA)", "Sonic", "Sonic (USA)")] + [InlineData("Mega Man v1.2", "Mega Man v1.2", "Mega Man", "Mega Man v1.2")] + [InlineData("Street Fighter Rev A", "Street Fighter Rev A", "Street Fighter", "Street Fighter Rev A")] + public void GeneratesExpectedCandidates(string input, string expected1, string expected2, string expected3) + { + List candidates = GetCandidates(input); + + Assert.Contains(expected1, candidates); + Assert.Contains(expected2, candidates); + Assert.Contains(expected3, candidates); + } + + [Fact] + public void ReturnsEmptyListForBlankName() + { + List candidates = GetCandidates(" "); + Assert.Empty(candidates); + } + + [Theory] + [InlineData("Resident Evil - Code: Veronica", "Resident Evil Code: Veronica")] + [InlineData("Metal Gear Solid: The Twin Snakes", "Metal Gear Solid The Twin Snakes")] + [InlineData("Prince of Persia - The Sands of Time", "Prince of Persia The Sands of Time")] + public void DropsDelimitersCorrectly(string input, string expectedWithDelimiterDrop) + { + List candidates = GetCandidates(input); + + Assert.Contains(expectedWithDelimiterDrop, candidates); + } + + [Theory] + [InlineData("Game 1", "Game One")] + [InlineData("Mega Man 2", "Mega Man Two")] + [InlineData("Final Fantasy VII", "Final Fantasy VII", "Final Fantasy 7", "Final Fantasy Seven")] + [InlineData("Take 2", "Take Two")] + [InlineData("Top 10", "Top Ten")] + [InlineData("The Room 3", "The Room Three")] + public void ConvertsNumbersToWords(string input, params string[] expectedCandidates) + { + List candidates = GetCandidates(input); + + foreach (string expected in expectedCandidates) + { + Assert.Contains(expected, candidates); + } + } + + [Theory] + [InlineData("Game One", "Game 1")] + [InlineData("Mega Man Two", "Mega Man 2")] + [InlineData("Final Fantasy Seven", "Final Fantasy 7")] + [InlineData("Take Twenty One", "Take 21")] + [InlineData("Top Ten", "Top 10")] + [InlineData("The Room Three", "The Room 3")] + public void ConvertsWordsToNumbers(string input, string expectedCandidate) + { + List candidates = GetCandidates(input); + + Assert.Contains(expectedCandidate, candidates); + } + + [Theory] + [InlineData("Resident Evil 5", "Resident Evil Five")] + [InlineData("Portal 2", "Portal Two")] + [InlineData("Call of Duty Modern Warfare 3", "Call of Duty Modern Warfare Three")] + public void BidirectionalNumberConversion(string input, string expectedCandidate) + { + List candidates = GetCandidates(input); + + Assert.Contains(expectedCandidate, candidates); + } + + [Theory] + [InlineData("Star Wars: Episode 1 - Racer", "Star Wars: Episode I - Racer")] + [InlineData("Game 2", "Game II")] + [InlineData("Final Fantasy 7", "Final Fantasy VII")] + [InlineData("Chapter 3", "Chapter III")] + [InlineData("Volume 5", "Volume V")] + [InlineData("Part 10", "Part X")] + public void ConvertsNumbersToRomanNumerals(string input, string expectedCandidate) + { + List candidates = GetCandidates(input); + + Assert.Contains(expectedCandidate, candidates); + } + + [Theory] + [InlineData("Star Wars: Episode I - Racer", "Star Wars: Episode 1 - Racer")] + [InlineData("Game II", "Game 2")] + [InlineData("Final Fantasy VII", "Final Fantasy 7")] + [InlineData("Chapter III", "Chapter 3")] + [InlineData("Volume V", "Volume 5")] + [InlineData("Part X", "Part 10")] + public void ConvertsRomanNumeralsToNumbers(string input, string expectedCandidate) + { + List candidates = GetCandidates(input); + + Assert.Contains(expectedCandidate, candidates); + } +} \ No newline at end of file diff --git a/hasheous-lib.Tests/hasheous-lib.Tests.csproj b/hasheous-lib.Tests/hasheous-lib.Tests.csproj new file mode 100644 index 00000000..d6f96e72 --- /dev/null +++ b/hasheous-lib.Tests/hasheous-lib.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + hasheous_lib.Tests + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hasheous-lib/Classes/Common.cs b/hasheous-lib/Classes/Common.cs index 28363f0a..2de87fe4 100644 --- a/hasheous-lib/Classes/Common.cs +++ b/hasheous-lib/Classes/Common.cs @@ -144,6 +144,200 @@ public static int RomanToInt(string roman) } } + public class Numbers + { + private static readonly Dictionary NumberWords = new Dictionary + { + { 0, "Zero" }, + { 1, "One" }, + { 2, "Two" }, + { 3, "Three" }, + { 4, "Four" }, + { 5, "Five" }, + { 6, "Six" }, + { 7, "Seven" }, + { 8, "Eight" }, + { 9, "Nine" }, + { 10, "Ten" }, + { 11, "Eleven" }, + { 12, "Twelve" }, + { 13, "Thirteen" }, + { 14, "Fourteen" }, + { 15, "Fifteen" }, + { 16, "Sixteen" }, + { 17, "Seventeen" }, + { 18, "Eighteen" }, + { 19, "Nineteen" }, + { 20, "Twenty" }, + { 30, "Thirty" }, + { 40, "Forty" }, + { 50, "Fifty" }, + { 60, "Sixty" }, + { 70, "Seventy" }, + { 80, "Eighty" }, + { 90, "Ninety" }, + { 100, "Hundred" }, + { 1000, "Thousand" }, + { 1000000, "Million" }, + { 1000000000, "Billion" } + }; + + private static readonly Dictionary WordsToNumber = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Zero", 0 }, + { "One", 1 }, + { "Two", 2 }, + { "Three", 3 }, + { "Four", 4 }, + { "Five", 5 }, + { "Six", 6 }, + { "Seven", 7 }, + { "Eight", 8 }, + { "Nine", 9 }, + { "Ten", 10 }, + { "Eleven", 11 }, + { "Twelve", 12 }, + { "Thirteen", 13 }, + { "Fourteen", 14 }, + { "Fifteen", 15 }, + { "Sixteen", 16 }, + { "Seventeen", 17 }, + { "Eighteen", 18 }, + { "Nineteen", 19 }, + { "Twenty", 20 }, + { "Thirty", 30 }, + { "Forty", 40 }, + { "Fifty", 50 }, + { "Sixty", 60 }, + { "Seventy", 70 }, + { "Eighty", 80 }, + { "Ninety", 90 }, + { "Hundred", 100 }, + { "Thousand", 1000 }, + { "Million", 1000000 }, + { "Billion", 1000000000 } + }; + + /// + /// Converts a number to its English word representation. + /// + /// The number to convert (0 to 999,999,999). + /// The English word representation of the number. + public static string NumberToWords(int number) + { + if (number < 0 || number > 999999999) + throw new ArgumentOutOfRangeException(nameof(number), "Value must be in the range 0-999,999,999."); + + if (number == 0) + return "Zero"; + + if (NumberWords.TryGetValue(number, out var word)) + return word; + + List parts = new List(); + + // Billions + int billions = number / 1000000000; + if (billions > 0) + { + parts.Add(NumberToWords(billions) + " Billion"); + number %= 1000000000; + } + + // Millions + int millions = number / 1000000; + if (millions > 0) + { + parts.Add(NumberToWords(millions) + " Million"); + number %= 1000000; + } + + // Thousands + int thousands = number / 1000; + if (thousands > 0) + { + parts.Add(NumberToWords(thousands) + " Thousand"); + number %= 1000; + } + + // Hundreds + int hundreds = number / 100; + if (hundreds > 0) + { + parts.Add(NumberWords[hundreds] + " Hundred"); + number %= 100; + } + + // Ones and Tens + if (number > 0) + { + if (number < 20) + { + parts.Add(NumberWords[number]); + } + else + { + int tens = number / 10; + int ones = number % 10; + string tensWord = NumberWords[tens * 10]; + if (ones > 0) + { + parts.Add(tensWord + " " + NumberWords[ones]); + } + else + { + parts.Add(tensWord); + } + } + } + + return string.Join(" ", parts); + } + + /// + /// Converts English number words to an integer. + /// Handles written forms like "Twenty One", "One Hundred Thirty Four", etc. + /// + /// The English words representing a number. + /// The integer representation, or null if conversion fails. + public static int? WordsToNumbers(string words) + { + if (string.IsNullOrWhiteSpace(words)) + return null; + + // Normalize spacing and remove extra whitespace + words = Regex.Replace(words.Trim(), @"\s+", " "); + string[] tokens = words.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + int result = 0; + int current = 0; + + foreach (string token in tokens) + { + if (!WordsToNumber.TryGetValue(token, out int value)) + return null; // Invalid token + + if (value >= 1000) + { + current += result; + result = current * value; + current = 0; + } + else if (value == 100) + { + current *= value; + } + else + { + current += value; + } + } + + result += current; + return result >= 0 ? result : null; + } + } + public class hashObject { public hashObject() diff --git a/hasheous-lib/Classes/DataObjects.cs b/hasheous-lib/Classes/DataObjects.cs index 71cba228..b3afc2c9 100644 --- a/hasheous-lib/Classes/DataObjects.cs +++ b/hasheous-lib/Classes/DataObjects.cs @@ -400,6 +400,113 @@ public async Task GetDataObjects(DataObjectType objectType, int return objectsList; } + public async Task GetDuplicateDataObjects(DataObjectType objectType, long id, int pageNumber = 0, int pageSize = 0) + { + DataObjectsList dataObjectList = new DataObjectsList + { + Objects = new List(), + Count = 0, + PageNumber = pageNumber, + PageSize = pageSize, + TotalPages = 0 + }; + + DataObjectItem? originalItem = await GetDataObject(objectType, id, true, true, true); + + if (originalItem == null) + { + return dataObjectList; + } + + Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString); + string sql; + List names = [.. GetSearchCandidates(originalItem.Name)]; + + + Dictionary dbDict = new Dictionary{ + { "objecttype", objectType }, + { "id", id } + }; + + string nameFragment = ""; + for (int i = 0; i < names.Count; i++) + { + dbDict.Add("name" + i, names[i]); + if (i > 0) + { + nameFragment += " OR "; + } + nameFragment += "DataObject.`Name` = @name" + i; + } + + switch (objectType) + { + case DataObjectType.Game: + // looking for items with the same name and platform + sql = $"SELECT * FROM DataObject LEFT JOIN DataObject_Attributes ON DataObject.`Id` = DataObject_Attributes.`DataObjectId` AND DataObject_Attributes.`AttributeName` = 4 WHERE DataObject.`ObjectType` = @objecttype AND DataObject.`Id` <> @id AND ({nameFragment}) AND DataObject_Attributes.`AttributeRelation` = @platformid;"; + AttributeItem? platformAttribute = originalItem.Attributes?.FirstOrDefault(a => a.attributeName == AttributeItem.AttributeName.Platform); + if (platformAttribute == null) + { + return dataObjectList; + } + DataObjectItem? platformObject = (DataObjectItem?)platformAttribute.Value; + + if (platformObject == null) + { + return dataObjectList; + } + + long platformId = platformObject.Id; + dbDict.Add("platformid", platformId); + break; + + default: + // looking for items with the same name + sql = $"SELECT * FROM DataObject WHERE DataObject.`ObjectType` = @objecttype AND DataObject.`Id` <> @id AND ({nameFragment});"; + break; + } + + DataTable data = db.ExecuteCMD(sql, dbDict); + + List DataObjects = new List(); + + // compile data for return + int pageOffset = pageSize * (pageNumber - 1); + for (int i = pageOffset; i < data.Rows.Count; i++) + { + if (pageNumber != 0 && pageSize != 0) + { + if (i >= (pageOffset + pageSize)) + { + break; + } + } + + Models.DataObjectItem item = await BuildDataObject( + objectType, + (long)data.Rows[i]["Id"], + data.Rows[i], + true, + false, + false + ); + + DataObjects.Add(item); + } + + float pageCount = (float)data.Rows.Count / (float)pageSize; + DataObjectsList objectsList = new DataObjectsList + { + Objects = DataObjects, + Count = data.Rows.Count, + PageNumber = pageNumber, + PageSize = pageSize, + TotalPages = (int)Math.Ceiling(pageCount) + }; + + return objectsList; + } + public async Task GetDataObject(long id) { Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString); @@ -551,6 +658,19 @@ private void UpdateDataObjectDate(long DataObjectId) { signatureItems = await GetSignatures(ObjectType, id); + List aliases = GetSearchCandidates((string)row["Name"]); + if (aliases.Count > 1) + { + AttributeItem aliasesAttribute = new AttributeItem + { + attributeName = AttributeItem.AttributeName.SearchAliases, + attributeType = AttributeItem.AttributeType.LongString, + attributeRelationType = DataObjectType.None, + Value = String.Join(", ", aliases) + }; + attributes.Add(aliasesAttribute); + } + // get extra attributes based on dataobjecttype switch (ObjectType) { @@ -2200,82 +2320,212 @@ private async Task _DataObjectMetadataSearch_Apply(DataObjectItem item, string l } } - private static List GetSearchCandidates(string GameName) + public static List GetSearchCandidates(string GameName) { - // remove version numbers from name - GameName = Regex.Replace(GameName, @"v(\d+\.)?(\d+\.)?(\*|\d+)$", "").Trim(); - GameName = Regex.Replace(GameName, @"Rev (\d+\.)?(\d+\.)?(\*|\d+)$", "").Trim(); + if (string.IsNullOrWhiteSpace(GameName)) + { + return new List(); + } + + List searchCandidates = new List(); - // assumption: no games have () in their titles so we'll remove them - int idx = GameName.IndexOf('('); - if (idx >= 0) + string NormalizeWhitespace(string value) { - GameName = GameName.Substring(0, idx); + return Regex.Replace(value, @"\s+", " ").Trim(); } - List SearchCandidates = new List(); - SearchCandidates.Add(GameName.Trim()); - if (GameName.Contains(" - ")) + string NormalizeDashes(string value) { - SearchCandidates.Add(GameName.Replace(" - ", ": ").Trim()); - SearchCandidates.Add(GameName.Substring(0, GameName.IndexOf(" - ")).Trim()); + return Regex.Replace(value, @"[\u2012\u2013\u2014\u2015\u2212]", "-"); } - if (GameName.Contains(": ")) + + void AddCandidate(string value) { - SearchCandidates.Add(GameName.Substring(0, GameName.IndexOf(": ")).Trim()); + string normalized = NormalizeWhitespace(value); + if (!string.IsNullOrWhiteSpace(normalized)) + { + searchCandidates.Add(normalized); + } } - // strip any leading "The " from the game name - if (GameName.StartsWith("The ", StringComparison.OrdinalIgnoreCase)) + string baseName = NormalizeWhitespace(GameName); + AddCandidate(baseName); + + string dashNormalized = NormalizeDashes(baseName); + if (!string.Equals(dashNormalized, baseName, StringComparison.Ordinal)) { - SearchCandidates.Add(GameName.Substring(4).Trim()); + AddCandidate(dashNormalized); } - // strip any ", The" from the end of the game name - if (GameName.EndsWith(", The", StringComparison.OrdinalIgnoreCase)) + // remove common trailing version/revision markers while keeping the original + string versionStripped = Regex.Replace(dashNormalized, @"\s*(?:v|ver\.?|version)\s*(\d+(?:\.\d+)*)\s*$", "", RegexOptions.IgnoreCase); + if (!string.Equals(versionStripped, dashNormalized, StringComparison.Ordinal)) { - SearchCandidates.Add(GameName.Substring(0, GameName.Length - 5).Trim()); + AddCandidate(versionStripped); } - // strip any leading "A " from the game name - if (GameName.StartsWith("A ", StringComparison.OrdinalIgnoreCase)) + string revisionStripped = Regex.Replace(dashNormalized, @"\s*(?:rev(?:ision)?\.?)(?:\s*[A-Za-z0-9]+)?\s*$", "", RegexOptions.IgnoreCase); + if (!string.Equals(revisionStripped, dashNormalized, StringComparison.Ordinal)) { - SearchCandidates.Add(GameName.Substring(2).Trim()); + AddCandidate(revisionStripped); } - // strip any leading "An " from the game name - if (GameName.StartsWith("An ", StringComparison.OrdinalIgnoreCase)) + // remove trailing bracketed metadata while keeping the original + string bracketStripped = Regex.Replace(dashNormalized, @"\s*[\(\[][^\)\]]+[\)\]]\s*$", "", RegexOptions.IgnoreCase); + if (!string.Equals(bracketStripped, dashNormalized, StringComparison.Ordinal)) { - SearchCandidates.Add(GameName.Substring(3).Trim()); + AddCandidate(bracketStripped); } - // add the original name as a candidate - SearchCandidates.Add(GameName); + void AddDelimiterVariants(string value) + { + if (value.Contains(" - ", StringComparison.Ordinal)) + { + AddCandidate(value.Replace(" - ", ": ")); + AddCandidate(value.Replace(" - ", " ")); + } + + if (value.Contains(": ", StringComparison.Ordinal)) + { + AddCandidate(value.Replace(": ", " ")); + } + } - // loop all candidates and convert roman numerals to numbers - List tempSearchCandidates = SearchCandidates.ToList(); - foreach (var candidate in tempSearchCandidates.Select((o, i) => new { Value = o, Index = i })) + void AddArticleVariants(string value) { - string? romanNumeral = Common.RomanNumerals.FindFirstRomanNumeral(candidate.Value); - if (!String.IsNullOrEmpty(romanNumeral)) + if (value.StartsWith("The ", StringComparison.OrdinalIgnoreCase)) { - string newCandidate = candidate.Value.Replace(romanNumeral, Common.RomanNumerals.RomanToInt(romanNumeral).ToString()); - if (candidate.Index + 1 == tempSearchCandidates.Count) - SearchCandidates.Add(newCandidate); // add a new candidate if the roman numeral is at the end - else - SearchCandidates.Insert(candidate.Index + 1, newCandidate); // insert a new candidate after the current one + string without = value.Substring(4).Trim(); + AddCandidate(without); + AddCandidate($"{without}, The"); + } + + if (value.StartsWith("A ", StringComparison.OrdinalIgnoreCase)) + { + string without = value.Substring(2).Trim(); + AddCandidate(without); + AddCandidate($"{without}, A"); + } + + if (value.StartsWith("An ", StringComparison.OrdinalIgnoreCase)) + { + string without = value.Substring(3).Trim(); + AddCandidate(without); + AddCandidate($"{without}, An"); + } + + if (value.EndsWith(", The", StringComparison.OrdinalIgnoreCase)) + { + string without = value.Substring(0, value.Length - 5).Trim(); + AddCandidate(without); + AddCandidate($"The {without}"); + } + + if (value.EndsWith(", A", StringComparison.OrdinalIgnoreCase)) + { + string without = value.Substring(0, value.Length - 3).Trim(); + AddCandidate(without); + AddCandidate($"A {without}"); + } + + if (value.EndsWith(", An", StringComparison.OrdinalIgnoreCase)) + { + string without = value.Substring(0, value.Length - 4).Trim(); + AddCandidate(without); + AddCandidate($"An {without}"); } } - // remove duplicates - SearchCandidates = SearchCandidates.Distinct().ToList(); + // expand with delimiter and article variants + foreach (string candidate in searchCandidates.ToList()) + { + AddDelimiterVariants(candidate); + AddArticleVariants(candidate); + } - // remove any empty candidates - SearchCandidates.RemoveAll(x => string.IsNullOrWhiteSpace(x)); + // convert roman numerals to numbers (token-based) + foreach (string candidate in searchCandidates.ToList()) + { + string romanConverted = Regex.Replace(candidate, @"\b[IVXLCDM]+\b", match => + { + return Common.RomanNumerals.RomanToInt(match.Value).ToString(); + }, RegexOptions.IgnoreCase); + + if (!string.Equals(romanConverted, candidate, StringComparison.Ordinal)) + { + AddCandidate(romanConverted); + } + } + + // convert numbers to roman numerals (token-based) + foreach (string candidate in searchCandidates.ToList()) + { + string numberToRoman = Regex.Replace(candidate, @"\b(\d+)\b", match => + { + if (int.TryParse(match.Groups[1].Value, out int num) && num >= 1 && num <= 3999) + { + return Common.RomanNumerals.IntToRoman(num); + } + return match.Value; + }); + + if (!string.Equals(numberToRoman, candidate, StringComparison.Ordinal)) + { + AddCandidate(numberToRoman); + } + } + + // convert numbers to English words and vice versa (token-based) + foreach (string candidate in searchCandidates.ToList()) + { + // Convert numbers to words + string numberToWords = Regex.Replace(candidate, @"\b(\d+)\b", match => + { + if (int.TryParse(match.Groups[1].Value, out int num) && num >= 0 && num <= 999999999) + { + return Common.Numbers.NumberToWords(num); + } + return match.Value; + }); + + if (!string.Equals(numberToWords, candidate, StringComparison.Ordinal)) + { + AddCandidate(numberToWords); + } + + // Convert English number words to numbers (look for sequences of number words) + string wordsToNumber = Regex.Replace(candidate, @"\b(?:Zero|One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten|Eleven|Twelve|Thirteen|Fourteen|Fifteen|Sixteen|Seventeen|Eighteen|Nineteen|Twenty|Thirty|Forty|Fifty|Sixty|Seventy|Eighty|Ninety|Hundred|Thousand|Million|Billion)(?:\s+(?:Zero|One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten|Eleven|Twelve|Thirteen|Fourteen|Fifteen|Sixteen|Seventeen|Eighteen|Nineteen|Twenty|Thirty|Forty|Fifty|Sixty|Seventy|Eighty|Ninety|Hundred|Thousand|Million|Billion))*\b", match => + { + var result = Common.Numbers.WordsToNumbers(match.Value); + return result.HasValue ? result.Value.ToString() : match.Value; + }, RegexOptions.IgnoreCase); + + if (!string.Equals(wordsToNumber, candidate, StringComparison.Ordinal)) + { + AddCandidate(wordsToNumber); + } + } + + // remove duplicates while preserving order + List distinctCandidates = new List(); + HashSet seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string candidate in searchCandidates) + { + string normalized = NormalizeWhitespace(candidate); + if (string.IsNullOrWhiteSpace(normalized)) + { + continue; + } + + if (seen.Add(normalized)) + { + distinctCandidates.Add(normalized); + } + } - Logging.Log(Logging.LogType.Information, "Import Game", "Search candidates: " + String.Join(", ", SearchCandidates)); + Logging.Log(Logging.LogType.Information, "Import Game", "Search candidates: " + String.Join(", ", distinctCandidates)); - return SearchCandidates; + return distinctCandidates; } public async Task GetDataObject(MetadataSources Source, string Endpoint, string Fields, string Query) diff --git a/hasheous-lib/Classes/Logging.cs b/hasheous-lib/Classes/Logging.cs index 50a45338..b8bd15a3 100644 --- a/hasheous-lib/Classes/Logging.cs +++ b/hasheous-lib/Classes/Logging.cs @@ -215,7 +215,13 @@ static public void SendReport(string progressItemKey, int? count, int? total, st { if (report != null) { - _ = report.SendAsync(progressItemKey, count, total, description, performETACalculation); + _ = report.SendAsync(progressItemKey, count, total, description, performETACalculation).ContinueWith(task => + { + if (task.IsFaulted) + { + Console.WriteLine($"[Logging.SendReport] Error sending report: {task.Exception?.InnerException?.Message}"); + } + }, TaskScheduler.Default); } } diff --git a/hasheous-lib/Classes/Maintenance.cs b/hasheous-lib/Classes/Maintenance.cs index 422475ac..c153dbb7 100644 --- a/hasheous-lib/Classes/Maintenance.cs +++ b/hasheous-lib/Classes/Maintenance.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Security.Cryptography.Xml; using HasheousClient.Models; namespace Classes @@ -141,6 +142,24 @@ public async Task RunDailyMaintenance() } Logging.Log(Logging.LogType.Information, "Maintenance", "Deleted " + affectedRows + " metadata mappings with source None."); } while (true); + + // delete all games that have no platform defined - these are likely to be incomplete entries that won't be fixed + Logging.Log(Logging.LogType.Information, "Maintenance", "Deleting games with no platform defined"); + sql = "SELECT DataObject.Id, DataObject.Name FROM DataObject LEFT JOIN (SELECT * FROM DataObject_Attributes WHERE AttributeName=@attributename) DOA ON DataObject.Id = DOA.DataObjectId WHERE DataObject.ObjectType=@objecttype AND DOA.AttributeRelation IS NULL;"; + dbDict = new Dictionary + { + { "objecttype", DataObjectType.Game }, + { "attributename", AttributeItem.AttributeName.Platform } + }; + DataTable gamesWithoutPlatform = await db.ExecuteCMDAsync(sql); + hasheous_server.Classes.DataObjects dataObjects = new hasheous_server.Classes.DataObjects(); + foreach (DataRow row in gamesWithoutPlatform.Rows) + { + long gameId = Convert.ToInt64(row["Id"]); + string gameName = row["Name"].ToString(); + Logging.Log(Logging.LogType.Information, "Maintenance", "Deleting game with no platform: " + gameName + " (ID: " + gameId + ")"); + dataObjects.DeleteDataObject(hasheous_server.Classes.DataObjects.DataObjectType.Game, gameId); + } } /// diff --git a/hasheous-lib/Classes/Report.cs b/hasheous-lib/Classes/Report.cs index 9ba7ce28..56249b1f 100644 --- a/hasheous-lib/Classes/Report.cs +++ b/hasheous-lib/Classes/Report.cs @@ -15,14 +15,23 @@ public Report(string reportingServerUrl, string processId, string correlationId) { this.processId = processId; this.correlationId = correlationId; - - this.httpClient.BaseAddress = new Uri(reportingServerUrl); + this.reportingServerUrl = reportingServerUrl; } - private HttpClient httpClient = new HttpClient(); + private static readonly HttpClient httpClient = new HttpClient(new SocketsHttpHandler + { + AllowAutoRedirect = false, + PooledConnectionLifetime = TimeSpan.FromSeconds(300), + }) + { + Timeout = TimeSpan.FromSeconds(30) + }; + + private static readonly SemaphoreSlim sendSemaphore = new SemaphoreSlim(1, 1); private string processId; private string correlationId; + private string reportingServerUrl; /// /// Shared instance of the report model used to aggregate reporting data across the host process. @@ -61,22 +70,31 @@ public async System.Threading.Tasks.Task SendAsync(string progressItemKey, int? } // send to reporting server if configured - if (this.httpClient.BaseAddress != null) + if (!string.IsNullOrEmpty(this.reportingServerUrl)) { - var jsonContent = System.Text.Json.JsonSerializer.Serialize(_reportModel); - var content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json"); - + // Acquire semaphore to serialize requests + await sendSemaphore.WaitAsync(); try { - string url = $"/api/v1/BackgroundTasks/{this.processId.ToString()}/{this.correlationId.ToString()}/report"; - Console.WriteLine($"Sending report to {httpClient.BaseAddress}{url}"); - var response = await httpClient.PostAsync(url, content); - response.EnsureSuccessStatusCode(); + var jsonContent = System.Text.Json.JsonSerializer.Serialize(_reportModel); + var content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json"); + + try + { + string url = $"{this.reportingServerUrl}/api/v1.0/BackgroundTasks/{this.processId}/{this.correlationId}/report"; + Console.WriteLine($"Sending report to {url}"); + var response = await httpClient.PostAsync(url, content); + response.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + // Log the error but do not throw + Console.WriteLine($"Failed to send report to server: {ex.Message}"); + } } - catch (Exception ex) + finally { - // Log the error but do not throw - Console.WriteLine($"Failed to send report to server: {ex.Message}"); + sendSemaphore.Release(); } } } diff --git a/hasheous-lib/Models/DataObjectItemModel.cs b/hasheous-lib/Models/DataObjectItemModel.cs index 064ca0eb..a6153d45 100644 --- a/hasheous-lib/Models/DataObjectItemModel.cs +++ b/hasheous-lib/Models/DataObjectItemModel.cs @@ -46,7 +46,8 @@ public enum AttributeName Public = 19, DumpFile = 20, Tags = 21, - AIDescription = 22 + AIDescription = 22, + SearchAliases = 23 } public long? Id { get; set; } diff --git a/hasheous/Controllers/V1.0/DataObjectController.cs b/hasheous/Controllers/V1.0/DataObjectController.cs index bd3b0317..aba61c83 100644 --- a/hasheous/Controllers/V1.0/DataObjectController.cs +++ b/hasheous/Controllers/V1.0/DataObjectController.cs @@ -15,7 +15,7 @@ namespace hasheous_server.Controllers.v1_0 [ApiController] [Route("api/v{version:apiVersion}/[controller]/")] [ApiVersion("1.0")] - [ApiExplorerSettings(IgnoreApi = true)] + [ApiExplorerSettings(IgnoreApi = false)] [Authorize] public class DataObjectsController : ControllerBase { @@ -635,6 +635,24 @@ public async Task GetSimilarDataObjects(Classes.DataObjects.DataO } } + [MapToApiVersion("1.0")] + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [AllowAnonymous] + [Route("{ObjectType}/{Id}/Duplicates")] + public async Task GetDuplicateDataObjects(Classes.DataObjects.DataObjectType ObjectType, long Id, int pageNumber = 0, int pageSize = 0) + { + hasheous_server.Classes.DataObjects DataObjects = new Classes.DataObjects(); + + hasheous_server.Models.DataObjectsList? objectsList; + + var user = await _userManager.GetUserAsync(User); + + objectsList = await DataObjects.GetDuplicateDataObjects(ObjectType, Id, pageNumber, pageSize); + + return Ok(objectsList); + } + [MapToApiVersion("1.0")] [HttpGet] [Authorize] diff --git a/hasheous/wwwroot/localisation/en.json b/hasheous/wwwroot/localisation/en.json index 97500cb6..7dd7525a 100644 --- a/hasheous/wwwroot/localisation/en.json +++ b/hasheous/wwwroot/localisation/en.json @@ -333,5 +333,8 @@ "similargame": "Similar Games", "similarcompany": "Similar Companies", "similarplatform": "Similar Platforms", - "servicetaskstatus": "Distributed Task Status" -} + "servicetaskstatus": "Distributed Task Status", + "possibleduplicates": "Possible Duplicates", + "mergeduplicates": "Merge Duplicates", + "searchaliases": "Search Aliases" +} \ No newline at end of file diff --git a/hasheous/wwwroot/pages/dataobjectdetail.html b/hasheous/wwwroot/pages/dataobjectdetail.html index b38e78e5..9c9594da 100644 --- a/hasheous/wwwroot/pages/dataobjectdetail.html +++ b/hasheous/wwwroot/pages/dataobjectdetail.html @@ -129,6 +129,15 @@

+ + + \ No newline at end of file diff --git a/hasheous/wwwroot/pages/dataobjectdetail.js b/hasheous/wwwroot/pages/dataobjectdetail.js index 8933d4ee..6c63c2b1 100644 --- a/hasheous/wwwroot/pages/dataobjectdetail.js +++ b/hasheous/wwwroot/pages/dataobjectdetail.js @@ -7,6 +7,7 @@ let showEditControls = false; let showMergeControls = false; let showRescanButton = false; let showMetadataSubmitButton = false; +let loadDuplicates = false; if (userProfile != null && userProfile.Roles != null) { switch (pageType) { @@ -15,6 +16,7 @@ if (userProfile != null && userProfile.Roles != null) { if (userProfile.Roles.includes('Moderator') || userProfile.Roles.includes('Admin')) { showEditControls = true; showMergeControls = true; + loadDuplicates = true; } // show metadata submit and rescan buttons to all signed in users @@ -23,6 +25,9 @@ if (userProfile != null && userProfile.Roles != null) { break; case "platform": + if (userProfile.Roles.includes('Moderator') || userProfile.Roles.includes('Admin')) { + loadDuplicates = true; + } case "app": if (userProfile.Roles.includes('Admin')) { showEditControls = true; @@ -99,6 +104,54 @@ document.getElementById('dataObjectMerge').addEventListener("click", function (e }); }); +let dataObjectMergeDuplicates = document.getElementById('dataObjectMergeDuplicates'); +dataObjectMergeDuplicates.addEventListener("click", async function (e) { + let selectedIds = Array.from(document.querySelectorAll('.duplicateCheckbox:checked')).map(checkbox => checkbox.getAttribute('data-id')); + if (selectedIds.length == 0) { + alert('Please select at least one duplicate to merge.'); + return; + } + + // disable the button and all checkboxes to prevent multiple submissions + dataObjectMergeDuplicates.setAttribute('disabled', 'disabled'); + document.querySelectorAll('.duplicateCheckbox').forEach(checkbox => checkbox.setAttribute('disabled', 'disabled')); + + // Create an array of promises for all merge operations + const mergePromises = selectedIds.map(element => { + return fetch('/api/v1/DataObjects/' + pageType + '/' + element + '/MergeObject?TargetId=' + getQueryString('id', 'int') + '&commit=true', { + method: 'GET' + }).then(async function (response) { + if (!response.ok) { + throw new Error('Failed to merge data objects'); + } + return response.json(); + }).then((success) => { + console.log(success); + if (success) { + // select the row with the matching data-id and remove it from the table + let row = document.querySelector('.duplicateRow[data-id="' + element + '"]'); + if (row) { + row.remove(); + } + } else { + alert('An error occurred while merging data objects. Please try again.'); + } + return success; + }).catch((error) => { + console.warn(error); + alert('An error occurred while merging data objects: ' + error.message); + return false; + }); + }); + + // Wait for all merge operations to complete + await Promise.all(mergePromises); + + document.querySelectorAll('.duplicateCheckbox').forEach(checkbox => { checkbox.removeAttribute('disabled'); checkbox.checked = false; }); + + location.reload(); +}); + let rescanButton = document.getElementById('metadatarescan'); rescanButton.addEventListener("click", (e) => { rescanButton.setAttribute('disabled', 'disabled'); @@ -586,6 +639,136 @@ function renderContent() { document.getElementById('dataObjectMetadataMap').appendChild(newMetadataMapTable); } + if (loadDuplicates) { + fetch('/api/v1/DataObjects/' + pageType + '/' + getQueryString('id', 'int') + '/Duplicates', { + method: 'GET' + }).then(async function (response) { + if (!response.ok) { + throw new Error('Failed to fetch duplicate data objects'); + } + return response.json(); + }).then(function (success) { + if (success.objects.length > 0) { + document.getElementById('dataObjectDuplicates').style.display = ''; + + let duplicatesTable = document.createElement('table'); + + // add header row + let headerRow = document.createElement('tr'); + + let checkHeader = document.createElement('th'); + checkHeader.classList.add('tableheadcell'); + checkHeader.innerHTML = ''; + let selectAllCheckbox = checkHeader.querySelector('#selectAllDuplicates'); + selectAllCheckbox.addEventListener('change', function () { + let checkboxes = document.querySelectorAll('.duplicateCheckbox'); + checkboxes.forEach(function (checkbox) { + checkbox.checked = selectAllCheckbox.checked; + }); + let mergeButton = document.getElementById('dataObjectMergeDuplicates'); + if (selectAllCheckbox.checked) { + mergeButton.removeAttribute('disabled'); + } else { + mergeButton.setAttribute('disabled', 'disabled'); + } + }); + headerRow.appendChild(checkHeader); + + let idHeader = document.createElement('th'); + idHeader.classList.add('tableheadcell'); + idHeader.innerHTML = 'ID'; + headerRow.appendChild(idHeader); + + let nameHeader = document.createElement('th'); + nameHeader.classList.add('tableheadcell'); + nameHeader.innerHTML = lang.getLang('name'); + headerRow.appendChild(nameHeader); + + if (pageType == "game") { + let platformHeader = document.createElement('th'); + platformHeader.classList.add('tableheadcell'); + platformHeader.innerHTML = lang.getLang('platform'); + headerRow.appendChild(platformHeader); + + let publisherHeader = document.createElement('th'); + publisherHeader.classList.add('tableheadcell'); + publisherHeader.innerHTML = lang.getLang('publisher'); + headerRow.appendChild(publisherHeader); + } + + duplicatesTable.appendChild(headerRow); + + success.objects.forEach(element => { + let row = document.createElement('tr'); + row.classList.add('duplicateRow'); + row.setAttribute('data-id', element.id); + + let checkCell = document.createElement('td'); + checkCell.innerHTML = ''; + let checkbox = checkCell.querySelector('.duplicateCheckbox'); + checkbox.addEventListener('change', function () { + let selectAllCheckbox = checkHeader.querySelector('#selectAllDuplicates'); + let selectedCheckboxes = document.querySelectorAll('.duplicateCheckbox:checked'); + let mergeButton = document.getElementById('dataObjectMergeDuplicates'); + if (selectedCheckboxes.length >= 1) { + mergeButton.removeAttribute('disabled'); + if (selectedCheckboxes.length === document.querySelectorAll('.duplicateCheckbox').length) { + selectAllCheckbox.checked = true; + } else { + selectAllCheckbox.checked = false; + } + } else { + mergeButton.setAttribute('disabled', 'disabled'); + selectAllCheckbox.checked = false; + } + }); + checkCell.classList.add('tablecell'); + row.appendChild(checkCell); + + let idCell = document.createElement('td'); + idCell.innerHTML = element.id; + idCell.classList.add('tablecell'); + row.appendChild(idCell); + + let nameCell = document.createElement('td'); + nameCell.innerHTML = '' + element.name + ''; + nameCell.classList.add('tablecell'); + row.appendChild(nameCell); + + if (pageType == "game") { + let platformCell = document.createElement('td'); + platformCell.classList.add('tablecell'); + let platformAttribute = element.attributes.find(attr => attr.attributeName === 'Platform'); + if (platformAttribute) { + platformCell.innerHTML = platformAttribute.value.name; + } else { + platformCell.innerHTML = ""; + } + row.appendChild(platformCell); + + let publisherCell = document.createElement('td'); + publisherCell.classList.add('tablecell'); + let publisherAttribute = element.attributes.find(attr => attr.attributeName === 'Publisher'); + if (publisherAttribute) { + publisherCell.innerHTML = publisherAttribute.value.name; + } else { + publisherCell.innerHTML = ""; + } + row.appendChild(publisherCell); + } + + duplicatesTable.appendChild(row); + }); + + document.getElementById('dataObjectDuplicates').appendChild(duplicatesTable); + } + }, + function (error) { + console.warn(error); + } + ); + } + switch (pageType) { case "platform": let linkedGamesSection = document.getElementById('dataObjectLinkedGames');